From f5058fb92f9a69a7334f2d2562320f1a1eae673b Mon Sep 17 00:00:00 2001 From: Radu Mihai Gheorghe Date: Mon, 6 Apr 2026 13:59:09 +0300 Subject: [PATCH 001/191] feat: add entrypoint autodiscover (#1431) --- packages/uipath/pyproject.toml | 2 +- packages/uipath/src/uipath/_cli/_errors.py | 20 ++++ packages/uipath/src/uipath/_cli/cli_eval.py | 68 ++++++------ packages/uipath/src/uipath/_cli/cli_run.py | 41 ++++++- packages/uipath/tests/cli/test_run.py | 117 ++++++++++++++++++-- packages/uipath/uv.lock | 2 +- 6 files changed, 199 insertions(+), 51 deletions(-) create mode 100644 packages/uipath/src/uipath/_cli/_errors.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 49a172d07..a96ce081a 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.40" +version = "2.10.41" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/_errors.py b/packages/uipath/src/uipath/_cli/_errors.py new file mode 100644 index 000000000..feb7006a4 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_errors.py @@ -0,0 +1,20 @@ +class EntrypointDiscoveryException(Exception): + """Raised when entrypoint auto-discovery fails.""" + + def __init__(self, entrypoints: list[str]): + self.entrypoints = entrypoints + + def get_usage_help(self) -> list[str]: + if self.entrypoints: + lines = ["Available entrypoints:"] + for name in self.entrypoints: + lines.append(f" - {name}") + return lines + return [ + "No entrypoints found.", + "", + "To configure entrypoints, use one of the following:", + " 1. Functions project (uipath.json)", + " 2. Framework-specific project (e.g. langgraph.json, llamaindex.json, openai_agents.json)", + " 3. MCP project (mcp.json)", + ] diff --git a/packages/uipath/src/uipath/_cli/cli_eval.py b/packages/uipath/src/uipath/_cli/cli_eval.py index d0bdc730c..ef1edb200 100644 --- a/packages/uipath/src/uipath/_cli/cli_eval.py +++ b/packages/uipath/src/uipath/_cli/cli_eval.py @@ -8,6 +8,7 @@ import click +from uipath._cli._errors import EntrypointDiscoveryException from uipath._cli._evals._console_progress_reporter import ConsoleProgressReporter from uipath._cli._evals._progress_reporter import StudioWebProgressReporter from uipath._cli._evals._telemetry import EvalTelemetrySubscriber @@ -135,13 +136,35 @@ def _resolve_model_settings_override( return override if override else None -class _EvalDiscoveryError(Exception): +class _EvalDiscoveryError(EntrypointDiscoveryException): """Raised when auto-discovery of entrypoint or eval set fails.""" def __init__(self, entrypoints: list[str], eval_sets: list[Path]): - self.entrypoints = entrypoints + super().__init__(entrypoints) self.eval_sets = eval_sets + def get_usage_help(self) -> list[str]: + lines = super().get_usage_help() + + if self.eval_sets: + lines.append("") + lines.append("Available eval sets:") + for f in self.eval_sets: + lines.append(f" - {f}") + else: + lines.append("") + lines.append( + f"No eval sets found in '{EVAL_SETS_DIRECTORY_NAME}/' directory." + ) + + lines.append("") + lines.append("Usage: uipath eval ") + if self.entrypoints and self.eval_sets: + lines.append( + f"Example: uipath eval {self.entrypoints[0]} {self.eval_sets[0]}" + ) + return lines + def _discover_eval_sets() -> list[Path]: """Discover available eval set files.""" @@ -151,39 +174,6 @@ def _discover_eval_sets() -> list[Path]: return [] -def _show_eval_usage_help(entrypoints: list[str], eval_set_files: list[Path]) -> None: - """Show available entrypoints and eval sets with usage examples.""" - lines: list[str] = [] - - if entrypoints: - lines.append("Available entrypoints:") - for name in entrypoints: - lines.append(f" - {name}") - else: - lines.append( - "No entrypoints found. " - "Add a 'functions' or 'agents' section to your config file " - "(e.g. uipath.json, langgraph.json)." - ) - - if eval_set_files: - lines.append("\nAvailable eval sets:") - for f in eval_set_files: - lines.append(f" - {f}") - else: - lines.append( - f"\nNo eval sets found in '{EVAL_SETS_DIRECTORY_NAME}/' directory." - ) - - lines.append("\nUsage: uipath eval ") - if entrypoints and eval_set_files: - ep_name = entrypoints[0] - es_path = eval_set_files[0] - lines.append(f"Example: uipath eval {ep_name} {es_path}") - - click.echo("\n".join(lines)) - - @click.command() @click.argument("entrypoint", required=False) @click.argument("eval_set", required=False) @@ -475,7 +465,13 @@ async def execute_eval(): asyncio.run(execute_eval()) except _EvalDiscoveryError as e: - _show_eval_usage_help(e.entrypoints, e.eval_sets) + click.echo("\n".join(e.get_usage_help())) + if not e.entrypoints: + click.echo() + console.link( + "uipath.json spec:", + "https://github.com/UiPath/uipath-python/blob/main/packages/uipath/specs/uipath.spec.md", + ) except ValueError as e: console.error(str(e)) except Exception as e: diff --git a/packages/uipath/src/uipath/_cli/cli_run.py b/packages/uipath/src/uipath/_cli/cli_run.py index 6b5152ed4..5bfd811ed 100644 --- a/packages/uipath/src/uipath/_cli/cli_run.py +++ b/packages/uipath/src/uipath/_cli/cli_run.py @@ -27,6 +27,7 @@ LlmOpsHttpExporter, ) +from ._errors import EntrypointDiscoveryException from ._telemetry import track_command from ._utils._console import ConsoleLogger from .middlewares import Middlewares @@ -34,6 +35,21 @@ console = ConsoleLogger() +class _RunDiscoveryError(EntrypointDiscoveryException): + """Raised when entrypoint auto-discovery fails.""" + + def get_usage_help(self) -> list[str]: + lines = super().get_usage_help() + lines.append("") + lines.append( + "Usage: uipath run " + " [-f ]" + ) + if self.entrypoints: + lines.append(f"Example: uipath run {self.entrypoints[0]}") + return lines + + @click.command() @click.argument("entrypoint", required=False) @click.argument("input", required=False, default=None) @@ -125,11 +141,6 @@ def run( return if result.should_continue: - if not entrypoint: - console.error("""No entrypoint specified. Please provide the path to the Python function. - Usage: `uipath run [-f ]`""") - return - try: async def execute_runtime( @@ -187,6 +198,15 @@ async def execute() -> None: factory: UiPathRuntimeFactoryProtocol | None = None try: factory = UiPathRuntimeFactoryRegistry.get(context=ctx) + + resolved_entrypoint = entrypoint + if not resolved_entrypoint: + available = factory.discover_entrypoints() + if len(available) == 1: + resolved_entrypoint = available[0] + else: + raise _RunDiscoveryError(available) + factory_settings = await factory.get_settings() trace_settings = ( factory_settings.trace_settings @@ -194,7 +214,7 @@ async def execute() -> None: else None ) runtime = await factory.new_runtime( - entrypoint, + resolved_entrypoint, ctx.conversation_id or ctx.job_id or "default", ) @@ -230,6 +250,15 @@ async def execute() -> None: asyncio.run(execute()) + except _RunDiscoveryError as e: + click.echo("\n".join(e.get_usage_help())) + if not e.entrypoints: + click.echo() + console.link( + "uipath.json spec:", + "https://github.com/UiPath/uipath-python/blob/main/packages/uipath/specs/uipath.spec.md", + ) + return except UiPathRuntimeError as e: console.error(f"{e.error_info.title} - {e.error_info.detail}") except Exception as e: diff --git a/packages/uipath/tests/cli/test_run.py b/packages/uipath/tests/cli/test_run.py index 9069c5426..479fc9953 100644 --- a/packages/uipath/tests/cli/test_run.py +++ b/packages/uipath/tests/cli/test_run.py @@ -1,6 +1,7 @@ # type: ignore import os -from unittest.mock import patch +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, Mock, patch import pytest from click.testing import CliRunner @@ -9,6 +10,41 @@ from uipath._cli.middlewares import MiddlewareResult +def _middleware_continue(): + return MiddlewareResult( + should_continue=True, + error_message=None, + should_include_stacktrace=False, + ) + + +async def _empty_async_gen(*args, **kwargs): + """An async generator that yields nothing (simulates empty runtime.stream).""" + if False: # pragma: no cover + yield + + +def _make_mock_factory(entrypoints: list[str]): + """Create a mock runtime factory with given entrypoints.""" + mock_factory = Mock() + mock_factory.discover_entrypoints.return_value = entrypoints + mock_factory.get_settings = AsyncMock(return_value=None) + mock_factory.dispose = AsyncMock() + + mock_runtime = Mock() + mock_runtime.execute = AsyncMock(return_value=Mock(status="SUCCESSFUL")) + mock_runtime.stream = Mock(side_effect=_empty_async_gen) + mock_runtime.dispose = AsyncMock() + mock_factory.new_runtime = AsyncMock(return_value=mock_runtime) + + return mock_factory + + +@asynccontextmanager +async def _mock_resource_overwrites_context(*args, **kwargs): + yield + + @pytest.fixture def entrypoint(): return "main" @@ -142,14 +178,81 @@ def test_run_input_file_success( assert "Successful execution." in result.output class TestMiddleware: - def test_no_entrypoint(self, runner: CliRunner, temp_dir: str): + def test_autodiscover_entrypoint(self, runner: CliRunner, temp_dir: str): + """When exactly one entrypoint exists, it is auto-resolved.""" with runner.isolated_filesystem(temp_dir=temp_dir): - result = runner.invoke(cli, ["run"]) - assert result.exit_code == 1 - assert ( - "No entrypoint specified" in result.output - or "Missing argument" in result.output + mock_factory = _make_mock_factory(["my_agent"]) + + with ( + patch( + "uipath._cli.cli_run.Middlewares.next", + return_value=_middleware_continue(), + ), + patch( + "uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get", + return_value=mock_factory, + ), + patch( + "uipath._cli.cli_run.ResourceOverwritesContext", + side_effect=_mock_resource_overwrites_context, + ), + ): + result = runner.invoke(cli, ["run"]) + + assert result.exit_code == 0, ( + f"output: {result.output!r}, exception: {result.exception}" ) + assert "Successful execution." in result.output + mock_factory.new_runtime.assert_awaited_once() + assert mock_factory.new_runtime.call_args[0][0] == "my_agent" + + def test_no_entrypoint_multiple_available( + self, runner: CliRunner, temp_dir: str + ): + """When multiple entrypoints exist and none specified, show usage help.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + mock_factory = _make_mock_factory(["agent_a", "agent_b"]) + + with ( + patch( + "uipath._cli.cli_run.Middlewares.next", + return_value=_middleware_continue(), + ), + patch( + "uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get", + return_value=mock_factory, + ), + ): + result = runner.invoke(cli, ["run"]) + + assert result.exit_code == 0 + assert "Available entrypoints:" in result.output + assert "agent_a" in result.output + assert "agent_b" in result.output + assert "Usage: uipath run" in result.output + mock_factory.new_runtime.assert_not_awaited() + + def test_no_entrypoint_none_available(self, runner: CliRunner, temp_dir: str): + """When no entrypoints exist and none specified, show usage help.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + mock_factory = _make_mock_factory([]) + + with ( + patch( + "uipath._cli.cli_run.Middlewares.next", + return_value=_middleware_continue(), + ), + patch( + "uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get", + return_value=mock_factory, + ), + ): + result = runner.invoke(cli, ["run"]) + + assert result.exit_code == 0 + assert "No entrypoints found" in result.output + assert "Usage: uipath run" in result.output + mock_factory.new_runtime.assert_not_awaited() def test_script_not_found( self, runner: CliRunner, temp_dir: str, entrypoint: str diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 77434aaa8..73c7124f8 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.40" +version = "2.10.41" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 11e86d4c9dce3ae2157daccd6f4f998f0c6f74d5 Mon Sep 17 00:00:00 2001 From: UIPath-Harshit Date: Mon, 6 Apr 2026 16:54:43 +0530 Subject: [PATCH 002/191] Fix/entity as resource overwrite (#1544) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/_uipath.py | 4 +- .../src/uipath/platform/common/__init__.py | 2 + .../src/uipath/platform/common/_bindings.py | 20 +- .../platform/entities/_entities_service.py | 186 ++++++++++++++++-- .../tests/services/test_entities_service.py | 185 +++++++++++++---- packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 2 +- .../src/uipath/_resources/SDK_REFERENCE.md | 4 +- .../tests/resource_overrides/overwrites.json | 4 + .../test_resource_overrides.py | 5 + packages/uipath/uv.lock | 4 +- 12 files changed, 357 insertions(+), 63 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index ba5634ef1..02d5e25d6 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.18" +version = "0.1.19" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index 87c3a17f0..e1d60fc39 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -139,7 +139,9 @@ def llm(self) -> UiPathLlmChatService: @property def entities(self) -> EntitiesService: - return EntitiesService(self._config, self._execution_context) + return EntitiesService( + self._config, self._execution_context, folders_service=self.folders + ) @cached_property def resource_catalog(self) -> ResourceCatalogService: diff --git a/packages/uipath-platform/src/uipath/platform/common/__init__.py b/packages/uipath-platform/src/uipath/platform/common/__init__.py index 40fc1ac34..9070d0d70 100644 --- a/packages/uipath-platform/src/uipath/platform/common/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/common/__init__.py @@ -7,6 +7,7 @@ from ._base_service import BaseService from ._bindings import ( ConnectionResourceOverwrite, + EntityResourceOverwrite, GenericResourceOverwrite, ResourceOverwrite, ResourceOverwriteParser, @@ -100,6 +101,7 @@ "EndpointManager", "jsonschema_to_pydantic", "ConnectionResourceOverwrite", + "EntityResourceOverwrite", "GenericResourceOverwrite", "ResourceOverwrite", "ResourceOverwriteParser", diff --git a/packages/uipath-platform/src/uipath/platform/common/_bindings.py b/packages/uipath-platform/src/uipath/platform/common/_bindings.py index 449d2a7ef..1ccb2b1fc 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_bindings.py +++ b/packages/uipath-platform/src/uipath/platform/common/_bindings.py @@ -45,7 +45,7 @@ def folder_identifier(self) -> str: class GenericResourceOverwrite(ResourceOverwrite): resource_type: Literal[ - "process", "index", "app", "asset", "bucket", "mcpServer", "queue", "entity" + "process", "index", "app", "asset", "bucket", "mcpServer", "queue" ] name: str = Field(alias="name") folder_path: str = Field(alias="folderPath") @@ -59,6 +59,20 @@ def folder_identifier(self) -> str: return self.folder_path +class EntityResourceOverwrite(ResourceOverwrite): + resource_type: Literal["entity"] + name: str = Field(alias="name") + folder_key: str = Field(alias="folderId") + + @property + def resource_identifier(self) -> str: + return self.name + + @property + def folder_identifier(self) -> str: + return self.folder_key + + class ConnectionResourceOverwrite(ResourceOverwrite): resource_type: Literal["connection"] # In eval context, studio web provides "ConnectionId". @@ -83,7 +97,9 @@ def folder_identifier(self) -> str: ResourceOverwriteUnion = Annotated[ - Union[GenericResourceOverwrite, ConnectionResourceOverwrite], + Union[ + GenericResourceOverwrite, EntityResourceOverwrite, ConnectionResourceOverwrite + ], Field(discriminator="resource_type"), ] diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py index f30c9492e..dc08131c5 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py @@ -1,3 +1,4 @@ +import logging from typing import Any, Dict, List, Optional, Type import sqlparse @@ -7,16 +8,21 @@ from uipath.core.tracing import traced from ..common._base_service import BaseService +from ..common._bindings import EntityResourceOverwrite, _resource_overwrites from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext from ..common._models import Endpoint, RequestSpec +from ..orchestrator._folder_service import FolderService from .entities import ( Entity, EntityRecord, EntityRecordsBatchResponse, + EntityRouting, QueryRoutingOverrideContext, ) +logger = logging.getLogger(__name__) + _FORBIDDEN_DML = {"INSERT", "UPDATE", "DELETE", "MERGE", "REPLACE"} _FORBIDDEN_DDL = {"DROP", "ALTER", "CREATE", "TRUNCATE"} _DISALLOWED_KEYWORDS = [ @@ -47,9 +53,32 @@ class EntitiesService(BaseService): """ def __init__( - self, config: UiPathApiConfig, execution_context: UiPathExecutionContext + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Optional[FolderService] = None, + folders_map: Optional[Dict[str, str]] = None, ) -> None: super().__init__(config=config, execution_context=execution_context) + self._folders_service = folders_service + self._folders_map = folders_map or {} + + def with_folders_map(self, folders_map: Dict[str, str]) -> "EntitiesService": + """Return a new EntitiesService configured with the given folders map. + + The map is used to build a routing context automatically when + ``query_entity_records`` is called without an explicit routing context. + Folder paths in the map are resolved to folder keys via ``FolderService``. + + Args: + folders_map: Mapping of entity name to folder path. + """ + return EntitiesService( + config=self._config, + execution_context=self._execution_context, + folders_service=self._folders_service, + folders_map=folders_map, + ) @traced(name="entity_retrieve", run_type="uipath") def retrieve(self, entity_key: str) -> Entity: @@ -417,7 +446,6 @@ class CustomerRecord: def query_entity_records( self, sql_query: str, - routing_context: Optional[QueryRoutingOverrideContext] = None, ) -> List[Dict[str, Any]]: """Query entity records using a validated SQL query. @@ -427,9 +455,10 @@ def query_entity_records( sql_query (str): A SQL SELECT query to execute against Data Service entities. Only SELECT statements are allowed. Queries without WHERE must include a LIMIT clause. Subqueries and multi-statement queries are not permitted. - routing_context (Optional[QueryRoutingOverrideContext]): Per-entity routing context - for multi-folder queries. When present, included in the request body - and takes precedence over the folder header on the backend. + + Notes: + A routing context is always derived from the configured ``folders_map`` + when present and included in the request body. Returns: List[Dict[str, Any]]: A list of result records as dictionaries. @@ -438,15 +467,12 @@ def query_entity_records( ValueError: If the SQL query fails validation (e.g., non-SELECT, missing WHERE/LIMIT, forbidden keywords, subqueries). """ - return self._query_entities_for_records( - sql_query, routing_context=routing_context - ) + return self._query_entities_for_records(sql_query) @traced(name="entity_query_records", run_type="uipath") async def query_entity_records_async( self, sql_query: str, - routing_context: Optional[QueryRoutingOverrideContext] = None, ) -> List[Dict[str, Any]]: """Asynchronously query entity records using a validated SQL query. @@ -456,9 +482,10 @@ async def query_entity_records_async( sql_query (str): A SQL SELECT query to execute against Data Service entities. Only SELECT statements are allowed. Queries without WHERE must include a LIMIT clause. Subqueries and multi-statement queries are not permitted. - routing_context (Optional[QueryRoutingOverrideContext]): Per-entity routing context - for multi-folder queries. When present, included in the request body - and takes precedence over the folder header on the backend. + + Notes: + A routing context is always derived from the configured ``folders_map`` + when present and included in the request body. Returns: List[Dict[str, Any]]: A list of result records as dictionaries. @@ -467,17 +494,14 @@ async def query_entity_records_async( ValueError: If the SQL query fails validation (e.g., non-SELECT, missing WHERE/LIMIT, forbidden keywords, subqueries). """ - return await self._query_entities_for_records_async( - sql_query, routing_context=routing_context - ) + return await self._query_entities_for_records_async(sql_query) def _query_entities_for_records( self, sql_query: str, - *, - routing_context: Optional[QueryRoutingOverrideContext] = None, ) -> List[Dict[str, Any]]: self._validate_sql_query(sql_query) + routing_context = self._build_routing_context_from_map() spec = self._query_entity_records_spec(sql_query, routing_context) response = self.request(spec.method, spec.endpoint, json=spec.json) return response.json().get("results", []) @@ -485,10 +509,9 @@ def _query_entities_for_records( async def _query_entities_for_records_async( self, sql_query: str, - *, - routing_context: Optional[QueryRoutingOverrideContext] = None, ) -> List[Dict[str, Any]]: self._validate_sql_query(sql_query) + routing_context = await self._build_routing_context_from_map_async() spec = self._query_entity_records_spec(sql_query, routing_context) response = await self.request_async(spec.method, spec.endpoint, json=spec.json) return response.json().get("results", []) @@ -992,6 +1015,131 @@ def _query_entity_records_spec( json=body, ) + def _build_routing_context_from_map( + self, + ) -> Optional[QueryRoutingOverrideContext]: + """Build a routing context from the configured folders_map and context overwrites. + + Folder paths in the map are resolved to folder keys via FolderService. + Entity overwrites from the active ``ResourceOverwritesContext`` are + merged in, supplying ``override_entity_name`` when the overwrite + provides a different entity name. + + Returns: + A QueryRoutingOverrideContext if routing entries exist, + None otherwise. + """ + resolved = self._resolve_folder_paths_to_ids() + return self._build_routing_context_from_resolved_map(resolved) + + async def _build_routing_context_from_map_async( + self, + ) -> Optional[QueryRoutingOverrideContext]: + """Async version of _build_routing_context_from_map.""" + resolved = await self._resolve_folder_paths_to_ids_async() + return self._build_routing_context_from_resolved_map(resolved) + + def _resolve_folder_paths_to_ids(self) -> Optional[dict[str, str]]: + if not self._folders_map: + return None + + resolved: dict[str, str] = {} + for folder_path in set(self._folders_map.values()): + if self._folders_service is not None: + folder_key = self._folders_service.retrieve_folder_key(folder_path) + if folder_key is not None: + resolved[folder_path] = folder_key + continue + resolved[folder_path] = folder_path + + return resolved + + async def _resolve_folder_paths_to_ids_async(self) -> Optional[dict[str, str]]: + if not self._folders_map: + return None + + resolved: dict[str, str] = {} + for folder_path in set(self._folders_map.values()): + if self._folders_service is not None: + folder_key = await self._folders_service.retrieve_folder_key_async( + folder_path + ) + if folder_key is not None: + resolved[folder_path] = folder_key + continue + resolved[folder_path] = folder_path + + return resolved + + @staticmethod + def _get_entity_overwrites_from_context() -> Dict[str, EntityResourceOverwrite]: + """Extract entity overwrites from the active ResourceOverwritesContext. + + Returns: + A dict mapping original entity name to its EntityResourceOverwrite. + """ + context_overwrites = _resource_overwrites.get() + if not context_overwrites: + return {} + + result: Dict[str, EntityResourceOverwrite] = {} + for key, overwrite in context_overwrites.items(): + if isinstance(overwrite, EntityResourceOverwrite): + # Key format is "entity." + original_name = key.split(".", 1)[1] if "." in key else key + result[original_name] = overwrite + return result + + def _build_routing_context_from_resolved_map( + self, + resolved: Optional[dict[str, str]], + ) -> Optional[QueryRoutingOverrideContext]: + entity_overwrites = self._get_entity_overwrites_from_context() + + routings: List[EntityRouting] = [] + + # Add routings from folders_map + if self._folders_map and resolved is not None: + for name, folder_path in self._folders_map.items(): + overwrite = entity_overwrites.pop(name, None) + override_name = ( + overwrite.resource_identifier + if overwrite and overwrite.resource_identifier != name + else None + ) + folder_id = ( + overwrite.folder_identifier + if overwrite + else resolved.get(folder_path, folder_path) + ) + routings.append( + EntityRouting( + entity_name=name, + folder_id=folder_id, + override_entity_name=override_name, + ) + ) + + # Add routings from context overwrites not already in folders_map + for original_name, overwrite in entity_overwrites.items(): + override_name = ( + overwrite.resource_identifier + if overwrite.resource_identifier != original_name + else None + ) + routings.append( + EntityRouting( + entity_name=original_name, + folder_id=overwrite.folder_identifier, + override_entity_name=override_name, + ) + ) + + if not routings: + return None + + return QueryRoutingOverrideContext(entity_routings=routings) + def _insert_batch_spec(self, entity_key: str, records: List[Any]) -> RequestSpec: return RequestSpec( method="POST", diff --git a/packages/uipath-platform/tests/services/test_entities_service.py b/packages/uipath-platform/tests/services/test_entities_service.py index 8a9abafef..4993a1367 100644 --- a/packages/uipath-platform/tests/services/test_entities_service.py +++ b/packages/uipath-platform/tests/services/test_entities_service.py @@ -8,7 +8,7 @@ from pytest_httpx import HTTPXMock from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.entities import Entity, EntityRouting, QueryRoutingOverrideContext +from uipath.platform.entities import Entity from uipath.platform.entities._entities_service import EntitiesService @@ -390,28 +390,21 @@ async def test_query_entity_records_async_calls_request_for_valid_sql( assert result == [{"id": "c1"}] service.request_async.assert_called_once() - def test_query_entity_records_with_routing_context( + def test_query_entity_records_builds_routing_context_from_folders_map( self, - service: EntitiesService, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, ) -> None: + service = EntitiesService( + config=config, + execution_context=execution_context, + folders_map={"Customers": "solution_folder", "Orders": "folder-2"}, + ) response = MagicMock() response.json.return_value = {"results": [{"id": 1}]} service.request = MagicMock(return_value=response) # type: ignore[method-assign] - routing = QueryRoutingOverrideContext( - entity_routings=[ - EntityRouting(entity_name="Customers", folder_id="folder-1"), - EntityRouting( - entity_name="Orders", - folder_id="folder-2", - override_entity_name="OrdersV2", - ), - ] - ) - - result = service.query_entity_records( - "SELECT id FROM Customers LIMIT 10", routing_context=routing - ) + result = service.query_entity_records("SELECT id FROM Customers LIMIT 10") assert result == [{"id": 1}] call_kwargs = service.request.call_args @@ -419,39 +412,38 @@ def test_query_entity_records_with_routing_context( assert body["query"] == "SELECT id FROM Customers LIMIT 10" assert body["routingContext"] == { "entityRoutings": [ - {"entityName": "Customers", "folderId": "folder-1"}, - { - "entityName": "Orders", - "folderId": "folder-2", - "overrideEntityName": "OrdersV2", - }, + {"entityName": "Customers", "folderId": "solution_folder"}, + {"entityName": "Orders", "folderId": "folder-2"}, ] } @pytest.mark.anyio - async def test_query_entity_records_async_with_routing_context( + async def test_query_entity_records_async_builds_routing_context_from_folders_map( self, - service: EntitiesService, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, ) -> None: + service = EntitiesService( + config=config, + execution_context=execution_context, + folders_map={"Customers": "solution_folder"}, + ) response = MagicMock() response.json.return_value = {"results": [{"id": "c1"}]} service.request_async = AsyncMock(return_value=response) # type: ignore[method-assign] - routing = QueryRoutingOverrideContext( - entity_routings=[ - EntityRouting(entity_name="Customers", folder_id="folder-1"), - ] - ) - result = await service.query_entity_records_async( - "SELECT id FROM Customers WHERE id = 'c1'", - routing_context=routing, + "SELECT id FROM Customers WHERE id = 'c1'" ) assert result == [{"id": "c1"}] call_kwargs = service.request_async.call_args body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") - assert "routingContext" in body + assert body["routingContext"] == { + "entityRoutings": [ + {"entityName": "Customers", "folderId": "solution_folder"}, + ] + } def test_query_entity_records_without_routing_context_omits_key( self, @@ -466,3 +458,128 @@ def test_query_entity_records_without_routing_context_omits_key( call_kwargs = service.request.call_args body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") assert "routingContext" not in body + + def test_query_entity_records_picks_up_entity_overwrites_from_context( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + from uipath.platform.common._bindings import ( + EntityResourceOverwrite, + _resource_overwrites, + ) + + service = EntitiesService( + config=config, + execution_context=execution_context, + ) + response = MagicMock() + response.json.return_value = {"results": [{"id": 1}]} + service.request = MagicMock(return_value=response) # type: ignore[method-assign] + + overwrite = EntityResourceOverwrite( + resource_type="entity", + name="Overwritten Customers", + folder_key="overwritten-folder-id", + ) + token = _resource_overwrites.set({"entity.Customers": overwrite}) + try: + service.query_entity_records("SELECT id FROM Customers LIMIT 10") + finally: + _resource_overwrites.reset(token) + + call_kwargs = service.request.call_args + body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + assert body["routingContext"] == { + "entityRoutings": [ + { + "entityName": "Customers", + "folderId": "overwritten-folder-id", + "overrideEntityName": "Overwritten Customers", + }, + ] + } + + def test_query_entity_records_merges_folders_map_with_context_overwrites( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + from uipath.platform.common._bindings import ( + EntityResourceOverwrite, + _resource_overwrites, + ) + + service = EntitiesService( + config=config, + execution_context=execution_context, + folders_map={"Customers": "original-folder", "Orders": "orders-folder"}, + ) + response = MagicMock() + response.json.return_value = {"results": []} + service.request = MagicMock(return_value=response) # type: ignore[method-assign] + + # Overwrite only Customers — Orders should keep its folders_map value + overwrite = EntityResourceOverwrite( + resource_type="entity", + name="Overwritten Customers", + folder_key="overwritten-folder-id", + ) + token = _resource_overwrites.set({"entity.Customers": overwrite}) + try: + service.query_entity_records("SELECT id FROM Customers LIMIT 10") + finally: + _resource_overwrites.reset(token) + + call_kwargs = service.request.call_args + body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + routings = body["routingContext"]["entityRoutings"] + # Customers overwritten by context + assert { + "entityName": "Customers", + "folderId": "overwritten-folder-id", + "overrideEntityName": "Overwritten Customers", + } in routings + # Orders unchanged from folders_map + assert {"entityName": "Orders", "folderId": "orders-folder"} in routings + + def test_query_entity_records_context_overwrite_same_name_no_override_field( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + from uipath.platform.common._bindings import ( + EntityResourceOverwrite, + _resource_overwrites, + ) + + service = EntitiesService( + config=config, + execution_context=execution_context, + ) + response = MagicMock() + response.json.return_value = {"results": []} + service.request = MagicMock(return_value=response) # type: ignore[method-assign] + + # Same entity name — only folder changes, no override_entity_name needed + overwrite = EntityResourceOverwrite( + resource_type="entity", + name="Customers", + folder_key="different-folder-id", + ) + token = _resource_overwrites.set({"entity.Customers": overwrite}) + try: + service.query_entity_records("SELECT id FROM Customers LIMIT 10") + finally: + _resource_overwrites.reset(token) + + call_kwargs = service.request.call_args + body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + assert body["routingContext"] == { + "entityRoutings": [ + { + "entityName": "Customers", + "folderId": "different-folder-id", + }, + ] + } diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 0ea4a2f63..a9234b560 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.18" +version = "0.1.19" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index a96ce081a..58f523a02 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.41" +version = "2.10.42" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md b/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md index 4af1b60ae..5b03700be 100644 --- a/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md +++ b/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md @@ -500,10 +500,10 @@ sdk.entities.list_records(entity_key: str, schema: Optional[Type[Any]]=None, sta sdk.entities.list_records_async(entity_key: str, schema: Optional[Type[Any]]=None, start: Optional[int]=None, limit: Optional[int]=None) -> typing.List[uipath.platform.entities.entities.EntityRecord] # Query entity records using a validated SQL query. -sdk.entities.query_entity_records(sql_query: str, routing_context: Optional[uipath.platform.entities.entities.QueryRoutingOverrideContext]=None) -> typing.List[typing.Dict[str, typing.Any]] +sdk.entities.query_entity_records(sql_query: str) -> typing.List[typing.Dict[str, typing.Any]] # Asynchronously query entity records using a validated SQL query. -sdk.entities.query_entity_records_async(sql_query: str, routing_context: Optional[uipath.platform.entities.entities.QueryRoutingOverrideContext]=None) -> typing.List[typing.Dict[str, typing.Any]] +sdk.entities.query_entity_records_async(sql_query: str) -> typing.List[typing.Dict[str, typing.Any]] # Retrieve an entity by its key. sdk.entities.retrieve(entity_key: str) -> uipath.platform.entities.entities.Entity diff --git a/packages/uipath/tests/resource_overrides/overwrites.json b/packages/uipath/tests/resource_overrides/overwrites.json index c58744a69..e0bca84ba 100644 --- a/packages/uipath/tests/resource_overrides/overwrites.json +++ b/packages/uipath/tests/resource_overrides/overwrites.json @@ -28,5 +28,9 @@ "mcpServer.mcp_server_name": { "name": "Overwritten MCP Server Name", "folderPath": "Overwritten/MCPServer/Folder" + }, + "entity.entity_name": { + "name": "Overwritten Entity Name", + "folderId": "overwritten-entity-folder-id-123" } } \ No newline at end of file diff --git a/packages/uipath/tests/resource_overrides/test_resource_overrides.py b/packages/uipath/tests/resource_overrides/test_resource_overrides.py index c15bc113b..8d39a762d 100644 --- a/packages/uipath/tests/resource_overrides/test_resource_overrides.py +++ b/packages/uipath/tests/resource_overrides/test_resource_overrides.py @@ -310,6 +310,11 @@ def test_parse_overwrites_with_type_adapter(self, overwrites_data): assert mcp_server.resource_identifier == "Overwritten MCP Server Name" assert mcp_server.folder_identifier == "Overwritten/MCPServer/Folder" + # Verify entity overwrite + entity = parsed_overwrites["entity.entity_name"] + assert entity.resource_identifier == "Overwritten Entity Name" + assert entity.folder_identifier == "overwritten-entity-folder-id-123" + def test_overrides_decorator_should_pop_kwargs_dict_when_present(self): from uipath.platform.common import resource_override diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 73c7124f8..48783e0bb 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.41" +version = "2.10.42" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.18" +version = "0.1.19" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 1917f51456867ee33fa634083d106a8b7c699477 Mon Sep 17 00:00:00 2001 From: Andrei Petraru Date: Mon, 6 Apr 2026 17:30:51 +0300 Subject: [PATCH 003/191] feat(guardrails): add decorator framework to uipath-platform [AL-288] (#1537) Co-authored-by: Claude Sonnet 4.6 --- packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/guardrails/guardrails.py | 2 +- packages/uipath-core/uv.lock | 2 +- packages/uipath-platform/pyproject.toml | 2 +- .../uipath/platform/guardrails/__init__.py | 43 + .../guardrails/decorators/__init__.py | 52 + .../guardrails/decorators/_actions.py | 82 ++ .../platform/guardrails/decorators/_core.py | 302 +++++ .../platform/guardrails/decorators/_enums.py | 44 + .../guardrails/decorators/_exceptions.py | 18 + .../guardrails/decorators/_guardrail.py | 224 ++++ .../platform/guardrails/decorators/_models.py | 59 + .../guardrails/decorators/_registry.py | 105 ++ .../decorators/validators/__init__.py | 20 + .../guardrails/decorators/validators/_base.py | 182 +++ .../decorators/validators/custom.py | 125 ++ .../guardrails/decorators/validators/pii.py | 76 ++ .../decorators/validators/prompt_injection.py | 65 + .../services/test_guardrails_decorators.py | 1171 +++++++++++++++++ packages/uipath-platform/uv.lock | 4 +- .../uipath/tests/agent/models/test_agent.py | 1 + packages/uipath/uv.lock | 4 +- 22 files changed, 2577 insertions(+), 8 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/guardrails/decorators/_actions.py create mode 100644 packages/uipath-platform/src/uipath/platform/guardrails/decorators/_core.py create mode 100644 packages/uipath-platform/src/uipath/platform/guardrails/decorators/_enums.py create mode 100644 packages/uipath-platform/src/uipath/platform/guardrails/decorators/_exceptions.py create mode 100644 packages/uipath-platform/src/uipath/platform/guardrails/decorators/_guardrail.py create mode 100644 packages/uipath-platform/src/uipath/platform/guardrails/decorators/_models.py create mode 100644 packages/uipath-platform/src/uipath/platform/guardrails/decorators/_registry.py create mode 100644 packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/_base.py create mode 100644 packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/custom.py create mode 100644 packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/pii.py create mode 100644 packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/prompt_injection.py create mode 100644 packages/uipath-platform/tests/services/test_guardrails_decorators.py diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 5604e3938..1a5c34ab7 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.10" +version = "0.5.11" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/guardrails/guardrails.py b/packages/uipath-core/src/uipath/core/guardrails/guardrails.py index fe651c35b..fc7102904 100644 --- a/packages/uipath-core/src/uipath/core/guardrails/guardrails.py +++ b/packages/uipath-core/src/uipath/core/guardrails/guardrails.py @@ -227,7 +227,7 @@ class BaseGuardrail(BaseModel): name: str description: str | None = None enabled_for_evals: bool = Field(True, alias="enabledForEvals") - selector: GuardrailSelector + selector: GuardrailSelector | None = None model_config = ConfigDict(populate_by_name=True, extra="allow") diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index 2544216df..e17802fc7 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -1007,7 +1007,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.10" +version = "0.5.11" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 02d5e25d6..748075eb4 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.19" +version = "0.1.20" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py b/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py index ffab74581..de439e92a 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py @@ -14,6 +14,27 @@ ) from ._guardrails_service import GuardrailsService +from .decorators import ( + BlockAction, + BuiltInGuardrailValidator, + CustomGuardrailValidator, + CustomValidator, + GuardrailAction, + GuardrailBlockException, + GuardrailExclude, + GuardrailExecutionStage, + GuardrailTargetAdapter, + GuardrailValidatorBase, + LogAction, + LoggingSeverityLevel, + PIIDetectionEntity, + PIIDetectionEntityType, + PIIValidator, + PromptInjectionValidator, + RuleFunction, + guardrail, + register_guardrail_adapter, +) from .guardrails import ( BuiltInValidatorGuardrail, EnumListParameterValue, @@ -22,7 +43,9 @@ ) __all__ = [ + # Service "GuardrailsService", + # Guardrail models "BuiltInValidatorGuardrail", "GuardrailType", "GuardrailValidationResultType", @@ -33,4 +56,24 @@ "GuardrailValidationResult", "EnumListParameterValue", "MapEnumParameterValue", + # Decorator framework + "guardrail", + "GuardrailValidatorBase", + "BuiltInGuardrailValidator", + "CustomGuardrailValidator", + "PIIValidator", + "PromptInjectionValidator", + "CustomValidator", + "RuleFunction", + "PIIDetectionEntity", + "PIIDetectionEntityType", + "GuardrailExecutionStage", + "GuardrailAction", + "LogAction", + "BlockAction", + "LoggingSeverityLevel", + "GuardrailBlockException", + "GuardrailExclude", + "GuardrailTargetAdapter", + "register_guardrail_adapter", ] diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py new file mode 100644 index 000000000..727925fb1 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py @@ -0,0 +1,52 @@ +"""Guardrail decorator framework for UiPath Platform. + +Provides the ``@guardrail`` decorator, built-in validators, actions, and an +adapter registry that framework integrations (e.g. *uipath-langchain*) use to +teach the decorator how to wrap their specific object types. +""" + +from ._actions import BlockAction, LogAction, LoggingSeverityLevel +from ._core import GuardrailExclude +from ._enums import GuardrailExecutionStage, PIIDetectionEntityType +from ._exceptions import GuardrailBlockException +from ._guardrail import guardrail +from ._models import GuardrailAction, PIIDetectionEntity +from ._registry import GuardrailTargetAdapter, register_guardrail_adapter +from .validators import ( + BuiltInGuardrailValidator, + CustomGuardrailValidator, + CustomValidator, + GuardrailValidatorBase, + PIIValidator, + PromptInjectionValidator, + RuleFunction, +) + +__all__ = [ + # Decorator + "guardrail", + # Validators + "GuardrailValidatorBase", + "BuiltInGuardrailValidator", + "CustomGuardrailValidator", + "PIIValidator", + "PromptInjectionValidator", + "CustomValidator", + "RuleFunction", + # Models & enums + "PIIDetectionEntity", + "PIIDetectionEntityType", + "GuardrailExecutionStage", + "GuardrailAction", + # Actions + "LogAction", + "BlockAction", + "LoggingSeverityLevel", + # Exception + "GuardrailBlockException", + # Exclude marker + "GuardrailExclude", + # Adapter registry + "GuardrailTargetAdapter", + "register_guardrail_adapter", +] diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_actions.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_actions.py new file mode 100644 index 000000000..8e6489797 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_actions.py @@ -0,0 +1,82 @@ +"""Built-in GuardrailAction implementations.""" + +import logging +from dataclasses import dataclass +from enum import Enum +from typing import Any, Optional + +from uipath.core.guardrails import ( + GuardrailValidationResult, + GuardrailValidationResultType, +) + +from ._exceptions import GuardrailBlockException +from ._models import GuardrailAction + + +class LoggingSeverityLevel(int, Enum): + """Logging severity level for :class:`LogAction`.""" + + ERROR = logging.ERROR + INFO = logging.INFO + WARNING = logging.WARNING + DEBUG = logging.DEBUG + + +@dataclass +class LogAction(GuardrailAction): + """Log guardrail violations without stopping execution. + + Args: + severity_level: Python logging level. Defaults to ``WARNING``. + message: Custom log message. If omitted, the validation reason is used. + """ + + severity_level: LoggingSeverityLevel = LoggingSeverityLevel.WARNING + message: Optional[str] = None + + def handle_validation_result( + self, + result: GuardrailValidationResult, + data: str | dict[str, Any], + guardrail_name: str, + ) -> str | dict[str, Any] | None: + """Log the violation and return ``None`` (no data modification).""" + if result.result == GuardrailValidationResultType.VALIDATION_FAILED: + msg = self.message or f"Failed: {result.reason}" + logging.getLogger(__name__).log( + self.severity_level, + "[GUARDRAIL] [%s] %s", + guardrail_name, + msg, + ) + return None + + +@dataclass +class BlockAction(GuardrailAction): + """Block execution by raising :class:`GuardrailBlockException`. + + Framework adapters catch ``GuardrailBlockException`` at the wrapper boundary + and convert it to their own runtime error type. + + Args: + title: Exception title. Defaults to a message derived from the guardrail name. + detail: Exception detail. Defaults to the validation reason. + """ + + title: Optional[str] = None + detail: Optional[str] = None + + def handle_validation_result( + self, + result: GuardrailValidationResult, + data: str | dict[str, Any], + guardrail_name: str, + ) -> str | dict[str, Any] | None: + """Raise :class:`GuardrailBlockException` when validation fails.""" + if result.result == GuardrailValidationResultType.VALIDATION_FAILED: + title = self.title or f"Guardrail [{guardrail_name}] blocked execution" + detail = self.detail or result.reason or "Guardrail validation failed" + raise GuardrailBlockException(title=title, detail=detail) + return None diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_core.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_core.py new file mode 100644 index 000000000..ca168a1e0 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_core.py @@ -0,0 +1,302 @@ +"""Core framework-agnostic utilities for guardrail decorators.""" + +import ast +import dataclasses +import inspect +import json +import logging +from typing import Annotated, Any, Callable, get_args, get_origin, get_type_hints + +from uipath.core.guardrails import ( + GuardrailValidationResult, +) + +from ._enums import GuardrailExecutionStage + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# GuardrailExclude marker +# --------------------------------------------------------------------------- + + +class GuardrailExclude: + """Marker to exclude a parameter from guardrail input serialization. + + Use with :data:`typing.Annotated` to prevent a specific function parameter + from being collected into the guardrail evaluation payload:: + + async def process( + text: str, + config: Annotated[dict, GuardrailExclude()], + ) -> str: ... + """ + + +# --------------------------------------------------------------------------- +# Evaluator type alias +# --------------------------------------------------------------------------- + +_EvaluatorFn = Callable[ + [ + "str | dict[str, Any]", # data + GuardrailExecutionStage, # stage + "dict[str, Any] | None", # input_data + "dict[str, Any] | None", # output_data + ], + GuardrailValidationResult, +] +"""Type alias for the unified evaluation callable used by all wrappers.""" + + +# --------------------------------------------------------------------------- +# Evaluator factory +# --------------------------------------------------------------------------- + + +def _make_evaluator( + validator: Any, + name: str, + description: str | None, + enabled_for_evals: bool, +) -> _EvaluatorFn: + """Return a unified evaluation callable. + + Delegates to ``validator.run()`` which each validator subclass implements + (:class:`BuiltInGuardrailValidator` hits the UiPath API; + :class:`CustomGuardrailValidator` runs a local Python rule). + + Args: + validator: :class:`GuardrailValidatorBase` instance. + name: Guardrail name — forwarded to ``validator.run()`` on each call. + description: Optional description — forwarded to ``validator.run()``. + enabled_for_evals: Whether active in evaluation scenarios. + + Returns: + Callable with signature ``(data, stage, input_data, output_data)``. + """ + + def _eval( + data: str | dict[str, Any], + stage: GuardrailExecutionStage, + input_data: dict[str, Any] | None, + output_data: dict[str, Any] | None, + ) -> GuardrailValidationResult: + return validator.run( + name, description, enabled_for_evals, data, stage, input_data, output_data + ) + + return _eval + + +# --------------------------------------------------------------------------- +# Parameter introspection +# --------------------------------------------------------------------------- + + +def _get_excluded_params(func: Any) -> set[str]: + """Return parameter names annotated with :class:`GuardrailExclude`. + + Args: + func: Callable to inspect. + + Returns: + Set of parameter names that should be excluded from guardrail input. + """ + try: + hints = get_type_hints(func, include_extras=True) + except Exception: + return set() + excluded: set[str] = set() + for name, hint in hints.items(): + if get_origin(hint) is Annotated: + for meta in get_args(hint)[1:]: + if isinstance(meta, GuardrailExclude): + excluded.add(name) + return excluded + + +# --------------------------------------------------------------------------- +# Serialization helpers +# --------------------------------------------------------------------------- + + +def _serialize_value(value: Any) -> Any: + """Serialize *value* to a JSON-compatible type for guardrail evaluation. + + Pydantic models → ``model_dump()``, dataclasses → ``asdict()``, + primitives → as-is, everything else → ``str()``. + """ + if value is None: + return None + if isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, dict): + return {k: _serialize_value(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_serialize_value(v) for v in value] + # Pydantic v2 + if hasattr(value, "model_dump"): + return value.model_dump() + # Pydantic v1 + if hasattr(value, "dict") and callable(value.dict): + try: + return value.dict() + except Exception: + pass + # dataclasses + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return dataclasses.asdict(value) + return str(value) + + +def _collect_input( + bound: inspect.BoundArguments, + excluded: set[str], +) -> dict[str, Any]: + """Collect non-excluded function parameters into a guardrail input dict. + + Args: + bound: Bound arguments from ``inspect.Signature.bind()``. + excluded: Parameter names to skip. + + Returns: + ``{param_name: serialized_value}`` for all non-excluded parameters. + """ + result: dict[str, Any] = {} + for name, value in bound.arguments.items(): + if name in excluded or name in ("self", "cls"): + continue + result[name] = _serialize_value(value) + return result + + +def _collect_output(return_value: Any) -> dict[str, Any]: + """Serialize a function return value into a dict for guardrail evaluation. + + Args: + return_value: The value returned by the wrapped function. + + Returns: + A ``dict`` representation suitable for guardrail evaluation. + """ + serialized = _serialize_value(return_value) + if isinstance(serialized, dict): + return serialized + return {"return": serialized} + + +def _reconstruct_output(original: Any, modified: Any) -> Any: + """Reconstruct a return value from a guardrail-modified payload. + + Args: + original: The original return value (used to determine target type). + modified: The modified value returned by the guardrail action. + + Returns: + Reconstructed value of the same type as *original* where possible. + """ + if modified is None: + return original + # Pydantic v2 model + dict modification → reconstruct via model_validate + if hasattr(original, "model_validate") and isinstance(modified, dict): + try: + return type(original).model_validate(modified) + except Exception: + pass + # Pydantic v1 + if hasattr(original, "parse_obj") and isinstance(modified, dict): + try: + return type(original).parse_obj(modified) + except Exception: + pass + return modified + + +def _apply_pre_modification( + bound: inspect.BoundArguments, + modified: Any, + excluded: set[str], +) -> None: + """Apply guardrail PRE-stage modifications back to bound function arguments. + + If the action returned a modified dict, keys matching non-excluded parameters + are updated in-place. If the action returned a plain string and there is exactly + one non-excluded parameter, that parameter is updated. + + Args: + bound: Bound arguments to mutate in-place. + modified: Value returned by the guardrail action. + excluded: Parameter names that were excluded from evaluation. + """ + if modified is None: + return + non_excluded = [ + n for n in bound.arguments if n not in excluded and n not in ("self", "cls") + ] + if isinstance(modified, dict): + for name in non_excluded: + if name in modified: + bound.arguments[name] = modified[name] + elif isinstance(modified, str) and len(non_excluded) == 1: + bound.arguments[non_excluded[0]] = modified + + +# --------------------------------------------------------------------------- +# Tool I/O normalisation helpers (used by LangChain adapter) +# --------------------------------------------------------------------------- + + +def _is_tool_call_envelope(tool_input: Any) -> bool: + """Return ``True`` if *tool_input* is a LangGraph tool-call envelope dict.""" + return ( + isinstance(tool_input, dict) + and "args" in tool_input + and tool_input.get("type") == "tool_call" + ) + + +def _extract_input(tool_input: Any) -> dict[str, Any]: + """Normalise tool input to a plain dict for rule / guardrail evaluation. + + LangGraph wraps tool inputs as ``{"name": ..., "args": {...}, "type": "tool_call"}``. + This function unwraps ``args`` so rules can access the actual tool arguments. + """ + if _is_tool_call_envelope(tool_input): + args = tool_input["args"] + if isinstance(args, dict): + return args + if isinstance(tool_input, dict): + return tool_input + return {"input": tool_input} + + +def _rewrap_input(original_tool_input: Any, modified_args: dict[str, Any]) -> Any: + """Re-wrap modified args back into the original tool-call envelope (if applicable).""" + if _is_tool_call_envelope(original_tool_input): + import copy + + wrapped = copy.copy(original_tool_input) + wrapped["args"] = modified_args + return wrapped + return modified_args + + +def _extract_output(result: Any) -> dict[str, Any]: + """Normalise tool output to a dict for guardrail / rule evaluation. + + Falls back to ``{"output": content}`` for plain strings and anything else. + """ + if isinstance(result, dict): + return result + if isinstance(result, str): + try: + parsed = json.loads(result) + return parsed if isinstance(parsed, dict) else {"output": parsed} + except ValueError: + try: + parsed = ast.literal_eval(result) + return parsed if isinstance(parsed, dict) else {"output": parsed} + except (ValueError, SyntaxError): + return {"output": result} + return {"output": result} diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_enums.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_enums.py new file mode 100644 index 000000000..be7832ddf --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_enums.py @@ -0,0 +1,44 @@ +"""Enums for guardrail decorators.""" + +from enum import Enum + + +class GuardrailExecutionStage(str, Enum): + """Execution stage for guardrails.""" + + PRE = "pre" + """Evaluate before the target executes.""" + + POST = "post" + """Evaluate after the target executes.""" + + PRE_AND_POST = "pre&post" + """Evaluate both before and after the target executes.""" + + +class PIIDetectionEntityType(str, Enum): + """PII detection entity types supported by UiPath guardrails. + + These entities match the available options from the UiPath guardrails service + backend. The enum values correspond to the exact strings expected by the API. + """ + + PERSON = "Person" + ADDRESS = "Address" + DATE = "Date" + PHONE_NUMBER = "PhoneNumber" + EUGPS_COORDINATES = "EugpsCoordinates" + EMAIL = "Email" + CREDIT_CARD_NUMBER = "CreditCardNumber" + INTERNATIONAL_BANKING_ACCOUNT_NUMBER = "InternationalBankingAccountNumber" + SWIFT_CODE = "SwiftCode" + ABA_ROUTING_NUMBER = "ABARoutingNumber" + US_DRIVERS_LICENSE_NUMBER = "USDriversLicenseNumber" + UK_DRIVERS_LICENSE_NUMBER = "UKDriversLicenseNumber" + US_INDIVIDUAL_TAXPAYER_IDENTIFICATION = "USIndividualTaxpayerIdentification" + UK_UNIQUE_TAXPAYER_NUMBER = "UKUniqueTaxpayerNumber" + US_BANK_ACCOUNT_NUMBER = "USBankAccountNumber" + US_SOCIAL_SECURITY_NUMBER = "USSocialSecurityNumber" + USUK_PASSPORT_NUMBER = "UsukPassportNumber" + URL = "URL" + IP_ADDRESS = "IPAddress" diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_exceptions.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_exceptions.py new file mode 100644 index 000000000..f4b7672e5 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_exceptions.py @@ -0,0 +1,18 @@ +"""Exceptions for guardrail decorators.""" + + +class GuardrailBlockException(Exception): + """Raised by BlockAction when a guardrail blocks execution. + + Framework adapters (e.g. LangChain) should catch this and convert it to + their own runtime exception type at the outermost wrapper boundary. + + Args: + title: Brief title for the block event. + detail: Detailed reason for the block. + """ + + def __init__(self, title: str, detail: str) -> None: + self.title = title + self.detail = detail + super().__init__(f"{title}: {detail}") diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_guardrail.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_guardrail.py new file mode 100644 index 000000000..d61b41c0d --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_guardrail.py @@ -0,0 +1,224 @@ +"""Single ``@guardrail`` decorator for all guardrail types.""" + +import inspect +import logging +from functools import wraps +from typing import Any + +from ._core import ( + _apply_pre_modification, + _collect_input, + _collect_output, + _EvaluatorFn, + _get_excluded_params, + _make_evaluator, + _reconstruct_output, +) +from ._enums import GuardrailExecutionStage +from ._models import GuardrailAction +from ._registry import is_recognized_by_adapter, wrap_with_adapter +from .validators._base import GuardrailValidatorBase + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _run_pre( + evaluator: _EvaluatorFn, + action: GuardrailAction, + name: str, + bound: inspect.BoundArguments, + excluded: set[str], +) -> None: + """Evaluate PRE guardrail and apply any modifications to *bound* in-place.""" + input_data = _collect_input(bound, excluded) + try: + result = evaluator(input_data, GuardrailExecutionStage.PRE, input_data, None) + except Exception as exc: + logger.error("Error evaluating PRE guardrail %r: %s", name, exc, exc_info=True) + return + from uipath.core.guardrails import GuardrailValidationResultType + + if result.result == GuardrailValidationResultType.VALIDATION_FAILED: + modified = action.handle_validation_result(result, input_data, name) + _apply_pre_modification(bound, modified, excluded) + + +def _run_post( + evaluator: _EvaluatorFn, + action: GuardrailAction, + name: str, + bound: inspect.BoundArguments, + excluded: set[str], + return_value: Any, +) -> Any: + """Evaluate POST guardrail and return (possibly modified) return value.""" + input_data = _collect_input(bound, excluded) + output_data = _collect_output(return_value) + try: + result = evaluator( + output_data, GuardrailExecutionStage.POST, input_data, output_data + ) + except Exception as exc: + logger.error("Error evaluating POST guardrail %r: %s", name, exc, exc_info=True) + return return_value + from uipath.core.guardrails import GuardrailValidationResultType + + if result.result == GuardrailValidationResultType.VALIDATION_FAILED: + modified = action.handle_validation_result(result, output_data, name) + return _reconstruct_output(return_value, modified) + return return_value + + +def _wrap_function( + func: Any, + evaluator: _EvaluatorFn, + action: GuardrailAction, + name: str, + stage: GuardrailExecutionStage, + excluded: set[str], +) -> Any: + """Wrap *func* as a pure Python function with PRE/POST guardrail evaluation.""" + sig = inspect.signature(func) + + def _dispatch_return(return_value: Any) -> Any: + """For factory functions: if the return value is recognized by an adapter, wrap it.""" + if is_recognized_by_adapter(return_value): + return wrap_with_adapter(return_value, evaluator, action, name, stage) + return return_value + + if inspect.iscoroutinefunction(func): + + @wraps(func) + async def _wrapped_async(*args: Any, **kwargs: Any) -> Any: + bound = sig.bind(*args, **kwargs) + bound.apply_defaults() + if stage in ( + GuardrailExecutionStage.PRE, + GuardrailExecutionStage.PRE_AND_POST, + ): + _run_pre(evaluator, action, name, bound, excluded) + return_value = await func(*bound.args, **bound.kwargs) + return_value = _dispatch_return(return_value) + if stage in ( + GuardrailExecutionStage.POST, + GuardrailExecutionStage.PRE_AND_POST, + ): + # Only run POST on plain (non-adapter-wrapped) values + if not is_recognized_by_adapter(return_value): + return_value = _run_post( + evaluator, action, name, bound, excluded, return_value + ) + return return_value + + return _wrapped_async + + @wraps(func) + def _wrapped(*args: Any, **kwargs: Any) -> Any: + bound = sig.bind(*args, **kwargs) + bound.apply_defaults() + if stage in ( + GuardrailExecutionStage.PRE, + GuardrailExecutionStage.PRE_AND_POST, + ): + _run_pre(evaluator, action, name, bound, excluded) + return_value = func(*bound.args, **bound.kwargs) + return_value = _dispatch_return(return_value) + if stage in ( + GuardrailExecutionStage.POST, + GuardrailExecutionStage.PRE_AND_POST, + ): + if not is_recognized_by_adapter(return_value): + return_value = _run_post( + evaluator, action, name, bound, excluded, return_value + ) + return return_value + + return _wrapped + + +# --------------------------------------------------------------------------- +# Public @guardrail decorator +# --------------------------------------------------------------------------- + + +def guardrail( + func: Any = None, + *, + validator: GuardrailValidatorBase, + action: GuardrailAction, + name: str = "Guardrail", + description: str | None = None, + stage: GuardrailExecutionStage = GuardrailExecutionStage.PRE_AND_POST, + enabled_for_evals: bool = True, +) -> Any: + """Apply a guardrail to any callable — tool functions, LLM factories, agent nodes. + + When applied to a plain function or async function, the decorator collects + function parameters (PRE) and return value (POST) and evaluates them against + the guardrail. Use :class:`~._core.GuardrailExclude` to opt individual + parameters out of serialization. + + When applied to a factory function whose return value is recognised by a + registered framework adapter (e.g. a LangChain ``BaseChatModel``), the + returned object is wrapped so every subsequent ``invoke()`` call is guarded. + + Multiple ``@guardrail`` decorators can be stacked on the same callable. + + Args: + func: Callable to decorate. Supplied directly when used without parentheses. + validator: :class:`~.validators.GuardrailValidatorBase` defining what to check. + action: :class:`~._models.GuardrailAction` defining how to respond on violation. + name: Human-readable name for this guardrail instance. + description: Optional description passed to API-based guardrails. + stage: When to evaluate — ``PRE``, ``POST``, or ``PRE_AND_POST``. + Defaults to ``PRE_AND_POST``. + enabled_for_evals: Whether this guardrail is active in evaluation scenarios. + Defaults to ``True``. + + Returns: + The decorated callable (or framework object). + + Raises: + ValueError: If *action* is invalid, or the validator does not support + the requested stage. + GuardrailBlockException: Raised at runtime by :class:`~._actions.BlockAction` + when a violation is detected. + """ + if action is None: + raise ValueError("action must be provided") + if not isinstance(action, GuardrailAction): + raise ValueError("action must be an instance of GuardrailAction") + if not isinstance(enabled_for_evals, bool): + raise ValueError("enabled_for_evals must be a boolean") + + def _apply(obj: Any) -> Any: + # ------------------------------------------------------------------ + # 1. Adapter-recognised direct object (e.g. BaseTool after @tool) + # ------------------------------------------------------------------ + if is_recognized_by_adapter(obj): + validator.validate_stage(stage) + evaluator = _make_evaluator(validator, name, description, enabled_for_evals) + return wrap_with_adapter(obj, evaluator, action, name, stage) + + # ------------------------------------------------------------------ + # 2. Plain callable — wrap as pure function + # ------------------------------------------------------------------ + if callable(obj): + validator.validate_stage(stage) + evaluator = _make_evaluator(validator, name, description, enabled_for_evals) + excluded = _get_excluded_params(obj) + return _wrap_function(obj, evaluator, action, name, stage, excluded) + + raise ValueError( + f"@guardrail cannot be applied to {type(obj)!r}. " + "Target must be a callable or a framework-registered object. " + "Ensure the relevant framework adapter is imported before using @guardrail." + ) + + if func is None: + return _apply + return _apply(func) diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_models.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_models.py new file mode 100644 index 000000000..ac22538e0 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_models.py @@ -0,0 +1,59 @@ +"""Models for guardrail decorators.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any + +from uipath.core.guardrails import GuardrailValidationResult + + +@dataclass +class PIIDetectionEntity: + """PII entity configuration with detection threshold. + + Args: + name: The entity type name (e.g. ``PIIDetectionEntityType.EMAIL``). + threshold: Confidence threshold (0.0 to 1.0) for detection. + """ + + name: str + threshold: float = 0.5 + + def __post_init__(self) -> None: + if not 0.0 <= self.threshold <= 1.0: + raise ValueError( + f"Threshold must be between 0.0 and 1.0, got {self.threshold}" + ) + + +class GuardrailAction(ABC): + """Interface for defining custom actions when a guardrail violation is detected. + + Subclass this to implement custom behaviour on validation failure, such as + logging, blocking, or content sanitisation. Built-in implementations are + :class:`LogAction` and :class:`BlockAction`. + """ + + @abstractmethod + def handle_validation_result( + self, + result: GuardrailValidationResult, + data: str | dict[str, Any], + guardrail_name: str, + ) -> "str | dict[str, Any] | None": + """Handle a guardrail validation result. + + Called when guardrail validation fails. May return modified data to + sanitise/filter the validated content before execution continues, or + ``None`` to leave it unchanged. + + Args: + result: The validation result from the guardrails service. + data: The data that was validated (string or dictionary). Depending + on context this can be tool input, tool output, or message text. + guardrail_name: The name of the guardrail that triggered. + + Returns: + Modified data if the action wants to replace the original, or + ``None`` if no modification is needed. + """ diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_registry.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_registry.py new file mode 100644 index 000000000..c4b7773b5 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_registry.py @@ -0,0 +1,105 @@ +"""Adapter registry for guardrail target recognition and wrapping.""" + +from typing import Any, Protocol, runtime_checkable + +from ._core import _EvaluatorFn +from ._enums import GuardrailExecutionStage +from ._models import GuardrailAction + + +@runtime_checkable +class GuardrailTargetAdapter(Protocol): + """Protocol for framework-specific guardrail adapters. + + Implement this protocol to teach :func:`guardrail` how to handle objects + from a particular framework. Register instances via + :func:`register_guardrail_adapter`. + """ + + def recognize(self, target: Any) -> bool: + """Return ``True`` if this adapter handles *target*. + + Args: + target: Object being decorated or returned by a factory function. + + Returns: + ``True`` if this adapter can wrap *target*, ``False`` otherwise. + """ + ... + + def wrap( + self, + target: Any, + evaluator: _EvaluatorFn, + action: GuardrailAction, + name: str, + stage: GuardrailExecutionStage, + ) -> Any: + """Wrap *target* with guardrail enforcement logic. + + Args: + target: Object to wrap. + evaluator: Unified evaluation callable from :func:`_make_evaluator`. + action: Action to invoke on validation failure. + name: Human-readable guardrail name. + stage: When to evaluate (PRE, POST, or PRE_AND_POST). + + Returns: + Wrapped object, same type or duck-type compatible. + """ + ... + + +# Module-level registry. Later-registered adapters take priority (inserted at 0). +_adapters: list[GuardrailTargetAdapter] = [] + + +def register_guardrail_adapter(adapter: GuardrailTargetAdapter) -> None: + """Register a framework adapter for the ``@guardrail`` decorator. + + Later-registered adapters are tried first. + + Args: + adapter: An instance implementing :class:`GuardrailTargetAdapter`. + """ + _adapters.insert(0, adapter) + + +def is_recognized_by_adapter(target: Any) -> bool: + """Return ``True`` if any registered adapter recognizes *target*. + + Args: + target: The object being decorated. + + Returns: + ``True`` if a registered adapter handles *target*. + """ + for adapter in _adapters: + if adapter.recognize(target): + return True + return False + + +def wrap_with_adapter( + target: Any, + evaluator: _EvaluatorFn, + action: GuardrailAction, + name: str, + stage: GuardrailExecutionStage, +) -> Any: + """Ask the first matching adapter to wrap *target*. + + Args: + target: The object to wrap. + evaluator: Unified evaluation callable. + action: Action on violation. + name: Guardrail name. + stage: Execution stage. + + Returns: + Wrapped object, or *target* unchanged if no adapter handles it. + """ + for adapter in _adapters: + if adapter.recognize(target): + return adapter.wrap(target, evaluator, action, name, stage) + return target diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py new file mode 100644 index 000000000..6be170534 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py @@ -0,0 +1,20 @@ +"""Guardrail validators for the ``@guardrail`` decorator.""" + +from ._base import ( + BuiltInGuardrailValidator, + CustomGuardrailValidator, + GuardrailValidatorBase, +) +from .custom import CustomValidator, RuleFunction +from .pii import PIIValidator +from .prompt_injection import PromptInjectionValidator + +__all__ = [ + "GuardrailValidatorBase", + "BuiltInGuardrailValidator", + "CustomGuardrailValidator", + "PIIValidator", + "PromptInjectionValidator", + "CustomValidator", + "RuleFunction", +] diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/_base.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/_base.py new file mode 100644 index 000000000..a9eaf5afd --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/_base.py @@ -0,0 +1,182 @@ +"""Abstract base classes for guardrail validators.""" + +from abc import ABC, abstractmethod +from typing import Any, ClassVar + +from uipath.core.guardrails import GuardrailValidationResult + +from uipath.platform.guardrails.guardrails import BuiltInValidatorGuardrail + +from .._enums import GuardrailExecutionStage + + +class GuardrailValidatorBase: + """Root base class for guardrail validators. + + Concrete validators should subclass either + :class:`BuiltInGuardrailValidator` (for UiPath API-backed validation) + or :class:`CustomGuardrailValidator` (for in-process Python validation). + """ + + supported_stages: ClassVar[list[GuardrailExecutionStage]] = [] + """Stages this validator supports. Empty list means all stages are allowed.""" + + def validate_stage(self, stage: GuardrailExecutionStage) -> None: + """Raise ``ValueError`` if *stage* is not in :attr:`supported_stages`. + + Args: + stage: Requested execution stage. + + Raises: + ValueError: If :attr:`supported_stages` is non-empty and *stage* is absent. + """ + if self.supported_stages and stage not in self.supported_stages: + raise ValueError( + f"{type(self).__name__} does not support stage {stage!r}. " + f"Supported stages: {[s.value for s in self.supported_stages]}" + ) + + def run( + self, + name: str, + description: str | None, + enabled_for_evals: bool, + data: "str | dict[str, Any]", + stage: GuardrailExecutionStage, + input_data: "dict[str, Any] | None", + output_data: "dict[str, Any] | None", + ) -> GuardrailValidationResult: + """Execute the guardrail evaluation. + + Called by the ``@guardrail`` decorator at each function invocation. + Subclasses override this via :class:`BuiltInGuardrailValidator` or + :class:`CustomGuardrailValidator`. + + Raises: + NotImplementedError: Always — subclass one of the two ABCs instead. + """ + raise NotImplementedError( + f"{type(self).__name__} must subclass BuiltInGuardrailValidator " + "or CustomGuardrailValidator and implement the required abstract method." + ) + + +class BuiltInGuardrailValidator(GuardrailValidatorBase, ABC): + """Base for validators that delegate to the UiPath Guardrails API. + + Subclass this and implement :meth:`get_built_in_guardrail` to create an + API-backed guardrail validator (e.g. PII detection, prompt injection). + + Example:: + + class MyValidator(BuiltInGuardrailValidator): + def get_built_in_guardrail(self, name, description, enabled_for_evals): + return BuiltInValidatorGuardrail( + id=str(uuid4()), + name=name, + ... + ) + """ + + @abstractmethod + def get_built_in_guardrail( + self, + name: str, + description: str | None, + enabled_for_evals: bool, + ) -> BuiltInValidatorGuardrail: + """Build the UiPath API guardrail definition for this validator. + + Args: + name: Name for the guardrail instance. + description: Optional description. + enabled_for_evals: Whether active in evaluation scenarios. + + Returns: + :class:`BuiltInValidatorGuardrail` ready to be sent to the API. + """ + ... + + def run( + self, + name: str, + description: str | None, + enabled_for_evals: bool, + data: "str | dict[str, Any]", + stage: GuardrailExecutionStage, + input_data: "dict[str, Any] | None", + output_data: "dict[str, Any] | None", + ) -> GuardrailValidationResult: + """Evaluate via the UiPath Guardrails API. + + Lazily initialises the ``UiPath`` client on the first call and reuses + it for all subsequent invocations. + """ + built_in = self.get_built_in_guardrail(name, description, enabled_for_evals) + if not hasattr(self, "_uipath"): + from uipath.platform import UiPath + + self._uipath: Any = UiPath() + return self._uipath.guardrails.evaluate_guardrail(data, built_in) + + +class CustomGuardrailValidator(GuardrailValidatorBase, ABC): + """Base for validators that run entirely in-process. + + Subclass this and implement :meth:`evaluate` to create a local guardrail + validator that requires no UiPath API call. + + Example:: + + class ProfanityValidator(CustomGuardrailValidator): + BANNED = {"badword"} + + def evaluate(self, data, stage, input_data, output_data): + text = (input_data or {}).get("message", "") + if any(w in text.lower() for w in self.BANNED): + return GuardrailValidationResult( + result=GuardrailValidationResultType.VALIDATION_FAILED, + reason="Profanity detected", + ) + return GuardrailValidationResult(result=GuardrailValidationResultType.PASSED) + """ + + @abstractmethod + def evaluate( + self, + data: "str | dict[str, Any]", + stage: GuardrailExecutionStage, + input_data: "dict[str, Any] | None", + output_data: "dict[str, Any] | None", + ) -> GuardrailValidationResult: + """Perform local validation without a UiPath API call. + + Return a result with ``VALIDATION_FAILED`` to **trigger** the guardrail + (causing the configured :class:`~uipath.platform.guardrails.decorators.GuardrailAction` + to fire), or ``PASSED`` to let execution continue unchanged. + + Args: + data: Primary data being evaluated. + stage: Current execution stage (PRE or POST). + input_data: Normalised function input dict, or ``None``. + output_data: Normalised function output dict, or ``None`` at PRE stage. + + Returns: + :class:`~uipath.core.guardrails.GuardrailValidationResult` — + return ``VALIDATION_FAILED`` to activate the guardrail, + ``PASSED`` to allow execution to continue. + """ + ... + + def run( + self, + name: str, + description: str | None, + enabled_for_evals: bool, + data: "str | dict[str, Any]", + stage: GuardrailExecutionStage, + input_data: "dict[str, Any] | None", + output_data: "dict[str, Any] | None", + ) -> GuardrailValidationResult: + """Delegate to :meth:`evaluate`.""" + return self.evaluate(data, stage, input_data, output_data) diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/custom.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/custom.py new file mode 100644 index 000000000..df6549600 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/custom.py @@ -0,0 +1,125 @@ +"""Custom (rule-based) guardrail validator.""" + +import inspect +from typing import Any, Callable + +from uipath.core.guardrails import ( + GuardrailValidationResult, + GuardrailValidationResultType, +) + +from .._enums import GuardrailExecutionStage +from ._base import CustomGuardrailValidator + +RuleFunction = ( + Callable[[dict[str, Any]], bool] | Callable[[dict[str, Any], dict[str, Any]], bool] +) +"""Type alias for custom rule functions passed to :class:`CustomValidator`. + +The rule must return ``True`` to **trigger** the guardrail (i.e. signal a +violation that causes the configured action to fire), or ``False`` to let +execution continue unchanged. + +It accepts either one parameter (the input or output dict) or two parameters +(input dict, output dict — POST stage only). + +Examples:: + + # Triggered when "donkey" appears in the joke argument + CustomValidator(lambda args: "donkey" in args.get("joke", "").lower()) + + # Triggered when the output joke exceeds 500 characters + CustomValidator(lambda args: len(args.get("joke", "")) > 500) + + # Two-parameter form: triggered at POST when output contains input keyword + CustomValidator(lambda inp, out: inp.get("topic", "") in out.get("joke", "")) +""" + + +class CustomValidator(CustomGuardrailValidator): + """Validate function input/output using a local Python rule function. + + No UiPath API call is made. Applicable at any stage. + + The *rule* is called with the collected parameter dict (PRE stage) or the + serialised return-value dict (POST stage). It must return ``True`` to + **activate** the guardrail — i.e. to signal a violation and invoke the + configured :class:`~uipath.platform.guardrails.decorators.GuardrailAction`. + Return ``False`` (or any falsy value) to let execution continue unchanged. + + Args: + rule: A :data:`RuleFunction` that returns ``True`` to trigger the + guardrail. Must accept 1 or 2 parameters. + + Raises: + ValueError: If *rule* is not callable or has an unsupported parameter count. + """ + + def __init__(self, rule: RuleFunction) -> None: + """Initialize CustomValidator with a rule callable.""" + if not callable(rule): + raise ValueError(f"rule must be callable, got {type(rule)}") + sig = inspect.signature(rule) + param_count = len(sig.parameters) + if param_count not in (1, 2): + raise ValueError(f"rule must have 1 or 2 parameters, got {param_count}") + self.rule = rule + self._param_count = param_count + + def evaluate( + self, + data: str | dict[str, Any], + stage: GuardrailExecutionStage, + input_data: dict[str, Any] | None, + output_data: dict[str, Any] | None, + ) -> GuardrailValidationResult: + """Run the rule against the collected input or output dict. + + The rule receives the PRE parameter dict or POST return-value dict and + must return ``True`` to **trigger** the guardrail (VALIDATION_FAILED), + or ``False`` to pass. + + Args: + data: Unused; the rule operates on *input_data* or *output_data*. + stage: Current stage (PRE or POST). + input_data: Collected function input dict. + output_data: Collected function output dict, or ``None`` at PRE stage. + + Returns: + :class:`~uipath.core.guardrails.GuardrailValidationResult` — + ``VALIDATION_FAILED`` when the rule returns ``True`` (guardrail + triggered), ``PASSED`` otherwise. + """ + try: + if self._param_count == 2: + if input_data is None or output_data is None: + return GuardrailValidationResult( + result=GuardrailValidationResultType.PASSED, + reason="Two-parameter rule skipped: input or output data unavailable", + ) + violation = self.rule(input_data, output_data) # type: ignore[call-arg] + else: + target = ( + input_data if stage == GuardrailExecutionStage.PRE else output_data + ) + if target is None: + return GuardrailValidationResult( + result=GuardrailValidationResultType.PASSED, + reason="Rule skipped: data unavailable at this stage", + ) + violation = self.rule(target) # type: ignore[call-arg] + except Exception as exc: + return GuardrailValidationResult( + result=GuardrailValidationResultType.PASSED, + reason=f"Rule raised exception: {exc}", + ) + + if violation: + return GuardrailValidationResult( + result=GuardrailValidationResultType.VALIDATION_FAILED, + reason="Rule detected violation", + ) + return GuardrailValidationResult( + result=GuardrailValidationResultType.PASSED, + reason="Rule passed", + ) diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/pii.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/pii.py new file mode 100644 index 000000000..64d0a47aa --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/pii.py @@ -0,0 +1,76 @@ +"""PII detection guardrail validator.""" + +from typing import Any, Sequence +from uuid import uuid4 + +from uipath.platform.guardrails.guardrails import ( + BuiltInValidatorGuardrail, + EnumListParameterValue, + MapEnumParameterValue, +) + +from .._models import PIIDetectionEntity +from ._base import BuiltInGuardrailValidator + + +class PIIValidator(BuiltInGuardrailValidator): + """Validate data for PII entities using the UiPath PII detection API. + + Supported at all stages. + + Args: + entities: One or more :class:`~uipath.platform.guardrails.decorators.PIIDetectionEntity` + instances specifying which PII types to detect and their confidence thresholds. + + Raises: + ValueError: If *entities* is empty. + """ + + def __init__(self, entities: Sequence[PIIDetectionEntity]) -> None: + """Initialize PIIValidator with a list of entities to detect.""" + if not entities: + raise ValueError("entities must be provided and non-empty") + self.entities = list(entities) + + def get_built_in_guardrail( + self, + name: str, + description: str | None, + enabled_for_evals: bool, + ) -> BuiltInValidatorGuardrail: + """Build a PII detection :class:`BuiltInValidatorGuardrail`. + + Args: + name: Name for the guardrail. + description: Optional description. + enabled_for_evals: Whether active in evaluation scenarios. + + Returns: + Configured :class:`BuiltInValidatorGuardrail` for PII detection. + """ + entity_names = [entity.name for entity in self.entities] + entity_thresholds: dict[str, Any] = { + entity.name: entity.threshold for entity in self.entities + } + + return BuiltInValidatorGuardrail( + id=str(uuid4()), + name=name, + description=description + or f"Detects PII entities: {', '.join(entity_names)}", + enabled_for_evals=enabled_for_evals, + guardrail_type="builtInValidator", + validator_type="pii_detection", + validator_parameters=[ + EnumListParameterValue( + parameter_type="enum-list", + id="entities", + value=entity_names, + ), + MapEnumParameterValue( + parameter_type="map-enum", + id="entityThresholds", + value=entity_thresholds, + ), + ], + ) diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/prompt_injection.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/prompt_injection.py new file mode 100644 index 000000000..b0943b396 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/prompt_injection.py @@ -0,0 +1,65 @@ +"""Prompt injection detection guardrail validator.""" + +from uuid import uuid4 + +from uipath.platform.guardrails.guardrails import ( + BuiltInValidatorGuardrail, + NumberParameterValue, +) + +from .._enums import GuardrailExecutionStage +from ._base import BuiltInGuardrailValidator + + +class PromptInjectionValidator(BuiltInGuardrailValidator): + """Validate input for prompt injection attacks via the UiPath API. + + Restricted to PRE stage only — prompt injection is an input-only concern. + + Args: + threshold: Detection confidence threshold (0.0–1.0). Defaults to ``0.5``. + + Raises: + ValueError: If *threshold* is outside [0.0, 1.0]. + """ + + supported_stages = [GuardrailExecutionStage.PRE] + + def __init__(self, threshold: float = 0.5) -> None: + """Initialize PromptInjectionValidator with a detection threshold.""" + if not 0.0 <= threshold <= 1.0: + raise ValueError(f"threshold must be between 0.0 and 1.0, got {threshold}") + self.threshold = threshold + + def get_built_in_guardrail( + self, + name: str, + description: str | None, + enabled_for_evals: bool, + ) -> BuiltInValidatorGuardrail: + """Build a prompt injection :class:`BuiltInValidatorGuardrail`. + + Args: + name: Name for the guardrail. + description: Optional description. + enabled_for_evals: Whether active in evaluation scenarios. + + Returns: + Configured :class:`BuiltInValidatorGuardrail` for prompt injection. + """ + return BuiltInValidatorGuardrail( + id=str(uuid4()), + name=name, + description=description + or f"Detects prompt injection with threshold {self.threshold}", + enabled_for_evals=enabled_for_evals, + guardrail_type="builtInValidator", + validator_type="prompt_injection", + validator_parameters=[ + NumberParameterValue( + parameter_type="number", + id="threshold", + value=self.threshold, + ), + ], + ) diff --git a/packages/uipath-platform/tests/services/test_guardrails_decorators.py b/packages/uipath-platform/tests/services/test_guardrails_decorators.py new file mode 100644 index 000000000..e578cba84 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_guardrails_decorators.py @@ -0,0 +1,1171 @@ +"""Tests for the guardrails decorator framework in uipath-platform. + +Focus: meaningful business behaviour — serialization, PRE/POST evaluation, +modification flow, stage enforcement, factory-function path, and integration +scenarios modelled on the joke-agent-decorator sample. +""" + +from __future__ import annotations + +import dataclasses +import logging +from typing import Annotated, Any +from unittest.mock import MagicMock, patch + +import pytest +from pydantic import BaseModel +from uipath.core.guardrails import ( + GuardrailValidationResult, + GuardrailValidationResultType, +) + +from uipath.platform.guardrails.decorators import ( + BlockAction, + CustomValidator, + GuardrailAction, + GuardrailBlockException, + GuardrailExclude, + GuardrailExecutionStage, + LogAction, + LoggingSeverityLevel, + PIIDetectionEntity, + PIIDetectionEntityType, + PIIValidator, + PromptInjectionValidator, + guardrail, + register_guardrail_adapter, +) +from uipath.platform.guardrails.decorators._core import ( + _collect_output, + _get_excluded_params, + _make_evaluator, + _reconstruct_output, + _serialize_value, +) +from uipath.platform.guardrails.decorators._registry import ( + _adapters, +) + +# --------------------------------------------------------------------------- +# Shared result constants +# --------------------------------------------------------------------------- + +_PASSED = GuardrailValidationResult( + result=GuardrailValidationResultType.PASSED, + reason="ok", +) +_FAILED = GuardrailValidationResult( + result=GuardrailValidationResultType.VALIDATION_FAILED, + reason="violation detected", +) + + +# --------------------------------------------------------------------------- +# Registry isolation fixture +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _isolate_adapter_registry(): + """Snapshot and restore the global adapter registry around every test.""" + snapshot = list(_adapters) + yield + _adapters.clear() + _adapters.extend(snapshot) + + +# --------------------------------------------------------------------------- +# Minimal fake types for adapter tests (no LangChain dependency) +# --------------------------------------------------------------------------- + + +class _DummyTarget: + """Minimal callable target recognised by _DummyAdapter.""" + + def __init__(self, return_value: Any = None) -> None: + self.return_value = ( + return_value if return_value is not None else {"output": "result"} + ) + self.invoke_calls: list[Any] = [] + + def invoke(self, args: Any) -> Any: + self.invoke_calls.append(args) + return self.return_value + + +class _WrappedDummyTarget: + """A _DummyTarget wrapped with guardrail evaluation.""" + + def __init__( + self, + target: Any, + evaluator: Any, + action: GuardrailAction, + name: str, + stage: GuardrailExecutionStage, + ) -> None: + self._target = target + self._evaluator = evaluator + self._action = action + self._name = name + self._stage = stage + + def invoke(self, args: Any) -> Any: + input_data = args if isinstance(args, dict) else {"input": args} + if self._stage in ( + GuardrailExecutionStage.PRE, + GuardrailExecutionStage.PRE_AND_POST, + ): + result = self._evaluator( + input_data, GuardrailExecutionStage.PRE, input_data, None + ) + if result.result == GuardrailValidationResultType.VALIDATION_FAILED: + self._action.handle_validation_result(result, input_data, self._name) + raw = self._target.invoke(args) + output_data = raw if isinstance(raw, dict) else {"output": raw} + if self._stage in ( + GuardrailExecutionStage.POST, + GuardrailExecutionStage.PRE_AND_POST, + ): + result = self._evaluator( + output_data, GuardrailExecutionStage.POST, input_data, output_data + ) + if result.result == GuardrailValidationResultType.VALIDATION_FAILED: + self._action.handle_validation_result(result, output_data, self._name) + return raw + + +class _DummyAdapter: + """Adapter that handles _DummyTarget and _WrappedDummyTarget instances.""" + + def recognize(self, target: Any) -> bool: + return isinstance(target, (_DummyTarget, _WrappedDummyTarget)) + + def wrap( + self, + target: Any, + evaluator: Any, + action: GuardrailAction, + name: str, + stage: GuardrailExecutionStage, + ) -> Any: + return _WrappedDummyTarget(target, evaluator, action, name, stage) + + +# --------------------------------------------------------------------------- +# 1. PIIDetectionEntity — threshold boundary enforcement +# --------------------------------------------------------------------------- + + +class TestPIIDetectionEntity: + def test_threshold_below_zero_raises(self): + with pytest.raises(ValueError, match="0.0 and 1.0"): + PIIDetectionEntity(name="Email", threshold=-0.1) + + def test_threshold_above_one_raises(self): + with pytest.raises(ValueError, match="0.0 and 1.0"): + PIIDetectionEntity(name="Email", threshold=1.1) + + +# --------------------------------------------------------------------------- +# 2. LogAction — does NOT stop execution; uses configured severity +# --------------------------------------------------------------------------- + + +class TestLogAction: + def test_violation_logs_guardrail_name_and_execution_continues(self, caplog): + action = LogAction() + with caplog.at_level(logging.WARNING): + result = action.handle_validation_result(_FAILED, "data", "MyGuardrail") + assert result is None # execution continues + assert any("MyGuardrail" in r.message for r in caplog.records) + + def test_pass_emits_no_log(self, caplog): + action = LogAction() + with caplog.at_level(logging.WARNING): + action.handle_validation_result(_PASSED, "data", "G") + assert not caplog.records + + def test_custom_message_overrides_reason(self, caplog): + action = LogAction(message="custom alert") + with caplog.at_level(logging.WARNING): + action.handle_validation_result(_FAILED, "data", "G") + assert any("custom alert" in r.message for r in caplog.records) + + def test_default_message_includes_validation_reason(self, caplog): + action = LogAction() + with caplog.at_level(logging.WARNING): + action.handle_validation_result(_FAILED, "data", "G") + assert any("violation detected" in r.message for r in caplog.records) + + def test_debug_severity(self, caplog): + action = LogAction(severity_level=LoggingSeverityLevel.DEBUG) + with caplog.at_level(logging.DEBUG): + action.handle_validation_result(_FAILED, "data", "G") + debug_records = [r for r in caplog.records if r.levelno == logging.DEBUG] + assert debug_records + + +# --------------------------------------------------------------------------- +# 3. BlockAction — raises GuardrailBlockException on violation +# --------------------------------------------------------------------------- + + +class TestBlockAction: + def test_raises_on_violation(self): + action = BlockAction() + with pytest.raises(GuardrailBlockException): + action.handle_validation_result(_FAILED, "data", "G") + + def test_no_raise_on_pass(self): + action = BlockAction() + result = action.handle_validation_result(_PASSED, "data", "G") + assert result is None + + def test_title_and_detail_from_result(self): + action = BlockAction() + with pytest.raises(GuardrailBlockException) as exc_info: + action.handle_validation_result(_FAILED, "data", "MyGuardrail") + assert exc_info.value.title + assert exc_info.value.detail + + def test_custom_title_and_detail(self): + action = BlockAction(title="Blocked", detail="Not allowed") + with pytest.raises(GuardrailBlockException) as exc_info: + action.handle_validation_result(_FAILED, "data", "G") + assert exc_info.value.title == "Blocked" + assert exc_info.value.detail == "Not allowed" + + +# --------------------------------------------------------------------------- +# 4. PIIValidator — builds correct BuiltInValidatorGuardrail +# --------------------------------------------------------------------------- + + +class TestPIIValidator: + def test_empty_entities_raises(self): + with pytest.raises(ValueError, match="non-empty"): + PIIValidator(entities=[]) + + def test_entity_names_and_thresholds_in_api_parameters(self): + v = PIIValidator( + entities=[ + PIIDetectionEntity(PIIDetectionEntityType.EMAIL, 0.6), + PIIDetectionEntity(PIIDetectionEntityType.PERSON, 0.8), + ] + ) + g = v.get_built_in_guardrail("G", None, True) + param_by_id = {p.id: p for p in g.validator_parameters} + entities_value = param_by_id["entities"].value + assert isinstance(entities_value, list) + assert "Email" in entities_value + assert "Person" in entities_value + thresholds_value = param_by_id["entityThresholds"].value + assert isinstance(thresholds_value, dict) + assert thresholds_value["Email"] == 0.6 + assert thresholds_value["Person"] == 0.8 + + def test_no_scope_restriction(self): + v = PIIValidator(entities=[PIIDetectionEntity(PIIDetectionEntityType.EMAIL)]) + # All stages allowed — no ValueError raised + v.validate_stage(GuardrailExecutionStage.PRE) + v.validate_stage(GuardrailExecutionStage.POST) + + def test_selector_is_none(self): + v = PIIValidator(entities=[PIIDetectionEntity(PIIDetectionEntityType.EMAIL)]) + g = v.get_built_in_guardrail("G", None, True) + assert g.selector is None + + +# --------------------------------------------------------------------------- +# 5. PromptInjectionValidator — LLM-only, PRE-only, threshold validation +# --------------------------------------------------------------------------- + + +class TestPromptInjectionValidator: + def test_threshold_below_zero_raises(self): + with pytest.raises(ValueError, match="threshold"): + PromptInjectionValidator(threshold=-0.1) + + def test_threshold_above_one_raises(self): + with pytest.raises(ValueError, match="threshold"): + PromptInjectionValidator(threshold=1.1) + + def test_restricted_to_pre_stage_only(self): + v = PromptInjectionValidator() + v.validate_stage(GuardrailExecutionStage.PRE) # ok + with pytest.raises(ValueError): + v.validate_stage(GuardrailExecutionStage.POST) + + def test_builds_prompt_injection_guardrail_with_threshold(self): + v = PromptInjectionValidator(threshold=0.7) + g = v.get_built_in_guardrail("PI", None, True) + assert g.validator_type == "prompt_injection" + threshold_param = next(p for p in g.validator_parameters if p.id == "threshold") + assert threshold_param.value == 0.7 + + def test_selector_is_none(self): + v = PromptInjectionValidator() + g = v.get_built_in_guardrail("PI", None, True) + assert g.selector is None + + +# --------------------------------------------------------------------------- +# 6. CustomValidator — rule routing and error handling +# --------------------------------------------------------------------------- + + +class TestCustomValidator: + def test_non_callable_raises(self): + with pytest.raises(ValueError, match="callable"): + CustomValidator(rule="not_a_function") # type: ignore[arg-type] + + def test_wrong_arity_raises(self): + with pytest.raises(ValueError, match="1 or 2"): + CustomValidator(rule=lambda: True) # type: ignore[arg-type] + with pytest.raises(ValueError, match="1 or 2"): + CustomValidator(rule=lambda a, b, c: True) # type: ignore[arg-type] + + def test_one_param_pre_receives_input_data(self): + received: list[Any] = [] + + def capture_pre(args: dict[str, Any]) -> bool: + received.append(args) + return False + + CustomValidator(rule=capture_pre).evaluate( + {}, GuardrailExecutionStage.PRE, {"a": 1}, None + ) + assert received == [{"a": 1}] + + def test_one_param_post_receives_output_data(self): + received: list[Any] = [] + + def capture_post(args: dict[str, Any]) -> bool: + received.append(args) + return False + + CustomValidator(rule=capture_post).evaluate( + {}, GuardrailExecutionStage.POST, {"in": 1}, {"out": 2} + ) + assert received == [{"out": 2}] + + def test_two_param_post_receives_input_and_output(self): + received: list[Any] = [] + + def rule(inp: dict[str, Any], out: dict[str, Any]) -> bool: + received.append((inp, out)) + return False + + CustomValidator(rule=rule).evaluate( + {}, GuardrailExecutionStage.POST, {"in": 1}, {"out": 2} + ) + assert received == [({"in": 1}, {"out": 2})] + + def test_two_param_rule_skipped_when_input_missing(self): + result = CustomValidator(rule=lambda a, b: True).evaluate( + {}, GuardrailExecutionStage.POST, None, {"out": 2} + ) + assert result.result == GuardrailValidationResultType.PASSED + + def test_rule_returning_true_means_violation(self): + result = CustomValidator(rule=lambda args: True).evaluate( + {}, GuardrailExecutionStage.PRE, {"x": 1}, None + ) + assert result.result == GuardrailValidationResultType.VALIDATION_FAILED + + def test_rule_exception_returns_passed(self): + def bad(args: dict[str, Any]) -> bool: + raise ValueError("boom") + + result = CustomValidator(rule=bad).evaluate( + {}, GuardrailExecutionStage.PRE, {"x": 1}, None + ) + assert result.result == GuardrailValidationResultType.PASSED + + +# --------------------------------------------------------------------------- +# 7. GuardrailExclude — parameter introspection +# --------------------------------------------------------------------------- + + +class TestGuardrailExclude: + def test_excluded_param_not_in_collected_input(self): + def func( + text: str, + config: Annotated[dict[str, Any], GuardrailExclude()], + ) -> str: + return text + + excluded = _get_excluded_params(func) + assert "config" in excluded + assert "text" not in excluded + + def test_multiple_excluded_params(self): + def func( + a: str, + b: Annotated[int, GuardrailExclude()], + c: Annotated[str, GuardrailExclude()], + ) -> str: + return a + + excluded = _get_excluded_params(func) + assert excluded == {"b", "c"} + + def test_no_annotations_returns_empty_set(self): + def func(a: str, b: int) -> str: + return a + + assert _get_excluded_params(func) == set() + + +# --------------------------------------------------------------------------- +# 8. Serialization helpers +# --------------------------------------------------------------------------- + + +class _PydanticModel(BaseModel): + topic: str + count: int = 0 + + +@dataclasses.dataclass +class _Dataclass: + name: str + value: float + + +class TestSerializationHelpers: + def test_primitive_str_passthrough(self): + assert _serialize_value("hello") == "hello" + + def test_primitive_int_passthrough(self): + assert _serialize_value(42) == 42 + + def test_dict_passthrough(self): + assert _serialize_value({"a": 1}) == {"a": 1} + + def test_pydantic_model_dumps(self): + m = _PydanticModel(topic="test", count=3) + result = _serialize_value(m) + assert result == {"topic": "test", "count": 3} + + def test_dataclass_asdict(self): + d = _Dataclass(name="x", value=1.5) + result = _serialize_value(d) + assert result == {"name": "x", "value": 1.5} + + def test_collect_output_from_pydantic(self): + m = _PydanticModel(topic="joke") + result = _collect_output(m) + assert result == {"topic": "joke", "count": 0} + + def test_collect_output_from_str(self): + result = _collect_output("hello") + assert result == {"return": "hello"} + + def test_collect_output_from_dict(self): + result = _collect_output({"key": "val"}) + assert result == {"key": "val"} + + def test_reconstruct_output_pydantic(self): + original = _PydanticModel(topic="original") + modified = {"topic": "modified", "count": 5} + result = _reconstruct_output(original, modified) + assert isinstance(result, _PydanticModel) + assert result.topic == "modified" + assert result.count == 5 + + def test_reconstruct_output_str(self): + result = _reconstruct_output("original", "modified") + assert result == "modified" + + def test_reconstruct_output_none_returns_original(self): + result = _reconstruct_output("original", None) + assert result == "original" + + +# --------------------------------------------------------------------------- +# 9. @guardrail on sync functions +# --------------------------------------------------------------------------- + + +class TestGuardrailOnSyncFunction: + def test_pre_fires_before_function(self): + calls: list[str] = [] + + mock_validator = MagicMock() + mock_validator.supported_stages = [] + mock_validator.validate_stage = MagicMock() + + mock_validator.run.side_effect = lambda *a, **kw: ( + calls.append("eval") or _PASSED # type: ignore[func-returns-value] + ) + + @guardrail( + validator=mock_validator, + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + def fn(text: str) -> str: + calls.append("fn") + return text + + fn("hello") + assert calls == ["eval", "fn"] + + def test_post_fires_after_function(self): + calls: list[str] = [] + + mock_validator = MagicMock() + mock_validator.supported_stages = [] + mock_validator.validate_stage = MagicMock() + + mock_validator.run.side_effect = lambda *a, **kw: ( + calls.append("eval") or _PASSED # type: ignore[func-returns-value] + ) + + @guardrail( + validator=mock_validator, + action=LogAction(), + stage=GuardrailExecutionStage.POST, + ) + def fn(text: str) -> str: + calls.append("fn") + return text + + fn("hello") + assert calls == ["fn", "eval"] + + def test_block_action_raises_guardrail_block_exception(self): + mock_validator = MagicMock() + mock_validator.supported_stages = [] + mock_validator.validate_stage = MagicMock() + + mock_validator.run.return_value = _FAILED + + @guardrail( + validator=mock_validator, + action=BlockAction(title="Blocked", detail="not allowed"), + stage=GuardrailExecutionStage.PRE, + ) + def fn(text: str) -> str: + return text + + with pytest.raises(GuardrailBlockException): + fn("bad input") + + def test_log_action_does_not_stop_execution(self): + mock_validator = MagicMock() + mock_validator.supported_stages = [] + mock_validator.validate_stage = MagicMock() + + mock_validator.run.return_value = _FAILED + + result = [] + + @guardrail( + validator=mock_validator, + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + def fn(text: str) -> str: + result.append("called") + return text + + fn("input") + assert result == ["called"] + + def test_pre_input_contains_function_params(self): + captured: list[Any] = [] + + @guardrail( + validator=CustomValidator( + lambda args: captured.append(args) or False # type: ignore[func-returns-value] + ), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + def fn(joke: str, count: int) -> str: + return joke + + fn("why did the chicken", 3) + assert captured == [{"joke": "why did the chicken", "count": 3}] + + def test_excluded_param_absent_from_pre_input(self): + captured: list[Any] = [] + + @guardrail( + validator=CustomValidator( + lambda args: captured.append(args) or False # type: ignore[func-returns-value] + ), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + def fn( + joke: str, + config: Annotated[dict[str, Any], GuardrailExclude()], + ) -> str: + return joke + + fn("why did the chicken", {"debug": True}) + assert "config" not in captured[0] + assert "joke" in captured[0] + + def test_pre_modification_updates_function_args(self): + class _ReplaceAction(GuardrailAction): + def handle_validation_result(self, result, data, name): + if isinstance(data, dict) and "joke" in data: + return {"joke": data["joke"].replace("donkey", "[censored]")} + return data + + @guardrail( + validator=CustomValidator(lambda args: "donkey" in args.get("joke", "")), + action=_ReplaceAction(), + stage=GuardrailExecutionStage.PRE, + ) + def fn(joke: str) -> str: + return joke + + result = fn("why did the donkey cross the road") + assert result == "why did the [censored] cross the road" + + def test_post_output_contains_return_value(self): + captured: list[Any] = [] + + @guardrail( + validator=CustomValidator( + lambda args: captured.append(args) or False # type: ignore[func-returns-value] + ), + action=LogAction(), + stage=GuardrailExecutionStage.POST, + ) + def fn(x: int) -> dict[str, int]: + return {"result": x * 2} + + fn(5) + assert captured == [{"result": 10}] + + def test_post_modification_updates_return_value(self): + class _FixedAction(GuardrailAction): + def handle_validation_result(self, result, data, name): + return {"result": 99} + + @guardrail( + validator=CustomValidator(lambda args: True), + action=_FixedAction(), + stage=GuardrailExecutionStage.POST, + ) + def fn(x: int) -> dict[str, int]: + return {"result": x * 2} + + assert fn(5) == {"result": 99} + + def test_pre_and_post_both_fire(self): + calls: list[str] = [] + + mock_validator = MagicMock() + mock_validator.supported_stages = [] + mock_validator.validate_stage = MagicMock() + + mock_validator.run.side_effect = lambda name, desc, enabled, data, stage, *a: ( + calls.append(stage.value) or _PASSED # type: ignore[func-returns-value] + ) + + @guardrail( + validator=mock_validator, + action=LogAction(), + stage=GuardrailExecutionStage.PRE_AND_POST, + ) + def fn(x: int) -> int: + return x + 1 + + fn(1) + assert "pre" in calls + assert "post" in calls + + +# --------------------------------------------------------------------------- +# 10. @guardrail on async functions +# --------------------------------------------------------------------------- + + +class TestGuardrailOnAsyncFunction: + @pytest.mark.asyncio + async def test_pre_fires_before_async_function(self): + calls: list[str] = [] + + mock_validator = MagicMock() + mock_validator.supported_stages = [] + mock_validator.validate_stage = MagicMock() + + mock_validator.run.side_effect = lambda *a, **kw: ( + calls.append("eval") or _PASSED # type: ignore[func-returns-value] + ) + + @guardrail( + validator=mock_validator, + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + async def fn(text: str) -> str: + calls.append("fn") + return text + + await fn("hello") + assert calls == ["eval", "fn"] + + @pytest.mark.asyncio + async def test_block_action_raises_in_async(self): + mock_validator = MagicMock() + mock_validator.supported_stages = [] + mock_validator.validate_stage = MagicMock() + + mock_validator.run.return_value = _FAILED + + @guardrail( + validator=mock_validator, + action=BlockAction(), + stage=GuardrailExecutionStage.PRE, + ) + async def fn(text: str) -> str: + return text + + with pytest.raises(GuardrailBlockException): + await fn("bad") + + @pytest.mark.asyncio + async def test_post_modification_in_async(self): + class _FortyTwoAction(GuardrailAction): + def handle_validation_result(self, result, data, name): + return {"result": 42} + + @guardrail( + validator=CustomValidator(lambda args: True), + action=_FortyTwoAction(), + stage=GuardrailExecutionStage.POST, + ) + async def fn(x: int) -> dict[str, int]: + return {"result": x} + + assert await fn(1) == {"result": 42} + + +# --------------------------------------------------------------------------- +# 11. Stage enforcement at decoration time +# --------------------------------------------------------------------------- + + +class TestStageEnforcement: + def test_prompt_injection_on_post_raises_at_decoration(self): + with pytest.raises(ValueError, match="stage"): + guardrail( + lambda text: text, + validator=PromptInjectionValidator(), + action=LogAction(), + stage=GuardrailExecutionStage.POST, + ) + + def test_prompt_injection_on_pre_and_post_raises_at_decoration(self): + with pytest.raises(ValueError, match="stage"): + guardrail( + lambda text: text, + validator=PromptInjectionValidator(), + action=LogAction(), + stage=GuardrailExecutionStage.PRE_AND_POST, + ) + + def test_prompt_injection_on_pre_ok(self): + # Should not raise + guardrail( + lambda text: text, + validator=PromptInjectionValidator(), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + + +# --------------------------------------------------------------------------- +# 12. @guardrail validation (bad arguments) +# --------------------------------------------------------------------------- + + +class TestGuardrailDecorator: + def test_missing_action_raises(self): + with pytest.raises(ValueError, match="action must be provided"): + guardrail( + lambda text: text, + validator=CustomValidator(lambda args: False), + action=None, # type: ignore[arg-type] + ) + + def test_non_action_instance_raises(self): + with pytest.raises(ValueError, match="GuardrailAction"): + guardrail( + lambda text: text, + validator=CustomValidator(lambda args: False), + action="bad", # type: ignore[arg-type] + ) + + def test_invalid_enabled_for_evals_type_raises(self): + with pytest.raises(ValueError, match="boolean"): + guardrail( + lambda text: text, + validator=CustomValidator(lambda args: False), + action=LogAction(), + enabled_for_evals="yes", # type: ignore[arg-type] + ) + + +# --------------------------------------------------------------------------- +# 13. Stacked decorators +# --------------------------------------------------------------------------- + + +class TestStackedDecorators: + def test_both_decorators_fire_on_same_function(self): + calls: list[str] = [] + + def _make_mock(tag: str) -> Any: + m = MagicMock() + m.supported_stages = [] + m.validate_stage = MagicMock() + m.get_built_in_guardrail.return_value = None + m.run.side_effect = lambda *a, **kw: calls.append(tag) or _PASSED # type: ignore[func-returns-value] + return m + + @guardrail( + validator=_make_mock("outer"), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + name="outer", + ) + @guardrail( + validator=_make_mock("inner"), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + name="inner", + ) + def fn(text: str) -> str: + return text + + fn("hello") + assert "outer" in calls + assert "inner" in calls + + def test_outer_block_prevents_inner_from_firing(self): + inner_called = [] + + outer_validator = MagicMock() + outer_validator.supported_stages = [] + outer_validator.validate_stage = MagicMock() + outer_validator.get_built_in_guardrail.return_value = None + outer_validator.run.return_value = _FAILED + + inner_validator = MagicMock() + inner_validator.supported_stages = [] + inner_validator.validate_stage = MagicMock() + inner_validator.get_built_in_guardrail.return_value = None + inner_validator.run.side_effect = lambda *a, **kw: ( + inner_called.append(True) or _PASSED # type: ignore[func-returns-value] + ) + + @guardrail( + validator=outer_validator, + action=BlockAction(), + stage=GuardrailExecutionStage.PRE, + ) + @guardrail( + validator=inner_validator, + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + def fn(text: str) -> str: + return text + + with pytest.raises(GuardrailBlockException): + fn("bad") + + assert not inner_called + + +# --------------------------------------------------------------------------- +# 14. Factory function path (adapter wraps return value) +# --------------------------------------------------------------------------- + + +class TestFactoryFunctionPath: + def test_adapter_wraps_return_value_of_factory(self): + register_guardrail_adapter(_DummyAdapter()) + target = _DummyTarget(return_value={"output": "ok"}) + + @guardrail( + validator=CustomValidator(lambda args: False), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + def factory() -> _DummyTarget: + return target + + wrapped = factory() + assert isinstance(wrapped, _WrappedDummyTarget) + + def test_factory_pre_guardrail_fires_on_factory_params(self): + register_guardrail_adapter(_DummyAdapter()) + captured: list[Any] = [] + target = _DummyTarget() + + @guardrail( + validator=CustomValidator( + lambda args: captured.append(args) or False # type: ignore[func-returns-value] + ), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + def factory(config: str) -> _DummyTarget: + return target + + factory("test-config") + assert captured == [{"config": "test-config"}] + + def test_adapter_recognizes_direct_object(self): + register_guardrail_adapter(_DummyAdapter()) + target = _DummyTarget() + + wrapped = guardrail( + target, + validator=CustomValidator(lambda args: False), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + assert isinstance(wrapped, _WrappedDummyTarget) + + def test_stacked_guardrails_on_factory_both_wrap_return_value(self): + register_guardrail_adapter(_DummyAdapter()) + target = _DummyTarget() + evals: list[str] = [] + + def _make_mock(tag: str) -> Any: + m = MagicMock() + m.supported_stages = [] + m.validate_stage = MagicMock() + m.get_built_in_guardrail.return_value = None + m.run.side_effect = lambda *a, **kw: evals.append(tag) or _PASSED # type: ignore[func-returns-value] + return m + + @guardrail( + validator=_make_mock("outer"), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + @guardrail( + validator=_make_mock("inner"), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + def factory() -> _DummyTarget: + return target + + wrapped = factory() + wrapped.invoke({"x": 1}) + assert "outer" in evals + assert "inner" in evals + + +# --------------------------------------------------------------------------- +# 15. _make_evaluator — local vs API path +# --------------------------------------------------------------------------- + + +class TestMakeEvaluator: + def test_custom_validator_path_delegates_to_run(self): + """_make_evaluator with a CustomGuardrailValidator calls validator.run().""" + mock_validator = MagicMock() + mock_validator.run.return_value = _PASSED + evaluator = _make_evaluator(mock_validator, "G", None, True) + result = evaluator({"data": 1}, GuardrailExecutionStage.PRE, {"a": 1}, None) + mock_validator.run.assert_called_once_with( + "G", None, True, {"data": 1}, GuardrailExecutionStage.PRE, {"a": 1}, None + ) + assert result == _PASSED + + def test_built_in_validator_path_lazy_initializes_uipath(self): + """BuiltInGuardrailValidator.run() lazily creates UiPath() and calls API.""" + from uipath.platform.guardrails.decorators.validators import ( + BuiltInGuardrailValidator, + ) + from uipath.platform.guardrails.guardrails import BuiltInValidatorGuardrail + + mock_built_in = MagicMock(spec=BuiltInValidatorGuardrail) + + class _TestBuiltIn(BuiltInGuardrailValidator): + def get_built_in_guardrail(self, name, description, enabled_for_evals): + return mock_built_in + + validator = _TestBuiltIn() + evaluator = _make_evaluator(validator, "G", None, True) + + mock_uipath = MagicMock() + mock_uipath.guardrails.evaluate_guardrail.return_value = _PASSED + with patch("uipath.platform.UiPath", return_value=mock_uipath): + evaluator({"text": "hello"}, GuardrailExecutionStage.PRE, None, None) + evaluator({"text": "hello"}, GuardrailExecutionStage.PRE, None, None) + + # UiPath() should be created only once despite two calls + assert mock_uipath.guardrails.evaluate_guardrail.call_count == 2 + + +# --------------------------------------------------------------------------- +# 16. Joke-agent integration scenarios (plain functions) +# --------------------------------------------------------------------------- + + +class _JokeInput(BaseModel): + topic: str + + +class _JokeOutput(BaseModel): + joke: str + + +class TestJokeAgentScenarios: + """Integration tests modelled on the joke-agent-decorator sample.""" + + def test_pii_validator_blocks_person_name_in_topic(self): + """Agent-level PRE guardrail blocks person names in the input topic.""" + calls: list[str] = [] + + mock_validator = MagicMock() + mock_validator.supported_stages = [] + mock_validator.validate_stage = MagicMock() + + mock_validator.run.return_value = _FAILED + + @guardrail( + validator=mock_validator, + action=BlockAction(title="Person detected", detail="Not allowed"), + stage=GuardrailExecutionStage.PRE, + name="Agent PII", + ) + async def joke_node(state: _JokeInput) -> _JokeOutput: + calls.append("called") + return _JokeOutput(joke="a joke") + + import asyncio + + with pytest.raises(GuardrailBlockException): + asyncio.run(joke_node(_JokeInput(topic="John Smith"))) + assert not calls + + def test_input_pydantic_model_serialized_for_guardrail(self): + """State Pydantic model is serialized to dict and sent to evaluator.""" + captured: list[Any] = [] + + @guardrail( + validator=CustomValidator( + lambda args: captured.append(args) or False # type: ignore[func-returns-value] + ), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + def process(state: _JokeInput) -> _JokeOutput: + return _JokeOutput(joke="a joke") + + process(_JokeInput(topic="cats")) + assert captured == [{"state": {"topic": "cats"}}] + + def test_output_pydantic_model_serialized_for_guardrail(self): + """Return Pydantic model is serialized to dict and sent to POST evaluator.""" + captured: list[Any] = [] + + @guardrail( + validator=CustomValidator( + lambda args: captured.append(args) or False # type: ignore[func-returns-value] + ), + action=LogAction(), + stage=GuardrailExecutionStage.POST, + ) + def process(state: _JokeInput) -> _JokeOutput: + return _JokeOutput(joke="funny joke about cats") + + process(_JokeInput(topic="cats")) + assert captured == [{"joke": "funny joke about cats"}] + + def test_excluded_config_param_not_in_guardrail_input(self): + """RunnableConfig-style param excluded from evaluation.""" + captured: list[Any] = [] + + @guardrail( + validator=CustomValidator( + lambda args: captured.append(args) or False # type: ignore[func-returns-value] + ), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + def process( + state: _JokeInput, + config: Annotated[dict[str, Any], GuardrailExclude()], + ) -> _JokeOutput: + return _JokeOutput(joke="joke") + + process(_JokeInput(topic="dogs"), {"thread_id": "abc"}) + assert "config" not in captured[0] + assert "state" in captured[0] + + def test_word_filter_custom_validator_on_tool_function(self): + """CustomValidator on plain function replaces offensive word via action.""" + censored: list[str] = [] + + class CensorAction(GuardrailAction): + def handle_validation_result(self, result, data, name): + if isinstance(data, dict) and "joke" in data: + censored.append(data["joke"]) + return {"joke": data["joke"].replace("donkey", "[censored]")} + return data + + @guardrail( + validator=CustomValidator( + lambda args: "donkey" in args.get("joke", "").lower() + ), + action=CensorAction(), + stage=GuardrailExecutionStage.PRE, + name="Word Filter", + ) + def analyze_joke(joke: str) -> str: + return f"analyzed: {joke}" + + result = analyze_joke(joke="why did the donkey cross the road") + assert "censored" in result + assert "donkey" not in result + + def test_log_action_does_not_stop_joke_generation(self): + """LogAction on PII violation logs but lets execution continue.""" + + @guardrail( + validator=CustomValidator(lambda args: True), # always violate + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + name="Always-Log", + ) + def generate_joke(topic: str) -> str: + return f"joke about {topic}" + + result = generate_joke("cats") + assert result == "joke about cats" + + def test_length_limiter_blocks_long_joke(self): + """BlockAction on length check raises for over-long content.""" + + @guardrail( + validator=CustomValidator(lambda args: len(args.get("joke", "")) > 10), + action=BlockAction(title="Too long", detail="Joke exceeds limit"), + stage=GuardrailExecutionStage.PRE, + ) + def submit_joke(joke: str) -> str: + return joke + + with pytest.raises(GuardrailBlockException, match="Too long"): + submit_joke(joke="a" * 20) + + # Short joke passes through + assert submit_joke(joke="short") == "short" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index a9234b560..b01388722 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1056,7 +1056,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.10" +version = "0.5.11" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.19" +version = "0.1.20" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py index fe5c19bb9..1ddd7d102 100644 --- a/packages/uipath/tests/agent/models/test_agent.py +++ b/packages/uipath/tests/agent/models/test_agent.py @@ -723,6 +723,7 @@ def test_agent_config_loads_guardrails(self): == "This validator is designed to detect personally identifiable information using Azure Cognitive Services" ) assert agent_builtin_guardrail.enabled_for_evals is True + assert agent_builtin_guardrail.selector is not None assert agent_builtin_guardrail.selector.scopes == ["Tool"] assert agent_builtin_guardrail.selector.match_names == ["StringToNumber"] diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 48783e0bb..49fb5bc70 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2650,7 +2650,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.10" +version = "0.5.11" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.19" +version = "0.1.20" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 3b4cba5bc08c2643982e6c7a4c9117e51e484e3e Mon Sep 17 00:00:00 2001 From: UIPath-Harshit Date: Tue, 7 Apr 2026 16:00:31 +0530 Subject: [PATCH 004/191] =?UTF-8?q?Revert=20"Fix/entity=20as=20resource=20?= =?UTF-8?q?overwrite=20(#1544)"=20as=20the=20upstream=20cha=E2=80=A6=20(#1?= =?UTF-8?q?545)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/_uipath.py | 4 +- .../src/uipath/platform/common/__init__.py | 2 - .../src/uipath/platform/common/_bindings.py | 20 +- .../platform/entities/_entities_service.py | 186 ++---------------- .../tests/services/test_entities_service.py | 185 ++++------------- packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 2 +- .../src/uipath/_resources/SDK_REFERENCE.md | 4 +- .../tests/resource_overrides/overwrites.json | 4 - .../test_resource_overrides.py | 5 - packages/uipath/uv.lock | 4 +- 12 files changed, 63 insertions(+), 357 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 748075eb4..b53fb68f9 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.20" +version = "0.1.21" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index e1d60fc39..87c3a17f0 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -139,9 +139,7 @@ def llm(self) -> UiPathLlmChatService: @property def entities(self) -> EntitiesService: - return EntitiesService( - self._config, self._execution_context, folders_service=self.folders - ) + return EntitiesService(self._config, self._execution_context) @cached_property def resource_catalog(self) -> ResourceCatalogService: diff --git a/packages/uipath-platform/src/uipath/platform/common/__init__.py b/packages/uipath-platform/src/uipath/platform/common/__init__.py index 9070d0d70..40fc1ac34 100644 --- a/packages/uipath-platform/src/uipath/platform/common/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/common/__init__.py @@ -7,7 +7,6 @@ from ._base_service import BaseService from ._bindings import ( ConnectionResourceOverwrite, - EntityResourceOverwrite, GenericResourceOverwrite, ResourceOverwrite, ResourceOverwriteParser, @@ -101,7 +100,6 @@ "EndpointManager", "jsonschema_to_pydantic", "ConnectionResourceOverwrite", - "EntityResourceOverwrite", "GenericResourceOverwrite", "ResourceOverwrite", "ResourceOverwriteParser", diff --git a/packages/uipath-platform/src/uipath/platform/common/_bindings.py b/packages/uipath-platform/src/uipath/platform/common/_bindings.py index 1ccb2b1fc..449d2a7ef 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_bindings.py +++ b/packages/uipath-platform/src/uipath/platform/common/_bindings.py @@ -45,7 +45,7 @@ def folder_identifier(self) -> str: class GenericResourceOverwrite(ResourceOverwrite): resource_type: Literal[ - "process", "index", "app", "asset", "bucket", "mcpServer", "queue" + "process", "index", "app", "asset", "bucket", "mcpServer", "queue", "entity" ] name: str = Field(alias="name") folder_path: str = Field(alias="folderPath") @@ -59,20 +59,6 @@ def folder_identifier(self) -> str: return self.folder_path -class EntityResourceOverwrite(ResourceOverwrite): - resource_type: Literal["entity"] - name: str = Field(alias="name") - folder_key: str = Field(alias="folderId") - - @property - def resource_identifier(self) -> str: - return self.name - - @property - def folder_identifier(self) -> str: - return self.folder_key - - class ConnectionResourceOverwrite(ResourceOverwrite): resource_type: Literal["connection"] # In eval context, studio web provides "ConnectionId". @@ -97,9 +83,7 @@ def folder_identifier(self) -> str: ResourceOverwriteUnion = Annotated[ - Union[ - GenericResourceOverwrite, EntityResourceOverwrite, ConnectionResourceOverwrite - ], + Union[GenericResourceOverwrite, ConnectionResourceOverwrite], Field(discriminator="resource_type"), ] diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py index dc08131c5..f30c9492e 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py @@ -1,4 +1,3 @@ -import logging from typing import Any, Dict, List, Optional, Type import sqlparse @@ -8,21 +7,16 @@ from uipath.core.tracing import traced from ..common._base_service import BaseService -from ..common._bindings import EntityResourceOverwrite, _resource_overwrites from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext from ..common._models import Endpoint, RequestSpec -from ..orchestrator._folder_service import FolderService from .entities import ( Entity, EntityRecord, EntityRecordsBatchResponse, - EntityRouting, QueryRoutingOverrideContext, ) -logger = logging.getLogger(__name__) - _FORBIDDEN_DML = {"INSERT", "UPDATE", "DELETE", "MERGE", "REPLACE"} _FORBIDDEN_DDL = {"DROP", "ALTER", "CREATE", "TRUNCATE"} _DISALLOWED_KEYWORDS = [ @@ -53,32 +47,9 @@ class EntitiesService(BaseService): """ def __init__( - self, - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - folders_service: Optional[FolderService] = None, - folders_map: Optional[Dict[str, str]] = None, + self, config: UiPathApiConfig, execution_context: UiPathExecutionContext ) -> None: super().__init__(config=config, execution_context=execution_context) - self._folders_service = folders_service - self._folders_map = folders_map or {} - - def with_folders_map(self, folders_map: Dict[str, str]) -> "EntitiesService": - """Return a new EntitiesService configured with the given folders map. - - The map is used to build a routing context automatically when - ``query_entity_records`` is called without an explicit routing context. - Folder paths in the map are resolved to folder keys via ``FolderService``. - - Args: - folders_map: Mapping of entity name to folder path. - """ - return EntitiesService( - config=self._config, - execution_context=self._execution_context, - folders_service=self._folders_service, - folders_map=folders_map, - ) @traced(name="entity_retrieve", run_type="uipath") def retrieve(self, entity_key: str) -> Entity: @@ -446,6 +417,7 @@ class CustomerRecord: def query_entity_records( self, sql_query: str, + routing_context: Optional[QueryRoutingOverrideContext] = None, ) -> List[Dict[str, Any]]: """Query entity records using a validated SQL query. @@ -455,10 +427,9 @@ def query_entity_records( sql_query (str): A SQL SELECT query to execute against Data Service entities. Only SELECT statements are allowed. Queries without WHERE must include a LIMIT clause. Subqueries and multi-statement queries are not permitted. - - Notes: - A routing context is always derived from the configured ``folders_map`` - when present and included in the request body. + routing_context (Optional[QueryRoutingOverrideContext]): Per-entity routing context + for multi-folder queries. When present, included in the request body + and takes precedence over the folder header on the backend. Returns: List[Dict[str, Any]]: A list of result records as dictionaries. @@ -467,12 +438,15 @@ def query_entity_records( ValueError: If the SQL query fails validation (e.g., non-SELECT, missing WHERE/LIMIT, forbidden keywords, subqueries). """ - return self._query_entities_for_records(sql_query) + return self._query_entities_for_records( + sql_query, routing_context=routing_context + ) @traced(name="entity_query_records", run_type="uipath") async def query_entity_records_async( self, sql_query: str, + routing_context: Optional[QueryRoutingOverrideContext] = None, ) -> List[Dict[str, Any]]: """Asynchronously query entity records using a validated SQL query. @@ -482,10 +456,9 @@ async def query_entity_records_async( sql_query (str): A SQL SELECT query to execute against Data Service entities. Only SELECT statements are allowed. Queries without WHERE must include a LIMIT clause. Subqueries and multi-statement queries are not permitted. - - Notes: - A routing context is always derived from the configured ``folders_map`` - when present and included in the request body. + routing_context (Optional[QueryRoutingOverrideContext]): Per-entity routing context + for multi-folder queries. When present, included in the request body + and takes precedence over the folder header on the backend. Returns: List[Dict[str, Any]]: A list of result records as dictionaries. @@ -494,14 +467,17 @@ async def query_entity_records_async( ValueError: If the SQL query fails validation (e.g., non-SELECT, missing WHERE/LIMIT, forbidden keywords, subqueries). """ - return await self._query_entities_for_records_async(sql_query) + return await self._query_entities_for_records_async( + sql_query, routing_context=routing_context + ) def _query_entities_for_records( self, sql_query: str, + *, + routing_context: Optional[QueryRoutingOverrideContext] = None, ) -> List[Dict[str, Any]]: self._validate_sql_query(sql_query) - routing_context = self._build_routing_context_from_map() spec = self._query_entity_records_spec(sql_query, routing_context) response = self.request(spec.method, spec.endpoint, json=spec.json) return response.json().get("results", []) @@ -509,9 +485,10 @@ def _query_entities_for_records( async def _query_entities_for_records_async( self, sql_query: str, + *, + routing_context: Optional[QueryRoutingOverrideContext] = None, ) -> List[Dict[str, Any]]: self._validate_sql_query(sql_query) - routing_context = await self._build_routing_context_from_map_async() spec = self._query_entity_records_spec(sql_query, routing_context) response = await self.request_async(spec.method, spec.endpoint, json=spec.json) return response.json().get("results", []) @@ -1015,131 +992,6 @@ def _query_entity_records_spec( json=body, ) - def _build_routing_context_from_map( - self, - ) -> Optional[QueryRoutingOverrideContext]: - """Build a routing context from the configured folders_map and context overwrites. - - Folder paths in the map are resolved to folder keys via FolderService. - Entity overwrites from the active ``ResourceOverwritesContext`` are - merged in, supplying ``override_entity_name`` when the overwrite - provides a different entity name. - - Returns: - A QueryRoutingOverrideContext if routing entries exist, - None otherwise. - """ - resolved = self._resolve_folder_paths_to_ids() - return self._build_routing_context_from_resolved_map(resolved) - - async def _build_routing_context_from_map_async( - self, - ) -> Optional[QueryRoutingOverrideContext]: - """Async version of _build_routing_context_from_map.""" - resolved = await self._resolve_folder_paths_to_ids_async() - return self._build_routing_context_from_resolved_map(resolved) - - def _resolve_folder_paths_to_ids(self) -> Optional[dict[str, str]]: - if not self._folders_map: - return None - - resolved: dict[str, str] = {} - for folder_path in set(self._folders_map.values()): - if self._folders_service is not None: - folder_key = self._folders_service.retrieve_folder_key(folder_path) - if folder_key is not None: - resolved[folder_path] = folder_key - continue - resolved[folder_path] = folder_path - - return resolved - - async def _resolve_folder_paths_to_ids_async(self) -> Optional[dict[str, str]]: - if not self._folders_map: - return None - - resolved: dict[str, str] = {} - for folder_path in set(self._folders_map.values()): - if self._folders_service is not None: - folder_key = await self._folders_service.retrieve_folder_key_async( - folder_path - ) - if folder_key is not None: - resolved[folder_path] = folder_key - continue - resolved[folder_path] = folder_path - - return resolved - - @staticmethod - def _get_entity_overwrites_from_context() -> Dict[str, EntityResourceOverwrite]: - """Extract entity overwrites from the active ResourceOverwritesContext. - - Returns: - A dict mapping original entity name to its EntityResourceOverwrite. - """ - context_overwrites = _resource_overwrites.get() - if not context_overwrites: - return {} - - result: Dict[str, EntityResourceOverwrite] = {} - for key, overwrite in context_overwrites.items(): - if isinstance(overwrite, EntityResourceOverwrite): - # Key format is "entity." - original_name = key.split(".", 1)[1] if "." in key else key - result[original_name] = overwrite - return result - - def _build_routing_context_from_resolved_map( - self, - resolved: Optional[dict[str, str]], - ) -> Optional[QueryRoutingOverrideContext]: - entity_overwrites = self._get_entity_overwrites_from_context() - - routings: List[EntityRouting] = [] - - # Add routings from folders_map - if self._folders_map and resolved is not None: - for name, folder_path in self._folders_map.items(): - overwrite = entity_overwrites.pop(name, None) - override_name = ( - overwrite.resource_identifier - if overwrite and overwrite.resource_identifier != name - else None - ) - folder_id = ( - overwrite.folder_identifier - if overwrite - else resolved.get(folder_path, folder_path) - ) - routings.append( - EntityRouting( - entity_name=name, - folder_id=folder_id, - override_entity_name=override_name, - ) - ) - - # Add routings from context overwrites not already in folders_map - for original_name, overwrite in entity_overwrites.items(): - override_name = ( - overwrite.resource_identifier - if overwrite.resource_identifier != original_name - else None - ) - routings.append( - EntityRouting( - entity_name=original_name, - folder_id=overwrite.folder_identifier, - override_entity_name=override_name, - ) - ) - - if not routings: - return None - - return QueryRoutingOverrideContext(entity_routings=routings) - def _insert_batch_spec(self, entity_key: str, records: List[Any]) -> RequestSpec: return RequestSpec( method="POST", diff --git a/packages/uipath-platform/tests/services/test_entities_service.py b/packages/uipath-platform/tests/services/test_entities_service.py index 4993a1367..8a9abafef 100644 --- a/packages/uipath-platform/tests/services/test_entities_service.py +++ b/packages/uipath-platform/tests/services/test_entities_service.py @@ -8,7 +8,7 @@ from pytest_httpx import HTTPXMock from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.entities import Entity +from uipath.platform.entities import Entity, EntityRouting, QueryRoutingOverrideContext from uipath.platform.entities._entities_service import EntitiesService @@ -390,21 +390,28 @@ async def test_query_entity_records_async_calls_request_for_valid_sql( assert result == [{"id": "c1"}] service.request_async.assert_called_once() - def test_query_entity_records_builds_routing_context_from_folders_map( + def test_query_entity_records_with_routing_context( self, - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, + service: EntitiesService, ) -> None: - service = EntitiesService( - config=config, - execution_context=execution_context, - folders_map={"Customers": "solution_folder", "Orders": "folder-2"}, - ) response = MagicMock() response.json.return_value = {"results": [{"id": 1}]} service.request = MagicMock(return_value=response) # type: ignore[method-assign] - result = service.query_entity_records("SELECT id FROM Customers LIMIT 10") + routing = QueryRoutingOverrideContext( + entity_routings=[ + EntityRouting(entity_name="Customers", folder_id="folder-1"), + EntityRouting( + entity_name="Orders", + folder_id="folder-2", + override_entity_name="OrdersV2", + ), + ] + ) + + result = service.query_entity_records( + "SELECT id FROM Customers LIMIT 10", routing_context=routing + ) assert result == [{"id": 1}] call_kwargs = service.request.call_args @@ -412,38 +419,39 @@ def test_query_entity_records_builds_routing_context_from_folders_map( assert body["query"] == "SELECT id FROM Customers LIMIT 10" assert body["routingContext"] == { "entityRoutings": [ - {"entityName": "Customers", "folderId": "solution_folder"}, - {"entityName": "Orders", "folderId": "folder-2"}, + {"entityName": "Customers", "folderId": "folder-1"}, + { + "entityName": "Orders", + "folderId": "folder-2", + "overrideEntityName": "OrdersV2", + }, ] } @pytest.mark.anyio - async def test_query_entity_records_async_builds_routing_context_from_folders_map( + async def test_query_entity_records_async_with_routing_context( self, - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, + service: EntitiesService, ) -> None: - service = EntitiesService( - config=config, - execution_context=execution_context, - folders_map={"Customers": "solution_folder"}, - ) response = MagicMock() response.json.return_value = {"results": [{"id": "c1"}]} service.request_async = AsyncMock(return_value=response) # type: ignore[method-assign] + routing = QueryRoutingOverrideContext( + entity_routings=[ + EntityRouting(entity_name="Customers", folder_id="folder-1"), + ] + ) + result = await service.query_entity_records_async( - "SELECT id FROM Customers WHERE id = 'c1'" + "SELECT id FROM Customers WHERE id = 'c1'", + routing_context=routing, ) assert result == [{"id": "c1"}] call_kwargs = service.request_async.call_args body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") - assert body["routingContext"] == { - "entityRoutings": [ - {"entityName": "Customers", "folderId": "solution_folder"}, - ] - } + assert "routingContext" in body def test_query_entity_records_without_routing_context_omits_key( self, @@ -458,128 +466,3 @@ def test_query_entity_records_without_routing_context_omits_key( call_kwargs = service.request.call_args body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") assert "routingContext" not in body - - def test_query_entity_records_picks_up_entity_overwrites_from_context( - self, - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - ) -> None: - from uipath.platform.common._bindings import ( - EntityResourceOverwrite, - _resource_overwrites, - ) - - service = EntitiesService( - config=config, - execution_context=execution_context, - ) - response = MagicMock() - response.json.return_value = {"results": [{"id": 1}]} - service.request = MagicMock(return_value=response) # type: ignore[method-assign] - - overwrite = EntityResourceOverwrite( - resource_type="entity", - name="Overwritten Customers", - folder_key="overwritten-folder-id", - ) - token = _resource_overwrites.set({"entity.Customers": overwrite}) - try: - service.query_entity_records("SELECT id FROM Customers LIMIT 10") - finally: - _resource_overwrites.reset(token) - - call_kwargs = service.request.call_args - body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") - assert body["routingContext"] == { - "entityRoutings": [ - { - "entityName": "Customers", - "folderId": "overwritten-folder-id", - "overrideEntityName": "Overwritten Customers", - }, - ] - } - - def test_query_entity_records_merges_folders_map_with_context_overwrites( - self, - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - ) -> None: - from uipath.platform.common._bindings import ( - EntityResourceOverwrite, - _resource_overwrites, - ) - - service = EntitiesService( - config=config, - execution_context=execution_context, - folders_map={"Customers": "original-folder", "Orders": "orders-folder"}, - ) - response = MagicMock() - response.json.return_value = {"results": []} - service.request = MagicMock(return_value=response) # type: ignore[method-assign] - - # Overwrite only Customers — Orders should keep its folders_map value - overwrite = EntityResourceOverwrite( - resource_type="entity", - name="Overwritten Customers", - folder_key="overwritten-folder-id", - ) - token = _resource_overwrites.set({"entity.Customers": overwrite}) - try: - service.query_entity_records("SELECT id FROM Customers LIMIT 10") - finally: - _resource_overwrites.reset(token) - - call_kwargs = service.request.call_args - body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") - routings = body["routingContext"]["entityRoutings"] - # Customers overwritten by context - assert { - "entityName": "Customers", - "folderId": "overwritten-folder-id", - "overrideEntityName": "Overwritten Customers", - } in routings - # Orders unchanged from folders_map - assert {"entityName": "Orders", "folderId": "orders-folder"} in routings - - def test_query_entity_records_context_overwrite_same_name_no_override_field( - self, - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - ) -> None: - from uipath.platform.common._bindings import ( - EntityResourceOverwrite, - _resource_overwrites, - ) - - service = EntitiesService( - config=config, - execution_context=execution_context, - ) - response = MagicMock() - response.json.return_value = {"results": []} - service.request = MagicMock(return_value=response) # type: ignore[method-assign] - - # Same entity name — only folder changes, no override_entity_name needed - overwrite = EntityResourceOverwrite( - resource_type="entity", - name="Customers", - folder_key="different-folder-id", - ) - token = _resource_overwrites.set({"entity.Customers": overwrite}) - try: - service.query_entity_records("SELECT id FROM Customers LIMIT 10") - finally: - _resource_overwrites.reset(token) - - call_kwargs = service.request.call_args - body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") - assert body["routingContext"] == { - "entityRoutings": [ - { - "entityName": "Customers", - "folderId": "different-folder-id", - }, - ] - } diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index b01388722..648df8ab4 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.20" +version = "0.1.21" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 58f523a02..207f46657 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.42" +version = "2.10.43" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md b/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md index 5b03700be..4af1b60ae 100644 --- a/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md +++ b/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md @@ -500,10 +500,10 @@ sdk.entities.list_records(entity_key: str, schema: Optional[Type[Any]]=None, sta sdk.entities.list_records_async(entity_key: str, schema: Optional[Type[Any]]=None, start: Optional[int]=None, limit: Optional[int]=None) -> typing.List[uipath.platform.entities.entities.EntityRecord] # Query entity records using a validated SQL query. -sdk.entities.query_entity_records(sql_query: str) -> typing.List[typing.Dict[str, typing.Any]] +sdk.entities.query_entity_records(sql_query: str, routing_context: Optional[uipath.platform.entities.entities.QueryRoutingOverrideContext]=None) -> typing.List[typing.Dict[str, typing.Any]] # Asynchronously query entity records using a validated SQL query. -sdk.entities.query_entity_records_async(sql_query: str) -> typing.List[typing.Dict[str, typing.Any]] +sdk.entities.query_entity_records_async(sql_query: str, routing_context: Optional[uipath.platform.entities.entities.QueryRoutingOverrideContext]=None) -> typing.List[typing.Dict[str, typing.Any]] # Retrieve an entity by its key. sdk.entities.retrieve(entity_key: str) -> uipath.platform.entities.entities.Entity diff --git a/packages/uipath/tests/resource_overrides/overwrites.json b/packages/uipath/tests/resource_overrides/overwrites.json index e0bca84ba..c58744a69 100644 --- a/packages/uipath/tests/resource_overrides/overwrites.json +++ b/packages/uipath/tests/resource_overrides/overwrites.json @@ -28,9 +28,5 @@ "mcpServer.mcp_server_name": { "name": "Overwritten MCP Server Name", "folderPath": "Overwritten/MCPServer/Folder" - }, - "entity.entity_name": { - "name": "Overwritten Entity Name", - "folderId": "overwritten-entity-folder-id-123" } } \ No newline at end of file diff --git a/packages/uipath/tests/resource_overrides/test_resource_overrides.py b/packages/uipath/tests/resource_overrides/test_resource_overrides.py index 8d39a762d..c15bc113b 100644 --- a/packages/uipath/tests/resource_overrides/test_resource_overrides.py +++ b/packages/uipath/tests/resource_overrides/test_resource_overrides.py @@ -310,11 +310,6 @@ def test_parse_overwrites_with_type_adapter(self, overwrites_data): assert mcp_server.resource_identifier == "Overwritten MCP Server Name" assert mcp_server.folder_identifier == "Overwritten/MCPServer/Folder" - # Verify entity overwrite - entity = parsed_overwrites["entity.entity_name"] - assert entity.resource_identifier == "Overwritten Entity Name" - assert entity.folder_identifier == "overwritten-entity-folder-id-123" - def test_overrides_decorator_should_pop_kwargs_dict_when_present(self): from uipath.platform.common import resource_override diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 49fb5bc70..b8a817758 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.42" +version = "2.10.43" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.20" +version = "0.1.21" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From d52bcf1be463425d0ea60639a3db6cf55debc75b Mon Sep 17 00:00:00 2001 From: Clement Date: Wed, 8 Apr 2026 09:37:46 -0500 Subject: [PATCH 005/191] fix(platform): use LLMOps span ID in trace header (#1547) Co-authored-by: Clement Fauchere Co-authored-by: Claude Opus 4.6 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/common/_base_service.py | 11 +++++++++-- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index b53fb68f9..44f48463f 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.21" +version = "0.1.22" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/common/_base_service.py b/packages/uipath-platform/src/uipath/platform/common/_base_service.py index d236e4ef5..2bd7c7ec3 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_base_service.py +++ b/packages/uipath-platform/src/uipath/platform/common/_base_service.py @@ -67,8 +67,15 @@ def _get_caller_component() -> str: def _inject_trace_context(headers: dict[str, str]) -> None: - """Inject UiPath trace context header from the active OTEL span.""" - span = trace.get_current_span() + """Inject UiPath trace context header from the active OTEL span. + + Prefers the LLMOps tool span (from ContextVar) over the raw OTEL span, + so the header carries the span ID visible in the LLMOps trace. + """ + from uipath.core.tracing.span_utils import UiPathSpanUtils + + llmops_span = UiPathSpanUtils.get_external_current_span() + span = llmops_span or trace.get_current_span() ctx = span.get_span_context() if ctx.trace_id and ctx.span_id: headers[_TRACE_PARENT_HEADER] = ( diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 648df8ab4..0494f3530 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.21" +version = "0.1.22" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index b8a817758..679981407 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.21" +version = "0.1.22" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From ec4b750ab52e786012fe55d78dc683f1aeaecf2d Mon Sep 17 00:00:00 2001 From: Popescu Tudor-Cristian <94108303+PopescuTudor@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:48:20 +0300 Subject: [PATCH 006/191] feat: enable llms-full.txt generation and add eval docs to llmstxt plugin (#1550) --- packages/uipath/docs/assets/llms.txt | 58 ---------------------------- packages/uipath/mkdocs.yml | 2 + 2 files changed, 2 insertions(+), 58 deletions(-) delete mode 100644 packages/uipath/docs/assets/llms.txt diff --git a/packages/uipath/docs/assets/llms.txt b/packages/uipath/docs/assets/llms.txt deleted file mode 100644 index ac9907c58..000000000 --- a/packages/uipath/docs/assets/llms.txt +++ /dev/null @@ -1,58 +0,0 @@ -# UiPath Python SDK Documentation -> https://uipath.github.io/uipath-python/ - -A Python SDK for programmatic interaction with UiPath Cloud Platform services, featuring CLI tools for automation creation, packaging, and deployment. Includes support for LangChain, LlamaIndex, and Model Context Protocol (MCP) agent frameworks. - -## Core Documentation - -### Getting Started -- https://uipath.github.io/uipath-python/ - Main landing page -- https://uipath.github.io/uipath-python/core/getting_started - SDK quickstart guide -- https://uipath.github.io/uipath-python/FAQ - Frequently Asked Questions -- https://uipath.github.io/uipath-python/CONTRIBUTING - Contribution guidelines -- https://uipath.github.io/uipath-python/release_policy - Release policy - -### SDK Features -- https://uipath.github.io/uipath-python/core/processes - Process automation -- https://uipath.github.io/uipath-python/core/jobs - Job management -- https://uipath.github.io/uipath-python/core/assets - Asset storage and retrieval -- https://uipath.github.io/uipath-python/core/queues - Queue operations -- https://uipath.github.io/uipath-python/core/resource_catalog - Resources search -- https://uipath.github.io/uipath-python/core/buckets - Cloud storage buckets -- https://uipath.github.io/uipath-python/core/attachments - File attachments -- https://uipath.github.io/uipath-python/core/actions - Action Center integration -- https://uipath.github.io/uipath-python/core/entities - Data Service integration -- https://uipath.github.io/uipath-python/core/connections - External connections -- https://uipath.github.io/uipath-python/core/documents - Document handling -- https://uipath.github.io/uipath-python/core/documents_models - Document data models -- https://uipath.github.io/uipath-python/core/environment_variables - Environment configuration -- https://uipath.github.io/uipath-python/core/guardrails - Guardrails validation -- https://uipath.github.io/uipath-python/core/traced - Tracing and observability - -### LLM & AI Features -- https://uipath.github.io/uipath-python/core/llm_gateway - LLM Gateway for model access -- https://uipath.github.io/uipath-python/core/context_grounding - RAG and semantic search - -### Agent Frameworks -- https://uipath.github.io/uipath-python/mcp/quick_start - Model Context Protocol (MCP) SDK -- https://uipath.github.io/uipath-python/langchain/quick_start - LangChain integration -- https://uipath.github.io/uipath-python/llamaindex/quick_start - LlamaIndex integration - -### CLI Tools -- https://uipath.github.io/uipath-python/cli/ - Command-line interface reference - -## Supported LLM Models - -The following LLM models are referenced in examples and evaluations throughout the repository: - -### OpenAI Models -- gpt-4o-mini-2024-07-18 - Used in samples and evaluations -- gpt-4o-2024-08-06 - Primary model for agent examples -- gpt-4 - General purpose examples -- gpt-4.1-2025-04-14 - LLM-as-judge evaluator - -### Google Models -- gemini-1.5-flash - Used in Google ADK agent sample - -### Embedding Models -- text-embedding-3-large - Azure OpenAI embeddings for RAG diff --git a/packages/uipath/mkdocs.yml b/packages/uipath/mkdocs.yml index 0b7fafd34..abffe1161 100644 --- a/packages/uipath/mkdocs.yml +++ b/packages/uipath/mkdocs.yml @@ -120,10 +120,12 @@ nav: plugins: - search - llmstxt: + full_output: llms-full.txt sections: "UiPath SDK": - core/*.md - cli/*.md + - eval/*.md "UiPath MCP SDK": - mcp/*.md "UiPath LangChain SDK": From 8df27995cd43e47b1decbe310fc0630a4f05e7c3 Mon Sep 17 00:00:00 2001 From: Clement Date: Wed, 8 Apr 2026 15:43:52 -0500 Subject: [PATCH 007/191] fix(platform): use agent trace ID from UIPATH_TRACE_ID in trace header (#1551) Co-authored-by: Clement Fauchere Co-authored-by: Claude Opus 4.6 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../uipath/platform/common/_base_service.py | 26 ++++++++++++++----- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 44f48463f..e22bf6ace 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.22" +version = "0.1.23" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/common/_base_service.py b/packages/uipath-platform/src/uipath/platform/common/_base_service.py index 2bd7c7ec3..21d8fa404 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_base_service.py +++ b/packages/uipath-platform/src/uipath/platform/common/_base_service.py @@ -67,20 +67,32 @@ def _get_caller_component() -> str: def _inject_trace_context(headers: dict[str, str]) -> None: - """Inject UiPath trace context header from the active OTEL span. + """Inject UiPath trace context header. - Prefers the LLMOps tool span (from ContextVar) over the raw OTEL span, - so the header carries the span ID visible in the LLMOps trace. + Trace ID: uses the agent trace ID from UIPATH_TRACE_ID env var (same + remapping the LLMOps exporter applies), falling back to the OTEL trace ID. + Span ID: uses the LLMOps tool span (via external span provider) so the + span ID matches what's visible in the LLMOps trace UI. """ from uipath.core.tracing.span_utils import UiPathSpanUtils + from ._config import UiPathConfig + from ._span_utils import _SpanUtils + llmops_span = UiPathSpanUtils.get_external_current_span() span = llmops_span or trace.get_current_span() ctx = span.get_span_context() - if ctx.trace_id and ctx.span_id: - headers[_TRACE_PARENT_HEADER] = ( - f"00-{format_trace_id(ctx.trace_id)}-{format_span_id(ctx.span_id)}-01" - ) + if not (ctx.trace_id and ctx.span_id): + return + + config_trace_id = UiPathConfig.trace_id + trace_id = ( + _SpanUtils.normalize_trace_id(config_trace_id) + if config_trace_id + else format_trace_id(ctx.trace_id) + ) + span_id = format_span_id(ctx.span_id) + headers[_TRACE_PARENT_HEADER] = f"00-{trace_id}-{span_id}-01" class BaseService: diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 0494f3530..db41f6ec8 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.22" +version = "0.1.23" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 679981407..81be68e4d 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.22" +version = "0.1.23" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From cbcb8841edce75f9977eb0d1fecdeff73fd6d94d Mon Sep 17 00:00:00 2001 From: Popescu Tudor-Cristian <94108303+PopescuTudor@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:15:11 +0300 Subject: [PATCH 008/191] feat: add runtime fields to AgentA2aResourceConfig for A2A tool support (#1553) --- packages/uipath/pyproject.toml | 2 +- packages/uipath/src/uipath/agent/models/agent.py | 2 ++ packages/uipath/uv.lock | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 207f46657..4b1b0dc3f 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.43" +version = "2.10.44" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 901ce7ca9..c5a63af7c 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -485,6 +485,8 @@ class AgentA2aResourceConfig(BaseAgentResourceConfig): created_by: Optional[str] = Field(default=None, alias="createdBy") updated_at: Optional[str] = Field(default=None, alias="updatedAt") updated_by: Optional[str] = Field(default=None, alias="updatedBy") + a2a_url: str = Field(default="", alias="a2aUrl") + folder_path: Optional[str] = Field(default=None, alias="folderPath") _RECIPIENT_TYPE_NORMALIZED_MAP: Mapping[int | str, AgentEscalationRecipientType] = { diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 81be68e4d..ba40c276e 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.43" +version = "2.10.44" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 1bf32e29242d3cb919dd79b1e7afdef78ea262d0 Mon Sep 17 00:00:00 2001 From: UIPath-Harshit Date: Thu, 9 Apr 2026 17:33:32 +0530 Subject: [PATCH 009/191] Fix/entity overwrite fixes (#1548) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/_uipath.py | 4 +- .../src/uipath/platform/common/__init__.py | 2 + .../src/uipath/platform/common/_bindings.py | 54 +- .../src/uipath/platform/entities/__init__.py | 4 + .../platform/entities/_entities_service.py | 189 ++++++- .../platform/entities/_entity_resolution.py | 526 ++++++++++++++++++ .../src/uipath/platform/entities/entities.py | 40 +- .../tests/services/test_entities_service.py | 377 +++++++++++-- packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 2 +- .../src/uipath/_resources/SDK_REFERENCE.md | 17 +- .../uipath/src/uipath/agent/models/agent.py | 11 +- .../uipath/tests/agent/models/test_agent.py | 6 +- .../tests/resource_overrides/overwrites.json | 4 + .../test_resource_overrides.py | 5 + packages/uipath/uv.lock | 4 +- 17 files changed, 1171 insertions(+), 78 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/entities/_entity_resolution.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index e22bf6ace..b54858ea6 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.23" +version = "0.1.24" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index 87c3a17f0..e1d60fc39 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -139,7 +139,9 @@ def llm(self) -> UiPathLlmChatService: @property def entities(self) -> EntitiesService: - return EntitiesService(self._config, self._execution_context) + return EntitiesService( + self._config, self._execution_context, folders_service=self.folders + ) @cached_property def resource_catalog(self) -> ResourceCatalogService: diff --git a/packages/uipath-platform/src/uipath/platform/common/__init__.py b/packages/uipath-platform/src/uipath/platform/common/__init__.py index 40fc1ac34..9070d0d70 100644 --- a/packages/uipath-platform/src/uipath/platform/common/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/common/__init__.py @@ -7,6 +7,7 @@ from ._base_service import BaseService from ._bindings import ( ConnectionResourceOverwrite, + EntityResourceOverwrite, GenericResourceOverwrite, ResourceOverwrite, ResourceOverwriteParser, @@ -100,6 +101,7 @@ "EndpointManager", "jsonschema_to_pydantic", "ConnectionResourceOverwrite", + "EntityResourceOverwrite", "GenericResourceOverwrite", "ResourceOverwrite", "ResourceOverwriteParser", diff --git a/packages/uipath-platform/src/uipath/platform/common/_bindings.py b/packages/uipath-platform/src/uipath/platform/common/_bindings.py index 449d2a7ef..321b83c4f 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_bindings.py +++ b/packages/uipath-platform/src/uipath/platform/common/_bindings.py @@ -14,7 +14,14 @@ Union, ) -from pydantic import AliasChoices, BaseModel, ConfigDict, Field, TypeAdapter +from pydantic import ( + AliasChoices, + BaseModel, + ConfigDict, + Field, + TypeAdapter, + model_validator, +) logger = logging.getLogger(__name__) @@ -45,7 +52,7 @@ def folder_identifier(self) -> str: class GenericResourceOverwrite(ResourceOverwrite): resource_type: Literal[ - "process", "index", "app", "asset", "bucket", "mcpServer", "queue", "entity" + "process", "index", "app", "asset", "bucket", "mcpServer", "queue" ] name: str = Field(alias="name") folder_path: str = Field(alias="folderPath") @@ -59,6 +66,29 @@ def folder_identifier(self) -> str: return self.folder_path +class EntityResourceOverwrite(ResourceOverwrite): + resource_type: Literal["entity"] + name: str = Field(alias="name") + folder_id: Optional[str] = Field(default=None, alias="folderId") + folder_path: Optional[str] = Field(default=None, alias="folderPath") + + @model_validator(mode="after") + def validate_folder_identifier(self) -> "EntityResourceOverwrite": + if self.folder_id and self.folder_path: + raise ValueError("Only one of folderId or folderPath may be provided.") + if not self.folder_id and not self.folder_path: + raise ValueError("Either folderId or folderPath must be provided.") + return self + + @property + def resource_identifier(self) -> str: + return self.name + + @property + def folder_identifier(self) -> str: + return self.folder_id or self.folder_path or "" + + class ConnectionResourceOverwrite(ResourceOverwrite): resource_type: Literal["connection"] # In eval context, studio web provides "ConnectionId". @@ -83,7 +113,9 @@ def folder_identifier(self) -> str: ResourceOverwriteUnion = Annotated[ - Union[GenericResourceOverwrite, ConnectionResourceOverwrite], + Union[ + GenericResourceOverwrite, EntityResourceOverwrite, ConnectionResourceOverwrite + ], Field(discriminator="resource_type"), ] @@ -112,9 +144,23 @@ def parse(cls, key: str, value: dict[str, Any]) -> ResourceOverwrite: The appropriate ResourceOverwrite subclass instance """ resource_type = key.split(".")[0] - value_with_type = {"resource_type": resource_type, **value} + normalized_value = cls._normalize_value(resource_type, value) + value_with_type = {"resource_type": resource_type, **normalized_value} return cls._adapter.validate_python(value_with_type) + @staticmethod + def _normalize_value(resource_type: str, value: dict[str, Any]) -> dict[str, Any]: + if resource_type != "entity": + return value + + normalized = dict(value) + if "folderId" in normalized: + normalized["folder_id"] = normalized.pop("folderId") + if "folderPath" in normalized: + normalized["folder_path"] = normalized.pop("folderPath") + + return normalized + _resource_overwrites: ContextVar[Optional[dict[str, ResourceOverwrite]]] = ContextVar( "resource_overwrites", default=None diff --git a/packages/uipath-platform/src/uipath/platform/entities/__init__.py b/packages/uipath-platform/src/uipath/platform/entities/__init__.py index bbc43cdb7..6c9ac7f9f 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/entities/__init__.py @@ -5,12 +5,14 @@ from ._entities_service import EntitiesService from .entities import ( + DataFabricEntityItem, Entity, EntityField, EntityFieldMetadata, EntityRecord, EntityRecordsBatchResponse, EntityRouting, + EntitySetResolution, ExternalField, ExternalObject, ExternalSourceFields, @@ -22,12 +24,14 @@ ) __all__ = [ + "DataFabricEntityItem", "EntitiesService", "Entity", "EntityField", "EntityRecord", "EntityFieldMetadata", "EntityRouting", + "EntitySetResolution", "FieldDataType", "FieldMetadata", "EntityRecordsBatchResponse", diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py index f30c9492e..43b437d65 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py @@ -1,3 +1,4 @@ +import logging from typing import Any, Dict, List, Optional, Type import sqlparse @@ -7,16 +8,32 @@ from uipath.core.tracing import traced from ..common._base_service import BaseService +from ..common._bindings import _resource_overwrites from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext from ..common._models import Endpoint, RequestSpec +from ..common.constants import HEADER_FOLDER_KEY +from ..orchestrator._folder_service import FolderService +from ._entity_resolution import ( + RoutingStrategy, + build_resolution_service, + create_resolution_plan, + create_resolution_plan_async, + create_routing_strategy, + fetch_resolved_entities, + fetch_resolved_entities_async, +) from .entities import ( + DataFabricEntityItem, Entity, EntityRecord, EntityRecordsBatchResponse, + EntitySetResolution, QueryRoutingOverrideContext, ) +logger = logging.getLogger(__name__) + _FORBIDDEN_DML = {"INSERT", "UPDATE", "DELETE", "MERGE", "REPLACE"} _FORBIDDEN_DDL = {"DROP", "ALTER", "CREATE", "TRUNCATE"} _DISALLOWED_KEYWORDS = [ @@ -47,9 +64,22 @@ class EntitiesService(BaseService): """ def __init__( - self, config: UiPathApiConfig, execution_context: UiPathExecutionContext + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Optional[FolderService] = None, + folders_map: Optional[Dict[str, str]] = None, + entity_name_overrides: Optional[Dict[str, str]] = None, + routing_context: Optional[QueryRoutingOverrideContext] = None, ) -> None: super().__init__(config=config, execution_context=execution_context) + self._folders_service = folders_service + self._routing_strategy: RoutingStrategy = create_routing_strategy( + folders_map=folders_map, + effective_entity_names=entity_name_overrides, + routing_context=routing_context, + folders_service=folders_service, + ) @traced(name="entity_retrieve", run_type="uipath") def retrieve(self, entity_key: str) -> Entity: @@ -128,6 +158,44 @@ async def retrieve_async(self, entity_key: str) -> Entity: return Entity.model_validate(response.json()) + @traced(name="entity_retrieve_by_name", run_type="uipath") + def retrieve_by_name( + self, entity_name: str, folder_key: Optional[str] = None + ) -> Entity: + """Retrieve an entity by its name. + + The server resolves the entity within the folder identified by + ``folder_key``. When omitted the default folder from the + execution context is used. + + Args: + entity_name: The name of the entity. + folder_key: Optional folder key for disambiguation. + """ + spec = self._retrieve_by_name_spec(entity_name) + headers = self._folder_key_headers(folder_key) + response = self.request(spec.method, spec.endpoint, headers=headers) + return Entity.model_validate(response.json()) + + @traced(name="entity_retrieve_by_name", run_type="uipath") + async def retrieve_by_name_async( + self, entity_name: str, folder_key: Optional[str] = None + ) -> Entity: + """Asynchronously retrieve an entity by its name. + + The server resolves the entity within the folder identified by + ``folder_key``. When omitted the default folder from the + execution context is used. + + Args: + entity_name: The name of the entity. + folder_key: Optional folder key for disambiguation. + """ + spec = self._retrieve_by_name_spec(entity_name) + headers = self._folder_key_headers(folder_key) + response = await self.request_async(spec.method, spec.endpoint, headers=headers) + return Entity.model_validate(response.json()) + @traced(name="list_entities", run_type="uipath") def list_entities(self) -> List[Entity]: """List all entities in Data Service. @@ -417,7 +485,6 @@ class CustomerRecord: def query_entity_records( self, sql_query: str, - routing_context: Optional[QueryRoutingOverrideContext] = None, ) -> List[Dict[str, Any]]: """Query entity records using a validated SQL query. @@ -427,9 +494,10 @@ def query_entity_records( sql_query (str): A SQL SELECT query to execute against Data Service entities. Only SELECT statements are allowed. Queries without WHERE must include a LIMIT clause. Subqueries and multi-statement queries are not permitted. - routing_context (Optional[QueryRoutingOverrideContext]): Per-entity routing context - for multi-folder queries. When present, included in the request body - and takes precedence over the folder header on the backend. + + Notes: + A routing context is always derived from the configured ``folders_map`` + when present and included in the request body. Returns: List[Dict[str, Any]]: A list of result records as dictionaries. @@ -438,15 +506,12 @@ def query_entity_records( ValueError: If the SQL query fails validation (e.g., non-SELECT, missing WHERE/LIMIT, forbidden keywords, subqueries). """ - return self._query_entities_for_records( - sql_query, routing_context=routing_context - ) + return self._query_entities_for_records(sql_query) @traced(name="entity_query_records", run_type="uipath") async def query_entity_records_async( self, sql_query: str, - routing_context: Optional[QueryRoutingOverrideContext] = None, ) -> List[Dict[str, Any]]: """Asynchronously query entity records using a validated SQL query. @@ -456,9 +521,10 @@ async def query_entity_records_async( sql_query (str): A SQL SELECT query to execute against Data Service entities. Only SELECT statements are allowed. Queries without WHERE must include a LIMIT clause. Subqueries and multi-statement queries are not permitted. - routing_context (Optional[QueryRoutingOverrideContext]): Per-entity routing context - for multi-folder queries. When present, included in the request body - and takes precedence over the folder header on the backend. + + Notes: + A routing context is always derived from the configured ``folders_map`` + when present and included in the request body. Returns: List[Dict[str, Any]]: A list of result records as dictionaries. @@ -467,17 +533,84 @@ async def query_entity_records_async( ValueError: If the SQL query fails validation (e.g., non-SELECT, missing WHERE/LIMIT, forbidden keywords, subqueries). """ - return await self._query_entities_for_records_async( - sql_query, routing_context=routing_context + return await self._query_entities_for_records_async(sql_query) + + @traced(name="resolve_entity_set", run_type="uipath") + def resolve_entity_set( + self, + items: list[DataFabricEntityItem], + ) -> EntitySetResolution: + """Resolve an agent entity set, applying resource overwrites.""" + plan = create_resolution_plan( + items, + _resource_overwrites.get() or {}, + lambda folder_path: ( + self._folders_service.retrieve_key(folder_path=folder_path) + if self._folders_service is not None + else None + ), + ) + entities = fetch_resolved_entities( + plan, + self.retrieve, + self.retrieve_by_name, + logger, + ) + resolution_service: EntitiesService = build_resolution_service( # type: ignore[assignment] + config=self._config, + execution_context=self._execution_context, + folders_service=self._folders_service, + plan=plan, + service_factory=EntitiesService, + ) + return EntitySetResolution( + entities=entities, + entities_service=resolution_service, + ) + + @traced(name="resolve_entity_set", run_type="uipath") + async def resolve_entity_set_async( + self, + items: list[DataFabricEntityItem], + ) -> EntitySetResolution: + """Resolve an agent entity set, applying resource overwrites.""" + + async def _resolve_folder_path(folder_path: str) -> Optional[str]: + if self._folders_service is None: + return None + return await self._folders_service.retrieve_key_async( + folder_path=folder_path + ) + + plan = await create_resolution_plan_async( + items, + _resource_overwrites.get() or {}, + _resolve_folder_path, + ) + entities = await fetch_resolved_entities_async( + plan, + self.retrieve_async, + self.retrieve_by_name_async, + logger, + ) + resolution_service: EntitiesService = build_resolution_service( # type: ignore[assignment] + config=self._config, + execution_context=self._execution_context, + folders_service=self._folders_service, + plan=plan, + service_factory=EntitiesService, + ) + return EntitySetResolution( + entities=entities, + entities_service=resolution_service, ) def _query_entities_for_records( self, sql_query: str, - *, - routing_context: Optional[QueryRoutingOverrideContext] = None, ) -> List[Dict[str, Any]]: self._validate_sql_query(sql_query) + routing_context = self._routing_strategy.resolve() spec = self._query_entity_records_spec(sql_query, routing_context) response = self.request(spec.method, spec.endpoint, json=spec.json) return response.json().get("results", []) @@ -485,10 +618,9 @@ def _query_entities_for_records( async def _query_entities_for_records_async( self, sql_query: str, - *, - routing_context: Optional[QueryRoutingOverrideContext] = None, ) -> List[Dict[str, Any]]: self._validate_sql_query(sql_query) + routing_context = await self._routing_strategy.resolve_async() spec = self._query_entity_records_spec(sql_query, routing_context) response = await self.request_async(spec.method, spec.endpoint, json=spec.json) return response.json().get("results", []) @@ -956,6 +1088,21 @@ def _retrieve_spec( endpoint=Endpoint(f"datafabric_/api/Entity/{entity_key}"), ) + def _retrieve_by_name_spec( + self, + entity_name: str, + ) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint(f"datafabric_/api/Entity/{entity_name}/metadata"), + ) + + @staticmethod + def _folder_key_headers(folder_key: Optional[str]) -> dict[str, str]: + if folder_key: + return {HEADER_FOLDER_KEY: folder_key} + return {} + def _list_entities_spec(self) -> RequestSpec: return RequestSpec( method="GET", @@ -1117,3 +1264,9 @@ def _projection_column_count( if not text: return 0 return len([part for part in text.split(",") if part.strip()]) + + +# Resolve the forward reference to EntitiesService in EntitySetResolution. +# The model uses TYPE_CHECKING to avoid circular imports in entities.py, +# so we must rebuild it here where EntitiesService is fully defined. +EntitySetResolution.model_rebuild() diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entity_resolution.py b/packages/uipath-platform/src/uipath/platform/entities/_entity_resolution.py new file mode 100644 index 000000000..2477b26b9 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/entities/_entity_resolution.py @@ -0,0 +1,526 @@ +from __future__ import annotations + +import abc +import asyncio +import logging +from dataclasses import dataclass +from typing import Awaitable, Callable, Dict, Optional + +from ..common._bindings import ( + EntityResourceOverwrite, + ResourceOverwrite, + _resource_overwrites, +) +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..orchestrator._folder_service import FolderService +from .entities import ( + DataFabricEntityItem, + Entity, + EntityRouting, + QueryRoutingOverrideContext, +) + +FolderPathResolver = Callable[[str], Optional[str]] +AsyncFolderPathResolver = Callable[[str], Awaitable[Optional[str]]] +EntityByKeyFetcher = Callable[[str], Entity] +AsyncEntityByKeyFetcher = Callable[[str], Awaitable[Entity]] +EntityByNameFetcher = Callable[[str, Optional[str]], Entity] +AsyncEntityByNameFetcher = Callable[[str, Optional[str]], Awaitable[Entity]] + + +# --------------------------------------------------------------------------- +# Routing strategy +# --------------------------------------------------------------------------- + + +class RoutingStrategy(abc.ABC): + """Strategy for resolving a ``QueryRoutingOverrideContext`` at query time.""" + + @abc.abstractmethod + def resolve(self) -> Optional[QueryRoutingOverrideContext]: ... + + @abc.abstractmethod + async def resolve_async(self) -> Optional[QueryRoutingOverrideContext]: ... + + +class PreResolvedRoutingStrategy(RoutingStrategy): + """Returns a routing context that was fully resolved at init time. + + Used after ``resolve_entity_set`` where all folder paths have already + been converted to folder keys and the routing context is immutable. + """ + + def __init__( + self, + routing_context: QueryRoutingOverrideContext, + ) -> None: + self._routing_context = routing_context + + def resolve(self) -> Optional[QueryRoutingOverrideContext]: + return self._routing_context + + async def resolve_async(self) -> Optional[QueryRoutingOverrideContext]: + return self._routing_context + + @property + def routing_context(self) -> QueryRoutingOverrideContext: + return self._routing_context + + +class FoldersMapRoutingStrategy(RoutingStrategy): + """Builds a routing context from a pre-populated folders map. + + Used when an ``EntitiesService`` is constructed with an explicit + ``folders_map`` (and optional entity-name overrides) but *without* a + pre-built routing context. Folder paths in the map are resolved to + folder keys lazily at query time via ``FolderService``. + """ + + def __init__( + self, + folders_map: Dict[str, str], + effective_entity_names: Dict[str, str], + folders_service: Optional[FolderService], + ) -> None: + self._folders_map = folders_map + self._effective_entity_names = effective_entity_names + self._folders_service = folders_service + + def resolve(self) -> Optional[QueryRoutingOverrideContext]: + resolved = self._resolve_folder_paths() + return build_resolution_routing_context( + { + name: (resolved or {}).get(path, path) + for name, path in self._folders_map.items() + }, + self._effective_entity_names, + ) + + async def resolve_async(self) -> Optional[QueryRoutingOverrideContext]: + resolved = await self._resolve_folder_paths_async() + return build_resolution_routing_context( + { + name: (resolved or {}).get(path, path) + for name, path in self._folders_map.items() + }, + self._effective_entity_names, + ) + + def _resolve_folder_paths(self) -> Optional[dict[str, str]]: + folder_paths = set(self._folders_map.values()) + if not folder_paths: + return None + + resolved: dict[str, str] = {} + for folder_path in folder_paths: + if self._folders_service is not None: + folder_key = self._folders_service.retrieve_key(folder_path=folder_path) + if folder_key is not None: + resolved[folder_path] = folder_key + continue + resolved[folder_path] = folder_path + return resolved + + async def _resolve_folder_paths_async(self) -> Optional[dict[str, str]]: + folder_paths = set(self._folders_map.values()) + if not folder_paths: + return None + + resolved: dict[str, str] = {} + for folder_path in folder_paths: + if self._folders_service is not None: + folder_key = await self._folders_service.retrieve_key_async( + folder_path=folder_path + ) + if folder_key is not None: + resolved[folder_path] = folder_key + continue + resolved[folder_path] = folder_path + return resolved + + +class ContextOverwriteRoutingStrategy(RoutingStrategy): + """Builds a routing context lazily from ``_resource_overwrites``. + + This is the fallback for direct SDK usage where no ``folders_map`` or + pre-resolved routing context exists. Entity overwrites are read from + the active ``ResourceOverwritesContext`` at query time. + """ + + def __init__(self, folders_service: Optional[FolderService]) -> None: + self._folders_service = folders_service + + def resolve(self) -> Optional[QueryRoutingOverrideContext]: + entity_overwrites = _get_entity_overwrites_from_context() + if not entity_overwrites: + return None + + folder_paths = { + ow.folder_path for ow in entity_overwrites.values() if ow.folder_path + } + resolved = self._resolve_paths(folder_paths) + return self._build(entity_overwrites, resolved) + + async def resolve_async(self) -> Optional[QueryRoutingOverrideContext]: + entity_overwrites = _get_entity_overwrites_from_context() + if not entity_overwrites: + return None + + folder_paths = { + ow.folder_path for ow in entity_overwrites.values() if ow.folder_path + } + resolved = await self._resolve_paths_async(folder_paths) + return self._build(entity_overwrites, resolved) + + def _resolve_paths(self, folder_paths: set[str]) -> dict[str, str]: + resolved: dict[str, str] = {} + for path in folder_paths: + if self._folders_service is not None: + key = self._folders_service.retrieve_key(folder_path=path) + if key is not None: + resolved[path] = key + continue + resolved[path] = path + return resolved + + async def _resolve_paths_async(self, folder_paths: set[str]) -> dict[str, str]: + resolved: dict[str, str] = {} + for path in folder_paths: + if self._folders_service is not None: + key = await self._folders_service.retrieve_key_async(folder_path=path) + if key is not None: + resolved[path] = key + continue + resolved[path] = path + return resolved + + @staticmethod + def _build( + entity_overwrites: Dict[str, EntityResourceOverwrite], + resolved: dict[str, str], + ) -> Optional[QueryRoutingOverrideContext]: + routings: list[EntityRouting] = [] + for original_name, overwrite in entity_overwrites.items(): + override_name = ( + overwrite.resource_identifier + if overwrite.resource_identifier != original_name + else None + ) + folder_id = _resolve_overwrite_folder(overwrite, resolved) + routings.append( + EntityRouting( + entity_name=original_name, + folder_id=folder_id, + override_entity_name=override_name, + ) + ) + + if not routings: + return None + return QueryRoutingOverrideContext(entity_routings=routings) + + +def create_routing_strategy( + *, + folders_map: Optional[Dict[str, str]], + effective_entity_names: Optional[Dict[str, str]], + routing_context: Optional[QueryRoutingOverrideContext], + folders_service: Optional[FolderService], +) -> RoutingStrategy: + """Select the appropriate routing strategy based on init-time state.""" + if routing_context is not None: + return PreResolvedRoutingStrategy(routing_context) + if folders_map: + return FoldersMapRoutingStrategy( + folders_map, + effective_entity_names or {}, + folders_service, + ) + return ContextOverwriteRoutingStrategy(folders_service) + + +# --------------------------------------------------------------------------- +# Helpers shared across strategies +# --------------------------------------------------------------------------- + + +def _get_entity_overwrites_from_context() -> Dict[str, EntityResourceOverwrite]: + """Extract entity overwrites from the active ResourceOverwritesContext.""" + context_overwrites = _resource_overwrites.get() + if not context_overwrites: + return {} + + result: Dict[str, EntityResourceOverwrite] = {} + for key, overwrite in context_overwrites.items(): + if isinstance(overwrite, EntityResourceOverwrite): + original_name = key.split(".", 1)[1] if "." in key else key + result[original_name] = overwrite + return result + + +def _resolve_overwrite_folder( + overwrite: EntityResourceOverwrite, + resolved: dict[str, str], +) -> str: + """Return the folder key for an entity overwrite. + + Uses folder_id directly when present (already a key). + Falls back to resolving folder_path through the resolved map. + """ + if overwrite.folder_id: + return overwrite.folder_id + if overwrite.folder_path and resolved: + return resolved.get(overwrite.folder_path, overwrite.folder_path) + return overwrite.folder_identifier + + +# --------------------------------------------------------------------------- +# Resolution plan (used by resolve_entity_set) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class EntityFetchByKey: + entity_key: str + + +@dataclass(frozen=True) +class EntityFetchByName: + entity_name: str + folder_key: str + + +@dataclass(frozen=True) +class EntityResolutionDraft: + fetch_by_key: list[EntityFetchByKey] + fetch_by_name: list[EntityFetchByName] + folders_map: dict[str, str] + effective_entity_names: dict[str, str] + folder_paths_to_resolve: set[str] + + +@dataclass(frozen=True) +class EntityResolutionPlan: + fetch_by_key: list[EntityFetchByKey] + fetch_by_name: list[EntityFetchByName] + folders_map: dict[str, str] + effective_entity_names: dict[str, str] + routing_context: QueryRoutingOverrideContext | None + + +def create_resolution_draft( + items: list[DataFabricEntityItem], + context_overwrites: dict[str, ResourceOverwrite], +) -> EntityResolutionDraft: + folders_map: dict[str, str] = {} + effective_entity_names: dict[str, str] = {} + folder_paths_to_resolve: set[str] = set() + fetch_by_key: list[EntityFetchByKey] = [] + fetch_by_name: list[EntityFetchByName] = [] + + for item in items: + overwrite = context_overwrites.get( + f"entity.{item.id}" + ) or context_overwrites.get(f"entity.{item.name}") + resolved_folder = item.folder_key + + if isinstance(overwrite, EntityResourceOverwrite): + folder_changed = False + if overwrite.folder_id: + resolved_folder = overwrite.folder_id + folder_changed = resolved_folder != item.folder_key + elif overwrite.folder_path: + resolved_folder = overwrite.folder_path + folder_changed = True + folder_paths_to_resolve.add(overwrite.folder_path) + + if overwrite.name != item.name or folder_changed: + if overwrite.name != item.name: + effective_entity_names[item.name] = overwrite.name + fetch_by_name.append( + EntityFetchByName( + entity_name=overwrite.name, + folder_key=resolved_folder, + ) + ) + folders_map[item.name] = resolved_folder + continue + + fetch_by_key.append(EntityFetchByKey(entity_key=item.entity_key or item.id)) + folders_map[item.name] = resolved_folder + + return EntityResolutionDraft( + fetch_by_key=fetch_by_key, + fetch_by_name=fetch_by_name, + folders_map=folders_map, + effective_entity_names=effective_entity_names, + folder_paths_to_resolve=folder_paths_to_resolve, + ) + + +def finalize_resolution_plan( + draft: EntityResolutionDraft, + resolve_folder_path: Callable[[str], Optional[str]], +) -> EntityResolutionPlan: + resolved_paths: dict[str, str] = {} + for folder_path in draft.folder_paths_to_resolve: + resolved_paths[folder_path] = resolve_folder_path(folder_path) or folder_path + + resolved_folders_map = { + entity_name: resolved_paths.get(folder_key, folder_key) + for entity_name, folder_key in draft.folders_map.items() + } + resolved_fetch_by_name = [ + EntityFetchByName( + entity_name=entry.entity_name, + folder_key=resolved_paths.get(entry.folder_key, entry.folder_key), + ) + for entry in draft.fetch_by_name + ] + + return EntityResolutionPlan( + fetch_by_key=draft.fetch_by_key, + fetch_by_name=resolved_fetch_by_name, + folders_map=resolved_folders_map, + effective_entity_names=draft.effective_entity_names, + routing_context=build_resolution_routing_context( + resolved_folders_map, + draft.effective_entity_names, + ), + ) + + +def build_resolution_routing_context( + folders_map: dict[str, str], + effective_entity_names: dict[str, str], +) -> QueryRoutingOverrideContext | None: + routings = [ + EntityRouting( + entity_name=original_name, + folder_id=folder_id, + override_entity_name=effective_entity_names.get(original_name), + ) + for original_name, folder_id in folders_map.items() + ] + if not routings: + return None + + return QueryRoutingOverrideContext(entity_routings=routings) + + +def create_resolution_plan( + items: list[DataFabricEntityItem], + context_overwrites: dict[str, ResourceOverwrite], + resolve_folder_path: FolderPathResolver, +) -> EntityResolutionPlan: + draft = create_resolution_draft(items, context_overwrites) + return finalize_resolution_plan(draft, resolve_folder_path) + + +async def create_resolution_plan_async( + items: list[DataFabricEntityItem], + context_overwrites: dict[str, ResourceOverwrite], + resolve_folder_path: AsyncFolderPathResolver, +) -> EntityResolutionPlan: + draft = create_resolution_draft(items, context_overwrites) + folder_paths = list(draft.folder_paths_to_resolve) + results = await asyncio.gather(*(resolve_folder_path(fp) for fp in folder_paths)) + resolved_paths = { + fp: result or fp for fp, result in zip(folder_paths, results, strict=True) + } + + return finalize_resolution_plan( + draft, + lambda folder_path: resolved_paths.get(folder_path, folder_path), + ) + + +def fetch_resolved_entities( + plan: EntityResolutionPlan, + retrieve_by_key: EntityByKeyFetcher, + retrieve_by_name: EntityByNameFetcher, + logger: logging.Logger, +) -> list[Entity]: + entities: list[Entity] = [] + for key_entry in plan.fetch_by_key: + try: + entities.append(retrieve_by_key(key_entry.entity_key)) + except Exception: + logger.warning( + "Failed to fetch entity by key '%s', skipping.", + key_entry.entity_key, + exc_info=True, + ) + + for name_entry in plan.fetch_by_name: + try: + entities.append( + retrieve_by_name(name_entry.entity_name, name_entry.folder_key) + ) + except Exception: + logger.warning( + "Failed to fetch entity by name '%s' (folder_key=%s), skipping.", + name_entry.entity_name, + name_entry.folder_key, + exc_info=True, + ) + + return entities + + +async def fetch_resolved_entities_async( + plan: EntityResolutionPlan, + retrieve_by_key: AsyncEntityByKeyFetcher, + retrieve_by_name: AsyncEntityByNameFetcher, + logger: logging.Logger, +) -> list[Entity]: + async def _safe_fetch_by_key(entry: EntityFetchByKey) -> Optional[Entity]: + try: + return await retrieve_by_key(entry.entity_key) + except Exception: + logger.warning( + "Failed to fetch entity by key '%s', skipping.", + entry.entity_key, + exc_info=True, + ) + return None + + async def _safe_fetch_by_name(entry: EntityFetchByName) -> Optional[Entity]: + try: + return await retrieve_by_name( + entry.entity_name, + entry.folder_key, + ) + except Exception: + logger.warning( + "Failed to fetch entity by name '%s' (folder_key=%s), skipping.", + entry.entity_name, + entry.folder_key, + exc_info=True, + ) + return None + + tasks = [_safe_fetch_by_key(entry) for entry in plan.fetch_by_key] + [ + _safe_fetch_by_name(entry) for entry in plan.fetch_by_name + ] + results = await asyncio.gather(*tasks) + return [entity for entity in results if entity is not None] + + +def build_resolution_service( + *, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: FolderService | None, + plan: EntityResolutionPlan, + service_factory: Callable[..., object], +) -> object: + return service_factory( + config=config, + execution_context=execution_context, + folders_service=folders_service, + folders_map=plan.folders_map, + entity_name_overrides=plan.effective_entity_names, + routing_context=plan.routing_context, + ) diff --git a/packages/uipath-platform/src/uipath/platform/entities/entities.py b/packages/uipath-platform/src/uipath/platform/entities/entities.py index b2c49b763..b14f308d7 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/entities.py +++ b/packages/uipath-platform/src/uipath/platform/entities/entities.py @@ -1,11 +1,26 @@ """Entities models for UiPath Platform API interactions.""" +from __future__ import annotations + from enum import Enum from types import EllipsisType -from typing import Any, Dict, List, Optional, Type, Union, get_args, get_origin +from typing import ( + TYPE_CHECKING, + Any, + Dict, + List, + Optional, + Type, + Union, + get_args, + get_origin, +) from pydantic import BaseModel, ConfigDict, Field, create_model +if TYPE_CHECKING: + from ._entities_service import EntitiesService + class ReferenceType(Enum): """Enum representing types of references between entities.""" @@ -342,4 +357,27 @@ class QueryRoutingOverrideContext(BaseModel): entity_routings: List[EntityRouting] = Field(alias="entityRoutings") +class DataFabricEntityItem(BaseModel): + """A single Data Fabric entity reference from agent configuration.""" + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + id: str + entity_key: Optional[str] = Field(None, alias="referenceKey") + name: str + folder_key: str = Field(alias="folderId") + description: Optional[str] = None + + +class EntitySetResolution(BaseModel): + """Result of resolving an agent entity set with overwrites applied.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + entities: list[Entity] + entities_service: EntitiesService + + Entity.model_rebuild() diff --git a/packages/uipath-platform/tests/services/test_entities_service.py b/packages/uipath-platform/tests/services/test_entities_service.py index 8a9abafef..d71971591 100644 --- a/packages/uipath-platform/tests/services/test_entities_service.py +++ b/packages/uipath-platform/tests/services/test_entities_service.py @@ -8,7 +8,11 @@ from pytest_httpx import HTTPXMock from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.entities import Entity, EntityRouting, QueryRoutingOverrideContext +from uipath.platform.common._bindings import ( + EntityResourceOverwrite, + _resource_overwrites, +) +from uipath.platform.entities import DataFabricEntityItem, Entity from uipath.platform.entities._entities_service import EntitiesService @@ -390,28 +394,21 @@ async def test_query_entity_records_async_calls_request_for_valid_sql( assert result == [{"id": "c1"}] service.request_async.assert_called_once() - def test_query_entity_records_with_routing_context( + def test_query_entity_records_builds_routing_context_from_folders_map( self, - service: EntitiesService, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, ) -> None: + service = EntitiesService( + config=config, + execution_context=execution_context, + folders_map={"Customers": "solution_folder", "Orders": "folder-2"}, + ) response = MagicMock() response.json.return_value = {"results": [{"id": 1}]} service.request = MagicMock(return_value=response) # type: ignore[method-assign] - routing = QueryRoutingOverrideContext( - entity_routings=[ - EntityRouting(entity_name="Customers", folder_id="folder-1"), - EntityRouting( - entity_name="Orders", - folder_id="folder-2", - override_entity_name="OrdersV2", - ), - ] - ) - - result = service.query_entity_records( - "SELECT id FROM Customers LIMIT 10", routing_context=routing - ) + result = service.query_entity_records("SELECT id FROM Customers LIMIT 10") assert result == [{"id": 1}] call_kwargs = service.request.call_args @@ -419,39 +416,38 @@ def test_query_entity_records_with_routing_context( assert body["query"] == "SELECT id FROM Customers LIMIT 10" assert body["routingContext"] == { "entityRoutings": [ - {"entityName": "Customers", "folderId": "folder-1"}, - { - "entityName": "Orders", - "folderId": "folder-2", - "overrideEntityName": "OrdersV2", - }, + {"entityName": "Customers", "folderId": "solution_folder"}, + {"entityName": "Orders", "folderId": "folder-2"}, ] } @pytest.mark.anyio - async def test_query_entity_records_async_with_routing_context( + async def test_query_entity_records_async_builds_routing_context_from_folders_map( self, - service: EntitiesService, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, ) -> None: + service = EntitiesService( + config=config, + execution_context=execution_context, + folders_map={"Customers": "solution_folder"}, + ) response = MagicMock() response.json.return_value = {"results": [{"id": "c1"}]} service.request_async = AsyncMock(return_value=response) # type: ignore[method-assign] - routing = QueryRoutingOverrideContext( - entity_routings=[ - EntityRouting(entity_name="Customers", folder_id="folder-1"), - ] - ) - result = await service.query_entity_records_async( - "SELECT id FROM Customers WHERE id = 'c1'", - routing_context=routing, + "SELECT id FROM Customers WHERE id = 'c1'" ) assert result == [{"id": "c1"}] call_kwargs = service.request_async.call_args body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") - assert "routingContext" in body + assert body["routingContext"] == { + "entityRoutings": [ + {"entityName": "Customers", "folderId": "solution_folder"}, + ] + } def test_query_entity_records_without_routing_context_omits_key( self, @@ -466,3 +462,316 @@ def test_query_entity_records_without_routing_context_omits_key( call_kwargs = service.request.call_args body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") assert "routingContext" not in body + + def test_query_entity_records_picks_up_entity_overwrites_from_context( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + from uipath.platform.common._bindings import ( + EntityResourceOverwrite, + _resource_overwrites, + ) + + service = EntitiesService( + config=config, + execution_context=execution_context, + ) + response = MagicMock() + response.json.return_value = {"results": [{"id": 1}]} + service.request = MagicMock(return_value=response) # type: ignore[method-assign] + + overwrite = EntityResourceOverwrite( + resource_type="entity", + name="Overwritten Customers", + folder_id="overwritten-folder-id", + ) + token = _resource_overwrites.set({"entity.Customers": overwrite}) + try: + service.query_entity_records("SELECT id FROM Customers LIMIT 10") + finally: + _resource_overwrites.reset(token) + + call_kwargs = service.request.call_args + body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + assert body["routingContext"] == { + "entityRoutings": [ + { + "entityName": "Customers", + "folderId": "overwritten-folder-id", + "overrideEntityName": "Overwritten Customers", + }, + ] + } + + def test_query_entity_records_merges_folders_map_with_entity_name_overrides( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + service = EntitiesService( + config=config, + execution_context=execution_context, + folders_map={ + "Customers": "overwritten-folder-id", + "Orders": "orders-folder", + }, + entity_name_overrides={"Customers": "Overwritten Customers"}, + ) + response = MagicMock() + response.json.return_value = {"results": []} + service.request = MagicMock(return_value=response) # type: ignore[method-assign] + + service.query_entity_records("SELECT id FROM Customers LIMIT 10") + + call_kwargs = service.request.call_args + body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + routings = body["routingContext"]["entityRoutings"] + assert { + "entityName": "Customers", + "folderId": "overwritten-folder-id", + "overrideEntityName": "Overwritten Customers", + } in routings + assert {"entityName": "Orders", "folderId": "orders-folder"} in routings + # Exactly two routings — no duplicates + assert len(routings) == 2 + + def test_resolve_entity_set_uses_effective_sql_name_in_routing_context( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + service = EntitiesService( + config=config, + execution_context=execution_context, + ) + service.retrieve_by_name = MagicMock( # type: ignore[method-assign] + return_value=MagicMock(spec=Entity) + ) + + overwrite = EntityResourceOverwrite( + resource_type="entity", + name="Overwritten Customers", + folder_id="known-folder-key", + ) + token = _resource_overwrites.set({"entity.entity-1": overwrite}) + try: + resolution = service.resolve_entity_set( + [ + DataFabricEntityItem( + id="entity-1", + name="Customers", + folder_key="original-folder-key", + ) + ] + ) + finally: + _resource_overwrites.reset(token) + + assert resolution.entities_service._routing_strategy.routing_context is not None + assert resolution.entities_service._routing_strategy.routing_context.model_dump( + by_alias=True, exclude_none=True + ) == { + "entityRoutings": [ + { + "entityName": "Customers", + "folderId": "known-folder-key", + "overrideEntityName": "Overwritten Customers", + } + ] + } + service.retrieve_by_name.assert_called_once_with( + "Overwritten Customers", + "known-folder-key", + ) + + @pytest.mark.asyncio + async def test_resolve_entity_set_async_resolves_folder_paths_before_fetch( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + folders_service = MagicMock() + folders_service.retrieve_key_async = AsyncMock( + return_value="resolved-folder-id" + ) + service = EntitiesService( + config=config, + execution_context=execution_context, + folders_service=folders_service, + ) + service.retrieve_by_name_async = AsyncMock( # type: ignore[method-assign] + return_value=MagicMock(spec=Entity) + ) + + overwrite = EntityResourceOverwrite( + resource_type="entity", + name="Overwritten Customers", + folder_path="Shared/Finance", + ) + token = _resource_overwrites.set({"entity.entity-1": overwrite}) + try: + resolution = await service.resolve_entity_set_async( + [ + DataFabricEntityItem( + id="entity-1", + name="Customers", + folder_key="original-folder-key", + ) + ] + ) + finally: + _resource_overwrites.reset(token) + + folders_service.retrieve_key_async.assert_awaited_once_with( + folder_path="Shared/Finance" + ) + assert resolution.entities_service._routing_strategy.routing_context is not None + assert resolution.entities_service._routing_strategy.routing_context.model_dump( + by_alias=True, exclude_none=True + ) == { + "entityRoutings": [ + { + "entityName": "Customers", + "folderId": "resolved-folder-id", + "overrideEntityName": "Overwritten Customers", + } + ] + } + service.retrieve_by_name_async.assert_awaited_once_with( + "Overwritten Customers", + "resolved-folder-id", + ) + + def test_query_entity_records_context_overwrite_same_name_no_override_field( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + from uipath.platform.common._bindings import ( + EntityResourceOverwrite, + _resource_overwrites, + ) + + service = EntitiesService( + config=config, + execution_context=execution_context, + ) + response = MagicMock() + response.json.return_value = {"results": []} + service.request = MagicMock(return_value=response) # type: ignore[method-assign] + + overwrite = EntityResourceOverwrite( + resource_type="entity", + name="Customers", + folder_id="different-folder-id", + ) + token = _resource_overwrites.set({"entity.Customers": overwrite}) + try: + service.query_entity_records("SELECT id FROM Customers LIMIT 10") + finally: + _resource_overwrites.reset(token) + + call_kwargs = service.request.call_args + body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + assert body["routingContext"] == { + "entityRoutings": [ + { + "entityName": "Customers", + "folderId": "different-folder-id", + }, + ] + } + + def test_query_entity_records_resolves_overwrite_folder_path_to_folder_key( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + from uipath.platform.common._bindings import ( + EntityResourceOverwrite, + _resource_overwrites, + ) + + folders_service = MagicMock() + folders_service.retrieve_key.return_value = "resolved-folder-id" + + service = EntitiesService( + config=config, + execution_context=execution_context, + folders_service=folders_service, + ) + response = MagicMock() + response.json.return_value = {"results": []} + service.request = MagicMock(return_value=response) # type: ignore[method-assign] + + overwrite = EntityResourceOverwrite( + resource_type="entity", + name="Overwritten Customers", + folder_path="Shared/Finance", + ) + token = _resource_overwrites.set({"entity.Customers": overwrite}) + try: + service.query_entity_records("SELECT id FROM Customers LIMIT 10") + finally: + _resource_overwrites.reset(token) + + call_kwargs = service.request.call_args + body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + assert body["routingContext"] == { + "entityRoutings": [ + { + "entityName": "Customers", + "folderId": "resolved-folder-id", + "overrideEntityName": "Overwritten Customers", + }, + ] + } + + def test_query_entity_records_uses_folder_id_directly_without_resolution( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + from uipath.platform.common._bindings import ( + EntityResourceOverwrite, + _resource_overwrites, + ) + + folders_service = MagicMock() + folders_service.retrieve_key.return_value = None + + service = EntitiesService( + config=config, + execution_context=execution_context, + folders_service=folders_service, + ) + response = MagicMock() + response.json.return_value = {"results": []} + service.request = MagicMock(return_value=response) # type: ignore[method-assign] + + overwrite = EntityResourceOverwrite( + resource_type="entity", + name="Overwritten Customers", + folder_id="known-folder-key", + ) + token = _resource_overwrites.set({"entity.Customers": overwrite}) + try: + service.query_entity_records("SELECT id FROM Customers LIMIT 10") + finally: + _resource_overwrites.reset(token) + + # folder_id is a key — should NOT be sent through FolderService + folders_service.retrieve_key.assert_not_called() + + call_kwargs = service.request.call_args + body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + assert body["routingContext"] == { + "entityRoutings": [ + { + "entityName": "Customers", + "folderId": "known-folder-key", + "overrideEntityName": "Overwritten Customers", + }, + ] + } diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index db41f6ec8..d31a54951 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.23" +version = "0.1.24" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 4b1b0dc3f..234a9c2d8 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.44" +version = "2.10.45" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md b/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md index 4af1b60ae..2e6a4a585 100644 --- a/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md +++ b/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md @@ -500,10 +500,16 @@ sdk.entities.list_records(entity_key: str, schema: Optional[Type[Any]]=None, sta sdk.entities.list_records_async(entity_key: str, schema: Optional[Type[Any]]=None, start: Optional[int]=None, limit: Optional[int]=None) -> typing.List[uipath.platform.entities.entities.EntityRecord] # Query entity records using a validated SQL query. -sdk.entities.query_entity_records(sql_query: str, routing_context: Optional[uipath.platform.entities.entities.QueryRoutingOverrideContext]=None) -> typing.List[typing.Dict[str, typing.Any]] +sdk.entities.query_entity_records(sql_query: str) -> typing.List[typing.Dict[str, typing.Any]] # Asynchronously query entity records using a validated SQL query. -sdk.entities.query_entity_records_async(sql_query: str, routing_context: Optional[uipath.platform.entities.entities.QueryRoutingOverrideContext]=None) -> typing.List[typing.Dict[str, typing.Any]] +sdk.entities.query_entity_records_async(sql_query: str) -> typing.List[typing.Dict[str, typing.Any]] + +# Resolve an agent entity set, applying resource overwrites. +sdk.entities.resolve_entity_set(items: list[uipath.platform.entities.entities.DataFabricEntityItem]) -> uipath.platform.entities.entities.EntitySetResolution + +# Resolve an agent entity set, applying resource overwrites. +sdk.entities.resolve_entity_set_async(items: list[uipath.platform.entities.entities.DataFabricEntityItem]) -> uipath.platform.entities.entities.EntitySetResolution # Retrieve an entity by its key. sdk.entities.retrieve(entity_key: str) -> uipath.platform.entities.entities.Entity @@ -511,12 +517,19 @@ sdk.entities.retrieve(entity_key: str) -> uipath.platform.entities.entities.Enti # Asynchronously retrieve an entity by its key. sdk.entities.retrieve_async(entity_key: str) -> uipath.platform.entities.entities.Entity +# Retrieve an entity by its name. +sdk.entities.retrieve_by_name(entity_name: str, folder_key: Optional[str]=None) -> uipath.platform.entities.entities.Entity + +# Asynchronously retrieve an entity by its name. +sdk.entities.retrieve_by_name_async(entity_name: str, folder_key: Optional[str]=None) -> uipath.platform.entities.entities.Entity + # Update multiple records in an entity in a single batch operation. sdk.entities.update_records(entity_key: str, records: List[Any], schema: Optional[Type[Any]]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse # Asynchronously update multiple records in an entity in a single batch operation. sdk.entities.update_records_async(entity_key: str, records: List[Any], schema: Optional[Type[Any]]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse + ``` ### Folders diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index c5a63af7c..f3121d9dd 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -35,6 +35,7 @@ ) from uipath.eval.mocks import ExampleCall from uipath.platform.connections import Connection +from uipath.platform.entities import DataFabricEntityItem from uipath.platform.guardrails import ( BuiltInValidatorGuardrail, ) @@ -394,16 +395,6 @@ class AgentContextSettings(BaseCfg): ) -class DataFabricEntityItem(BaseCfg): - """A single Data Fabric entity reference.""" - - id: str - reference_key: Optional[str] = Field(None, alias="referenceKey") - name: str - folder_id: str = Field(alias="folderId") - description: Optional[str] = None - - class AgentContextResourceConfig(BaseAgentResourceConfig): """Agent context resource configuration model.""" diff --git a/packages/uipath/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py index 1ddd7d102..d730f7e31 100644 --- a/packages/uipath/tests/agent/models/test_agent.py +++ b/packages/uipath/tests/agent/models/test_agent.py @@ -3529,10 +3529,10 @@ def test_datafabric_context_config_parses(self): assert len(parsed.entity_set) == 2 assert parsed.entity_set[0].id == "abc-123" assert parsed.entity_set[0].name == "Customers" - assert parsed.entity_set[0].folder_id == "folder-1" + assert parsed.entity_set[0].folder_key == "folder-1" assert parsed.entity_set[0].description == "Customer records" - assert parsed.entity_set[0].reference_key is None - assert parsed.entity_set[1].reference_key == "orders-ref" + assert parsed.entity_set[0].entity_key is None + assert parsed.entity_set[1].entity_key == "orders-ref" assert parsed.entity_set[1].description is None def test_is_datafabric(self): diff --git a/packages/uipath/tests/resource_overrides/overwrites.json b/packages/uipath/tests/resource_overrides/overwrites.json index c58744a69..e0bca84ba 100644 --- a/packages/uipath/tests/resource_overrides/overwrites.json +++ b/packages/uipath/tests/resource_overrides/overwrites.json @@ -28,5 +28,9 @@ "mcpServer.mcp_server_name": { "name": "Overwritten MCP Server Name", "folderPath": "Overwritten/MCPServer/Folder" + }, + "entity.entity_name": { + "name": "Overwritten Entity Name", + "folderId": "overwritten-entity-folder-id-123" } } \ No newline at end of file diff --git a/packages/uipath/tests/resource_overrides/test_resource_overrides.py b/packages/uipath/tests/resource_overrides/test_resource_overrides.py index c15bc113b..8d39a762d 100644 --- a/packages/uipath/tests/resource_overrides/test_resource_overrides.py +++ b/packages/uipath/tests/resource_overrides/test_resource_overrides.py @@ -310,6 +310,11 @@ def test_parse_overwrites_with_type_adapter(self, overwrites_data): assert mcp_server.resource_identifier == "Overwritten MCP Server Name" assert mcp_server.folder_identifier == "Overwritten/MCPServer/Folder" + # Verify entity overwrite + entity = parsed_overwrites["entity.entity_name"] + assert entity.resource_identifier == "Overwritten Entity Name" + assert entity.folder_identifier == "overwritten-entity-folder-id-123" + def test_overrides_decorator_should_pop_kwargs_dict_when_present(self): from uipath.platform.common import resource_override diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index ba40c276e..b7fed5ea5 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.44" +version = "2.10.45" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.23" +version = "0.1.24" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 48d9f4ebd10b0b0eaebeaa9aa843ef5adc339659 Mon Sep 17 00:00:00 2001 From: Radu Mihai Gheorghe Date: Thu, 9 Apr 2026 17:58:48 +0300 Subject: [PATCH 010/191] fix: remove unwanted a2a fields (#1554) --- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/agent/models/agent.py | 9 +------- .../uipath/tests/agent/models/test_agent.py | 21 +++++-------------- packages/uipath/uv.lock | 2 +- 4 files changed, 8 insertions(+), 26 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 234a9c2d8..7eb42ce59 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.45" +version = "2.10.46" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index f3121d9dd..679ddb808 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -467,17 +467,10 @@ class AgentA2aResourceConfig(BaseAgentResourceConfig): ) id: str slug: str = Field(..., alias="slug") - agent_card_url: str = Field(default="", alias="agentCardUrl") - is_active: bool = Field(default=True, alias="isActive") + folder_path: str = Field(alias="folderPath") cached_agent_card: Optional[Dict[str, Any]] = Field( default=None, alias="cachedAgentCard" ) - created_at: Optional[str] = Field(default=None, alias="createdAt") - created_by: Optional[str] = Field(default=None, alias="createdBy") - updated_at: Optional[str] = Field(default=None, alias="updatedAt") - updated_by: Optional[str] = Field(default=None, alias="updatedBy") - a2a_url: str = Field(default="", alias="a2aUrl") - folder_path: Optional[str] = Field(default=None, alias="folderPath") _RECIPIENT_TYPE_NORMALIZED_MAP: Mapping[int | str, AgentEscalationRecipientType] = { diff --git a/packages/uipath/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py index d730f7e31..750564deb 100644 --- a/packages/uipath/tests/agent/models/test_agent.py +++ b/packages/uipath/tests/agent/models/test_agent.py @@ -3614,8 +3614,7 @@ def test_a2a_resource(self): "name": "Philosopher Agent", "slug": "philosopher-agent", "description": "A philosophical agent that answers questions with wisdom and philosopher quotes", - "agentCardUrl": "", - "isActive": True, + "folderPath": "shared", "cachedAgentCard": { "name": "Philosopher Agent", "description": "Philosopher Agent assistant", @@ -3666,10 +3665,6 @@ def test_a2a_resource(self): ], "version": "0.7.70", }, - "createdAt": "2026-03-15T10:12:47.9073065", - "createdBy": "f4bc4946-baed-4083-82b9-03d334bbacbe", - "updatedAt": None, - "updatedBy": None, } ], "features": [], @@ -3693,13 +3688,8 @@ def test_a2a_resource(self): a2a_resource.description == "A philosophical agent that answers questions with wisdom and philosopher quotes" ) - assert a2a_resource.is_active is True - assert a2a_resource.agent_card_url == "" assert a2a_resource.id == "755e2f7d-5a3d-47f3-8e9d-7ff0bf226357" - assert a2a_resource.created_at == "2026-03-15T10:12:47.9073065" - assert a2a_resource.created_by == "f4bc4946-baed-4083-82b9-03d334bbacbe" - assert a2a_resource.updated_at is None - assert a2a_resource.updated_by is None + assert a2a_resource.folder_path == "shared" # Validate cached agent card is a plain dict card = a2a_resource.cached_agent_card @@ -3737,7 +3727,7 @@ def test_a2a_resource_without_cached_card(self): "name": "Minimal A2A Agent", "slug": "minimal-a2a", "description": "A minimal A2A agent", - "isActive": False, + "folderPath": "shared", } ], "features": [], @@ -3756,10 +3746,8 @@ def test_a2a_resource_without_cached_card(self): assert isinstance(a2a_resource, AgentA2aResourceConfig) assert a2a_resource.name == "Minimal A2A Agent" assert a2a_resource.slug == "minimal-a2a" - assert a2a_resource.is_active is False + assert a2a_resource.folder_path == "shared" assert a2a_resource.cached_agent_card is None - assert a2a_resource.agent_card_url == "" - assert a2a_resource.created_at is None def test_a2a_resource_case_insensitive(self): """Test that A2A resource type is parsed case-insensitively.""" @@ -3785,6 +3773,7 @@ def test_a2a_resource_case_insensitive(self): "name": "Case Test Agent", "slug": "case-test", "description": "Testing case insensitive parsing", + "folderPath": "shared", } ], "features": [], diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index b7fed5ea5..3c06d2318 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.45" +version = "2.10.46" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 39d51e4cf4b5a380baff5cddbffa7610da0607a6 Mon Sep 17 00:00:00 2001 From: Chibi Vikramathithan Date: Thu, 9 Apr 2026 14:02:18 -0700 Subject: [PATCH 011/191] fix: use agent model for eval simulations (#1555) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Akshaya Shanbhogue --- packages/uipath/pyproject.toml | 2 +- packages/uipath/src/uipath/_cli/cli_debug.py | 12 ++++++++ packages/uipath/src/uipath/_cli/cli_eval.py | 23 -------------- packages/uipath/src/uipath/eval/helpers.py | 23 ++++++++++++++ .../src/uipath/eval/mocks/_input_mocker.py | 9 ++++++ .../src/uipath/eval/mocks/_llm_mocker.py | 9 +++++- .../src/uipath/eval/mocks/_mock_runtime.py | 15 ++++++++-- .../uipath/src/uipath/eval/runtime/runtime.py | 30 +++++++++++++++++-- .../cli/eval/test_eval_runtime_metadata.py | 22 +++++++------- .../uipath/tests/cli/test_debug_simulation.py | 6 ++++ packages/uipath/uv.lock | 2 +- 11 files changed, 110 insertions(+), 43 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 7eb42ce59..25c704503 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.46" +version = "2.10.47" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/cli_debug.py b/packages/uipath/src/uipath/_cli/cli_debug.py index 44415dd26..d2e08353b 100644 --- a/packages/uipath/src/uipath/_cli/cli_debug.py +++ b/packages/uipath/src/uipath/_cli/cli_debug.py @@ -9,6 +9,7 @@ from uipath._cli._utils._studio_project import StudioClient from uipath.core.tracing import UiPathTraceManager from uipath.eval.mocks import UiPathMockRuntime +from uipath.eval.mocks._mock_runtime import load_simulation_config from uipath.platform.common import ResourceOverwritesContext, UiPathConfig from uipath.runtime import ( UiPathExecuteOptions, @@ -163,8 +164,19 @@ async def execute_debug_runtime(): trigger_poll_interval=trigger_poll_interval, ) + # Build mocking context with agent model for simulations + schema = await runtime.get_schema() + agent_model = None + if schema.metadata and "settings" in schema.metadata: + agent_model = schema.metadata["settings"].get("model") + + mocking_context = load_simulation_config( + agent_model=agent_model + ) + mock_runtime = UiPathMockRuntime( delegate=debug_runtime, + mocking_context=mocking_context, ) try: diff --git a/packages/uipath/src/uipath/_cli/cli_eval.py b/packages/uipath/src/uipath/_cli/cli_eval.py index ef1edb200..9ebe275dc 100644 --- a/packages/uipath/src/uipath/_cli/cli_eval.py +++ b/packages/uipath/src/uipath/_cli/cli_eval.py @@ -25,7 +25,6 @@ from uipath.runtime import ( UiPathRuntimeContext, UiPathRuntimeFactoryRegistry, - UiPathRuntimeSchema, ) from uipath.telemetry._track import flush_events from uipath.tracing import ( @@ -65,27 +64,6 @@ def setup_reporting_prereq(no_report: bool) -> bool: return True -def _get_agent_model(schema: UiPathRuntimeSchema) -> str | None: - """Get agent model from the runtime schema metadata. - - The model is read from schema.metadata["settings"]["model"] which is - populated by the low-code agents runtime from agent.json. - - Returns: - The model name from agent settings, or None if not found. - """ - try: - if schema.metadata and "settings" in schema.metadata: - settings = schema.metadata["settings"] - model = settings.get("model") - if model: - logger.debug(f"Got agent model from schema.metadata: {model}") - return model - return None - except Exception: - return None - - def _resolve_model_settings_override( model_settings_id: str, evaluation_set: EvaluationSet ) -> dict[str, Any] | None: @@ -431,7 +409,6 @@ async def execute_eval(): eval_context.evaluators = await EvalHelpers.load_evaluators( resolved_eval_set_path, eval_context.evaluation_set, - _get_agent_model(eval_context.runtime_schema), ) # Runtime is not required anymore. diff --git a/packages/uipath/src/uipath/eval/helpers.py b/packages/uipath/src/uipath/eval/helpers.py index 0a0a0ca7f..8405e4a7a 100644 --- a/packages/uipath/src/uipath/eval/helpers.py +++ b/packages/uipath/src/uipath/eval/helpers.py @@ -7,6 +7,8 @@ from pydantic import ValidationError +from uipath.runtime.schema import UiPathRuntimeSchema + from .evaluators.base_evaluator import GenericBaseEvaluator from .evaluators.evaluator_factory import EvaluatorFactory from .mocks._types import InputMockingStrategy, LLMMockingStrategy @@ -277,3 +279,24 @@ async def load_evaluators( ) return evaluators + + +def get_agent_model(schema: UiPathRuntimeSchema) -> str | None: + """Get agent model from the runtime schema metadata. + + The model is read from schema.metadata["settings"]["model"] which is + populated by the low-code agents runtime from agent.json. + + Returns: + The model name from agent settings, or None if not found. + """ + try: + if schema.metadata and "settings" in schema.metadata: + settings = schema.metadata["settings"] + model = settings.get("model") + if model: + logger.debug(f"Got agent model from schema.metadata: {model}") + return model + return None + except Exception: + return None diff --git a/packages/uipath/src/uipath/eval/mocks/_input_mocker.py b/packages/uipath/src/uipath/eval/mocks/_input_mocker.py index f1d253ba8..57a727ec1 100644 --- a/packages/uipath/src/uipath/eval/mocks/_input_mocker.py +++ b/packages/uipath/src/uipath/eval/mocks/_input_mocker.py @@ -1,6 +1,7 @@ """LLM Input Mocker implementation.""" import json +import logging from datetime import datetime from typing import Any @@ -9,6 +10,7 @@ from uipath.core.tracing import traced from uipath.platform import UiPath from uipath.platform.chat import UiPathLlmChatService +from uipath.platform.chat._llm_gateway_service import ChatModels from .._execution_context import eval_set_run_id_context from ._mock_context import cache_manager_context @@ -17,6 +19,8 @@ InputMockingStrategy, ) +logger = logging.getLogger(__name__) + def get_input_mocking_prompt( input_schema: str, @@ -117,6 +121,11 @@ async def generate_llm_input( else {} ) + simulation_model = completion_kwargs.get( + "model", ChatModels.gpt_4_1_mini_2025_04_14 + ) + logger.info(f"Simulating input generation using model: {simulation_model}") + if cache_manager is not None: cache_key_data = { "response_format": response_format, diff --git a/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py b/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py index 194aa6c09..3715ac226 100644 --- a/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py +++ b/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py @@ -10,7 +10,7 @@ from uipath.core.tracing import traced from uipath.platform import UiPath from uipath.platform.chat import UiPathLlmChatService -from uipath.platform.chat._llm_gateway_service import _cleanup_schema +from uipath.platform.chat._llm_gateway_service import ChatModels, _cleanup_schema from .._execution_context import ( eval_set_run_id_context, @@ -182,6 +182,13 @@ async def response( else {} ) + simulation_model = completion_kwargs.get( + "model", ChatModels.gpt_4_1_mini_2025_04_14 + ) + logger.info( + f"Simulating tool '{function_name}' using model: {simulation_model}" + ) + formatted_prompt = PROMPT.format(**prompt_generation_args) cache_key_data = { diff --git a/packages/uipath/src/uipath/eval/mocks/_mock_runtime.py b/packages/uipath/src/uipath/eval/mocks/_mock_runtime.py index 512d8d6ee..df41dadeb 100644 --- a/packages/uipath/src/uipath/eval/mocks/_mock_runtime.py +++ b/packages/uipath/src/uipath/eval/mocks/_mock_runtime.py @@ -28,13 +28,14 @@ LLMMockingStrategy, MockingContext, MockingStrategyType, + ModelSettings, ToolSimulation, ) logger = logging.getLogger(__name__) -def load_simulation_config() -> MockingContext | None: +def load_simulation_config(agent_model: str | None = None) -> MockingContext | None: """Load simulation.json from current directory and convert to MockingContext. Returns: @@ -63,11 +64,21 @@ def load_simulation_config() -> MockingContext | None: if not tools_to_simulate: return None - # Create LLM mocking strategy + # Honor model from simulation config if specified, otherwise use the agent model + simulation_model = simulation_data.get("model") + model = ( + ModelSettings(model=simulation_model) + if simulation_model + else ModelSettings(model=agent_model) + if agent_model + else None + ) + mocking_strategy = LLMMockingStrategy( type=MockingStrategyType.LLM, prompt=simulation_data.get("instructions", ""), tools_to_simulate=tools_to_simulate, + model=model, ) # Create MockingContext for debugging diff --git a/packages/uipath/src/uipath/eval/runtime/runtime.py b/packages/uipath/src/uipath/eval/runtime/runtime.py index 1c32b9efe..7f7614446 100644 --- a/packages/uipath/src/uipath/eval/runtime/runtime.py +++ b/packages/uipath/src/uipath/eval/runtime/runtime.py @@ -47,13 +47,14 @@ from .._execution_context import ExecutionSpanCollector from ..evaluators.base_evaluator import GenericBaseEvaluator from ..evaluators.output_evaluator import OutputEvaluationCriteria +from ..helpers import get_agent_model from ..mocks._cache_manager import CacheManager from ..mocks._input_mocker import ( generate_llm_input, ) from ..mocks._mock_context import cache_manager_context from ..mocks._mock_runtime import UiPathMockRuntime -from ..mocks._types import MockingContext +from ..mocks._types import LLMMockingStrategy, MockingContext, ModelSettings from ..models import EvaluationResult from ..models.evaluation_set import ( EvaluationItem, @@ -526,12 +527,25 @@ async def _execute_eval( eval_item=eval_item, ), ) + # Set agent model on the mocking strategy if not already set + mocking_strategy = eval_item.mocking_strategy + if ( + mocking_strategy + and isinstance(mocking_strategy, LLMMockingStrategy) + and not mocking_strategy.model + ): + mocking_model = get_agent_model(self.context.runtime_schema) + if mocking_model: + mocking_strategy = mocking_strategy.model_copy( + update={"model": ModelSettings(model=mocking_model)} + ) + agent_execution_output = await self.execute_runtime( eval_item, execution_id, input_overrides=self.context.input_overrides, mocking_context=MockingContext( - strategy=eval_item.mocking_strategy, + strategy=mocking_strategy, name=eval_item.name, inputs=eval_item.inputs, ), @@ -811,8 +825,18 @@ async def _generate_input_for_eval( or getattr(eval_item, "expected_output", None) or {} ) + # Set agent model on the input mocking strategy if not already set + input_strategy = eval_item.input_mocking_strategy + # If input strategy does not specify a model, extract it + if input_strategy and not input_strategy.model: + input_generation_model = get_agent_model(self.context.runtime_schema) + if input_generation_model: + input_strategy = input_strategy.model_copy( + update={"model": ModelSettings(model=input_generation_model)} + ) + generated_input = await generate_llm_input( - eval_item.input_mocking_strategy, + input_strategy, (await self.get_schema()).input, expected_behavior=eval_item.expected_agent_behavior or "", expected_output=expected_output, diff --git a/packages/uipath/tests/cli/eval/test_eval_runtime_metadata.py b/packages/uipath/tests/cli/eval/test_eval_runtime_metadata.py index 112f8774b..07042cc12 100644 --- a/packages/uipath/tests/cli/eval/test_eval_runtime_metadata.py +++ b/packages/uipath/tests/cli/eval/test_eval_runtime_metadata.py @@ -1,7 +1,7 @@ """Tests for UiPathEvalRuntime metadata loading functionality. This module tests: -- _get_agent_model() - cached agent model retrieval +- get_agent_model() - cached agent model retrieval - get_schema() - cached schema retrieval """ @@ -10,11 +10,9 @@ import pytest -from uipath._cli.cli_eval import ( - _get_agent_model, -) from uipath.core.events import EventBus from uipath.core.tracing import UiPathTraceManager +from uipath.eval.helpers import get_agent_model from uipath.eval.runtime import UiPathEvalContext, UiPathEvalRuntime from uipath.runtime import ( UiPathExecuteOptions, @@ -119,34 +117,34 @@ async def dispose(self) -> None: class TestGetAgentModel: - """Tests for _get_agent_model function.""" + """Tests for get_agent_model function.""" @pytest.mark.asyncio async def test_returns_agent_model(self): - """Test that _get_agent_model returns the correct model from schema.""" + """Test that get_agent_model returns the correct model from schema.""" schema = MockRuntimeSchema() schema.metadata = {"settings": {"model": "gpt-4o-2024-11-20"}} - model = _get_agent_model(schema) + model = get_agent_model(schema) assert model == "gpt-4o-2024-11-20" @pytest.mark.asyncio async def test_returns_none_when_no_model(self): - """Test that _get_agent_model returns None when runtime has no model.""" + """Test that get_agent_model returns None when runtime has no model.""" schema = MockRuntimeSchema() - model = _get_agent_model(schema) + model = get_agent_model(schema) assert model is None @pytest.mark.asyncio async def test_returns_model_consistently(self): - """Test that _get_agent_model returns consistent results.""" + """Test that get_agent_model returns consistent results.""" schema = MockRuntimeSchema() schema.metadata = {"settings": {"model": "consistent-model"}} # Multiple calls should return the same value - model1 = _get_agent_model(schema) - model2 = _get_agent_model(schema) + model1 = get_agent_model(schema) + model2 = get_agent_model(schema) assert model1 == model2 == "consistent-model" diff --git a/packages/uipath/tests/cli/test_debug_simulation.py b/packages/uipath/tests/cli/test_debug_simulation.py index b2d795c79..d9266327b 100644 --- a/packages/uipath/tests/cli/test_debug_simulation.py +++ b/packages/uipath/tests/cli/test_debug_simulation.py @@ -241,6 +241,9 @@ def test_debug_always_wraps_with_mock_runtime( ) as mock_factory_get: mock_runtime = Mock() mock_runtime.dispose = AsyncMock() + mock_runtime.get_schema = AsyncMock( + return_value=Mock(metadata=None) + ) mock_factory = Mock() mock_factory.new_runtime = AsyncMock(return_value=mock_runtime) @@ -305,6 +308,9 @@ def test_debug_wraps_with_mock_runtime_on_error( ) as mock_factory_get: mock_runtime = Mock() mock_runtime.dispose = AsyncMock() + mock_runtime.get_schema = AsyncMock( + return_value=Mock(metadata=None) + ) mock_factory = Mock() mock_factory.new_runtime = AsyncMock(return_value=mock_runtime) diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 3c06d2318..f956b3d82 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.46" +version = "2.10.47" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From a3509b28068fa060858fb6871f04ac25e375f1b5 Mon Sep 17 00:00:00 2001 From: Chibi Vikramathithan Date: Thu, 9 Apr 2026 16:59:23 -0700 Subject: [PATCH 012/191] fix: restore agent_model for same-as-agent evaluator resolution (#1556) Co-authored-by: Claude Opus 4.6 (1M context) --- packages/uipath/pyproject.toml | 2 +- packages/uipath/src/uipath/_cli/cli_eval.py | 3 ++- packages/uipath/uv.lock | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 25c704503..7a7bc20ee 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.47" +version = "2.10.48" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/cli_eval.py b/packages/uipath/src/uipath/_cli/cli_eval.py index 9ebe275dc..e101717d6 100644 --- a/packages/uipath/src/uipath/_cli/cli_eval.py +++ b/packages/uipath/src/uipath/_cli/cli_eval.py @@ -17,7 +17,7 @@ from uipath._cli.middlewares import Middlewares from uipath.core.events import EventBus from uipath.core.tracing import UiPathTraceManager -from uipath.eval.helpers import EVAL_SETS_DIRECTORY_NAME, EvalHelpers +from uipath.eval.helpers import EVAL_SETS_DIRECTORY_NAME, EvalHelpers, get_agent_model from uipath.eval.models.evaluation_set import EvaluationSet from uipath.eval.runtime import UiPathEvalContext, evaluate from uipath.platform.chat import set_llm_concurrency @@ -409,6 +409,7 @@ async def execute_eval(): eval_context.evaluators = await EvalHelpers.load_evaluators( resolved_eval_set_path, eval_context.evaluation_set, + get_agent_model(eval_context.runtime_schema), ) # Runtime is not required anymore. diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index f956b3d82..126658fe1 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.47" +version = "2.10.48" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From f5838a91a397b4a5fb99edfc8c2219248dfa7499 Mon Sep 17 00:00:00 2001 From: Andrei Petraru Date: Fri, 10 Apr 2026 10:18:33 +0300 Subject: [PATCH 013/191] feat(guardrails): add Azure guardrail validators [AL-372] (#1549) Co-authored-by: Claude --- packages/uipath-platform/pyproject.toml | 2 +- .../uipath/platform/guardrails/__init__.py | 12 ++ .../guardrails/decorators/__init__.py | 18 ++- .../platform/guardrails/decorators/_enums.py | 19 +++ .../platform/guardrails/decorators/_models.py | 17 ++ .../decorators/validators/__init__.py | 6 + .../decorators/validators/harmful_content.py | 77 +++++++++ .../validators/intellectual_property.py | 67 ++++++++ .../validators/user_prompt_attacks.py | 44 ++++++ .../test_azure_guardrail_validators.py | 146 ++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 12 files changed, 407 insertions(+), 5 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/harmful_content.py create mode 100644 packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/intellectual_property.py create mode 100644 packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/user_prompt_attacks.py create mode 100644 packages/uipath-platform/tests/services/test_azure_guardrail_validators.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index b54858ea6..8650e44e2 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.24" +version = "0.1.25" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py b/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py index de439e92a..93c60b0a4 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py @@ -25,6 +25,11 @@ GuardrailExecutionStage, GuardrailTargetAdapter, GuardrailValidatorBase, + HarmfulContentEntity, + HarmfulContentEntityType, + HarmfulContentValidator, + IntellectualPropertyEntityType, + IntellectualPropertyValidator, LogAction, LoggingSeverityLevel, PIIDetectionEntity, @@ -32,6 +37,7 @@ PIIValidator, PromptInjectionValidator, RuleFunction, + UserPromptAttacksValidator, guardrail, register_guardrail_adapter, ) @@ -61,10 +67,16 @@ "GuardrailValidatorBase", "BuiltInGuardrailValidator", "CustomGuardrailValidator", + "HarmfulContentValidator", + "IntellectualPropertyValidator", "PIIValidator", "PromptInjectionValidator", + "UserPromptAttacksValidator", "CustomValidator", "RuleFunction", + "HarmfulContentEntity", + "HarmfulContentEntityType", + "IntellectualPropertyEntityType", "PIIDetectionEntity", "PIIDetectionEntityType", "GuardrailExecutionStage", diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py index 727925fb1..e8d692164 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py @@ -7,19 +7,27 @@ from ._actions import BlockAction, LogAction, LoggingSeverityLevel from ._core import GuardrailExclude -from ._enums import GuardrailExecutionStage, PIIDetectionEntityType +from ._enums import ( + GuardrailExecutionStage, + HarmfulContentEntityType, + IntellectualPropertyEntityType, + PIIDetectionEntityType, +) from ._exceptions import GuardrailBlockException from ._guardrail import guardrail -from ._models import GuardrailAction, PIIDetectionEntity +from ._models import GuardrailAction, HarmfulContentEntity, PIIDetectionEntity from ._registry import GuardrailTargetAdapter, register_guardrail_adapter from .validators import ( BuiltInGuardrailValidator, CustomGuardrailValidator, CustomValidator, GuardrailValidatorBase, + HarmfulContentValidator, + IntellectualPropertyValidator, PIIValidator, PromptInjectionValidator, RuleFunction, + UserPromptAttacksValidator, ) __all__ = [ @@ -29,11 +37,17 @@ "GuardrailValidatorBase", "BuiltInGuardrailValidator", "CustomGuardrailValidator", + "HarmfulContentValidator", + "IntellectualPropertyValidator", "PIIValidator", "PromptInjectionValidator", + "UserPromptAttacksValidator", "CustomValidator", "RuleFunction", # Models & enums + "HarmfulContentEntity", + "HarmfulContentEntityType", + "IntellectualPropertyEntityType", "PIIDetectionEntity", "PIIDetectionEntityType", "GuardrailExecutionStage", diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_enums.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_enums.py index be7832ddf..49956f62f 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_enums.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_enums.py @@ -42,3 +42,22 @@ class PIIDetectionEntityType(str, Enum): USUK_PASSPORT_NUMBER = "UsukPassportNumber" URL = "URL" IP_ADDRESS = "IPAddress" + + +class HarmfulContentEntityType(str, Enum): + """Harmful content entity types supported by UiPath guardrails. + + These entities correspond to the Azure Content Safety categories. + """ + + HATE = "Hate" + SELF_HARM = "SelfHarm" + SEXUAL = "Sexual" + VIOLENCE = "Violence" + + +class IntellectualPropertyEntityType(str, Enum): + """Intellectual property entity types supported by UiPath guardrails.""" + + TEXT = "Text" + CODE = "Code" diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_models.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_models.py index ac22538e0..8d86fbf39 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_models.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_models.py @@ -26,6 +26,23 @@ def __post_init__(self) -> None: ) +@dataclass +class HarmfulContentEntity: + """Harmful content entity configuration with severity threshold. + + Args: + name: The entity type name (e.g. ``HarmfulContentEntityType.VIOLENCE``). + threshold: Severity threshold (0 to 6) for detection. Defaults to ``2``. + """ + + name: str + threshold: int = 2 + + def __post_init__(self) -> None: + if not 0 <= self.threshold <= 6: + raise ValueError(f"Threshold must be between 0 and 6, got {self.threshold}") + + class GuardrailAction(ABC): """Interface for defining custom actions when a guardrail violation is detected. diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py index 6be170534..bbcf29039 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py @@ -6,15 +6,21 @@ GuardrailValidatorBase, ) from .custom import CustomValidator, RuleFunction +from .harmful_content import HarmfulContentValidator +from .intellectual_property import IntellectualPropertyValidator from .pii import PIIValidator from .prompt_injection import PromptInjectionValidator +from .user_prompt_attacks import UserPromptAttacksValidator __all__ = [ "GuardrailValidatorBase", "BuiltInGuardrailValidator", "CustomGuardrailValidator", + "HarmfulContentValidator", + "IntellectualPropertyValidator", "PIIValidator", "PromptInjectionValidator", + "UserPromptAttacksValidator", "CustomValidator", "RuleFunction", ] diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/harmful_content.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/harmful_content.py new file mode 100644 index 000000000..d186341d7 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/harmful_content.py @@ -0,0 +1,77 @@ +"""Harmful content detection guardrail validator.""" + +from typing import Any, Sequence +from uuid import uuid4 + +from uipath.platform.guardrails.guardrails import ( + BuiltInValidatorGuardrail, + EnumListParameterValue, + MapEnumParameterValue, +) + +from .._models import HarmfulContentEntity +from ._base import BuiltInGuardrailValidator + + +class HarmfulContentValidator(BuiltInGuardrailValidator): + """Validate data for harmful content using the UiPath API. + + Supported at all stages (PRE, POST, PRE_AND_POST). + + Args: + entities: One or more :class:`~uipath.platform.guardrails.decorators.HarmfulContentEntity` + instances specifying which harmful content categories to detect + and their severity thresholds. + + Raises: + ValueError: If *entities* is empty. + """ + + def __init__(self, entities: Sequence[HarmfulContentEntity]) -> None: + """Initialize HarmfulContentValidator with entities to detect.""" + if not entities: + raise ValueError("entities must be provided and non-empty") + self.entities = list(entities) + + def get_built_in_guardrail( + self, + name: str, + description: str | None, + enabled_for_evals: bool, + ) -> BuiltInValidatorGuardrail: + """Build a harmful content :class:`BuiltInValidatorGuardrail`. + + Args: + name: Name for the guardrail. + description: Optional description. + enabled_for_evals: Whether active in evaluation scenarios. + + Returns: + Configured :class:`BuiltInValidatorGuardrail` for harmful content detection. + """ + entity_names = [entity.name for entity in self.entities] + entity_thresholds: dict[str, Any] = { + entity.name: entity.threshold for entity in self.entities + } + + return BuiltInValidatorGuardrail( + id=str(uuid4()), + name=name, + description=description + or f"Detects harmful content: {', '.join(entity_names)}", + enabled_for_evals=enabled_for_evals, + guardrail_type="builtInValidator", + validator_type="harmful_content", + validator_parameters=[ + EnumListParameterValue( + parameter_type="enum-list", + id="harmfulContentEntities", + value=entity_names, + ), + MapEnumParameterValue( + parameter_type="map-enum", + id="harmfulContentEntityThresholds", + value=entity_thresholds, + ), + ], + ) diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/intellectual_property.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/intellectual_property.py new file mode 100644 index 000000000..8a18e6a37 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/intellectual_property.py @@ -0,0 +1,67 @@ +"""Intellectual property detection guardrail validator.""" + +from typing import Sequence +from uuid import uuid4 + +from uipath.platform.guardrails.guardrails import ( + BuiltInValidatorGuardrail, + EnumListParameterValue, +) + +from .._enums import GuardrailExecutionStage +from ._base import BuiltInGuardrailValidator + + +class IntellectualPropertyValidator(BuiltInGuardrailValidator): + """Validate output for intellectual property violations using the UiPath API. + + Restricted to POST stage only — IP detection is an output-only concern. + + Args: + entities: One or more entity type strings (e.g. + ``IntellectualPropertyEntityType.TEXT``). + + Raises: + ValueError: If *entities* is empty. + """ + + supported_stages = [GuardrailExecutionStage.POST] + + def __init__(self, entities: Sequence[str]) -> None: + """Initialize IntellectualPropertyValidator with entities to detect.""" + if not entities: + raise ValueError("entities must be provided and non-empty") + self.entities = list(entities) + + def get_built_in_guardrail( + self, + name: str, + description: str | None, + enabled_for_evals: bool, + ) -> BuiltInValidatorGuardrail: + """Build an intellectual property :class:`BuiltInValidatorGuardrail`. + + Args: + name: Name for the guardrail. + description: Optional description. + enabled_for_evals: Whether active in evaluation scenarios. + + Returns: + Configured :class:`BuiltInValidatorGuardrail` for IP detection. + """ + return BuiltInValidatorGuardrail( + id=str(uuid4()), + name=name, + description=description + or f"Detects intellectual property: {', '.join(self.entities)}", + enabled_for_evals=enabled_for_evals, + guardrail_type="builtInValidator", + validator_type="intellectual_property", + validator_parameters=[ + EnumListParameterValue( + parameter_type="enum-list", + id="ipEntities", + value=self.entities, + ), + ], + ) diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/user_prompt_attacks.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/user_prompt_attacks.py new file mode 100644 index 000000000..7275acc25 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/user_prompt_attacks.py @@ -0,0 +1,44 @@ +"""User prompt attacks detection guardrail validator.""" + +from uuid import uuid4 + +from uipath.platform.guardrails.guardrails import BuiltInValidatorGuardrail + +from .._enums import GuardrailExecutionStage +from ._base import BuiltInGuardrailValidator + + +class UserPromptAttacksValidator(BuiltInGuardrailValidator): + """Validate input for user prompt attacks via the UiPath API. + + Restricted to PRE stage only — prompt attacks are an input-only concern. + Takes no parameters. + """ + + supported_stages = [GuardrailExecutionStage.PRE] + + def get_built_in_guardrail( + self, + name: str, + description: str | None, + enabled_for_evals: bool, + ) -> BuiltInValidatorGuardrail: + """Build a user prompt attacks :class:`BuiltInValidatorGuardrail`. + + Args: + name: Name for the guardrail. + description: Optional description. + enabled_for_evals: Whether active in evaluation scenarios. + + Returns: + Configured :class:`BuiltInValidatorGuardrail` for user prompt attacks. + """ + return BuiltInValidatorGuardrail( + id=str(uuid4()), + name=name, + description=description or "Detects user prompt attacks", + enabled_for_evals=enabled_for_evals, + guardrail_type="builtInValidator", + validator_type="user_prompt_attacks", + validator_parameters=[], + ) diff --git a/packages/uipath-platform/tests/services/test_azure_guardrail_validators.py b/packages/uipath-platform/tests/services/test_azure_guardrail_validators.py new file mode 100644 index 000000000..fd6a3f39e --- /dev/null +++ b/packages/uipath-platform/tests/services/test_azure_guardrail_validators.py @@ -0,0 +1,146 @@ +"""Tests for the Azure-provided guardrail validators. + +Covers HarmfulContentValidator, IntellectualPropertyValidator, and +UserPromptAttacksValidator — verifying guardrail construction, parameter +serialization, stage enforcement, and input validation. +""" + +from __future__ import annotations + +import pytest + +from uipath.platform.guardrails.decorators import ( + GuardrailExecutionStage, + HarmfulContentEntity, + HarmfulContentEntityType, + HarmfulContentValidator, + IntellectualPropertyEntityType, + IntellectualPropertyValidator, + UserPromptAttacksValidator, +) + +# --------------------------------------------------------------------------- +# HarmfulContentValidator +# --------------------------------------------------------------------------- + + +class TestHarmfulContentValidator: + """Tests for HarmfulContentValidator.""" + + def test_builds_guardrail(self): + """Verify get_built_in_guardrail returns correct structure.""" + validator = HarmfulContentValidator( + entities=[ + HarmfulContentEntity(HarmfulContentEntityType.VIOLENCE, threshold=3), + HarmfulContentEntity(HarmfulContentEntityType.HATE, threshold=4), + ] + ) + guardrail = validator.get_built_in_guardrail( + name="Test HC", + description="test", + enabled_for_evals=True, + ) + assert guardrail.validator_type == "harmful_content" + assert len(guardrail.validator_parameters) == 2 + + enum_param = guardrail.validator_parameters[0] + assert enum_param.id == "harmfulContentEntities" + assert enum_param.value == ["Violence", "Hate"] + + map_param = guardrail.validator_parameters[1] + assert map_param.id == "harmfulContentEntityThresholds" + assert map_param.value == {"Violence": 3, "Hate": 4} + + def test_empty_entities_raises(self): + """Empty entities should raise ValueError.""" + with pytest.raises(ValueError, match="non-empty"): + HarmfulContentValidator(entities=[]) + + def test_threshold_validation(self): + """Threshold outside 0-6 should raise ValueError.""" + with pytest.raises(ValueError, match="between 0 and 6"): + HarmfulContentEntity(HarmfulContentEntityType.VIOLENCE, threshold=7) + with pytest.raises(ValueError, match="between 0 and 6"): + HarmfulContentEntity(HarmfulContentEntityType.VIOLENCE, threshold=-1) + + def test_all_stages_supported(self): + """supported_stages should be empty (all stages allowed).""" + validator = HarmfulContentValidator( + entities=[HarmfulContentEntity(HarmfulContentEntityType.VIOLENCE)] + ) + assert validator.supported_stages == [] + # Should not raise for any stage + validator.validate_stage(GuardrailExecutionStage.PRE) + validator.validate_stage(GuardrailExecutionStage.POST) + + +# --------------------------------------------------------------------------- +# IntellectualPropertyValidator +# --------------------------------------------------------------------------- + + +class TestIntellectualPropertyValidator: + """Tests for IntellectualPropertyValidator.""" + + def test_builds_guardrail(self): + """Verify get_built_in_guardrail returns correct structure.""" + validator = IntellectualPropertyValidator( + entities=[ + IntellectualPropertyEntityType.TEXT, + IntellectualPropertyEntityType.CODE, + ] + ) + guardrail = validator.get_built_in_guardrail( + name="Test IP", + description=None, + enabled_for_evals=False, + ) + assert guardrail.validator_type == "intellectual_property" + assert len(guardrail.validator_parameters) == 1 + + param = guardrail.validator_parameters[0] + assert param.id == "ipEntities" + assert param.value == ["Text", "Code"] + + def test_empty_entities_raises(self): + """Empty entities should raise ValueError.""" + with pytest.raises(ValueError, match="non-empty"): + IntellectualPropertyValidator(entities=[]) + + def test_post_only(self): + """Should only support POST stage.""" + validator = IntellectualPropertyValidator( + entities=[IntellectualPropertyEntityType.TEXT] + ) + assert validator.supported_stages == [GuardrailExecutionStage.POST] + validator.validate_stage(GuardrailExecutionStage.POST) + with pytest.raises(ValueError, match="does not support stage"): + validator.validate_stage(GuardrailExecutionStage.PRE) + + +# --------------------------------------------------------------------------- +# UserPromptAttacksValidator +# --------------------------------------------------------------------------- + + +class TestUserPromptAttacksValidator: + """Tests for UserPromptAttacksValidator.""" + + def test_builds_guardrail(self): + """Verify get_built_in_guardrail returns correct structure.""" + validator = UserPromptAttacksValidator() + guardrail = validator.get_built_in_guardrail( + name="Test UPA", + description=None, + enabled_for_evals=True, + ) + assert guardrail.validator_type == "user_prompt_attacks" + assert guardrail.validator_parameters == [] + + def test_pre_only(self): + """Should only support PRE stage.""" + validator = UserPromptAttacksValidator() + assert validator.supported_stages == [GuardrailExecutionStage.PRE] + validator.validate_stage(GuardrailExecutionStage.PRE) + with pytest.raises(ValueError, match="does not support stage"): + validator.validate_stage(GuardrailExecutionStage.POST) diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index d31a54951..5133a4575 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.24" +version = "0.1.25" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 126658fe1..159e6608c 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.24" +version = "0.1.25" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From a4a6cb9b965c67e623c77adff1929db669d53c10 Mon Sep 17 00:00:00 2001 From: RobinMennens-UiPath <80457569+RobinMennens-UiPath@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:09:00 +0200 Subject: [PATCH 014/191] fix: exclude start and limit from params if not provided (#1557) --- packages/uipath-platform/pyproject.toml | 2 +- .../platform/entities/_entities_service.py | 7 +++- .../tests/services/test_entities_service.py | 35 +++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 5 files changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 8650e44e2..39a8feb36 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.25" +version = "0.1.26" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py index 43b437d65..09987002d 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py @@ -1115,12 +1115,17 @@ def _list_records_spec( start: Optional[int] = None, limit: Optional[int] = None, ) -> RequestSpec: + params: dict[str, Any] = {} + if start is not None: + params["start"] = start + if limit is not None: + params["limit"] = limit return RequestSpec( method="GET", endpoint=Endpoint( f"datafabric_/api/EntityService/entity/{entity_key}/read" ), - params=({"start": start, "limit": limit}), + params=params, ) def _query_entity_records_spec( diff --git a/packages/uipath-platform/tests/services/test_entities_service.py b/packages/uipath-platform/tests/services/test_entities_service.py index d71971591..d018dd639 100644 --- a/packages/uipath-platform/tests/services/test_entities_service.py +++ b/packages/uipath-platform/tests/services/test_entities_service.py @@ -267,6 +267,41 @@ def test_retrieve_records_with_optional_fields( limit=1, ) + def test_retrieve_records_without_start_and_limit( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{str(entity_key)}/read", + status_code=200, + json={ + "totalCount": 1, + "value": [ + {"Id": "12345", "name": "record_name", "integer_field": 10}, + ], + }, + ) + + records = service.list_records(entity_key=str(entity_key)) + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + # Verify no start or limit query params are sent + assert "start" not in str(sent_request.url.params) + assert "limit" not in str(sent_request.url.params) + + assert isinstance(records, list) + assert len(records) == 1 + assert records[0].id == "12345" + @pytest.mark.parametrize( "sql_query", [ diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 5133a4575..2c517d570 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.25" +version = "0.1.26" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 159e6608c..7a694afc0 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.25" +version = "0.1.26" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From c209c76b556dcd4941e5ea42551eea7f82a5808a Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Tue, 14 Apr 2026 13:45:25 +0200 Subject: [PATCH 015/191] feat: add choiceset support to entities service (#1564) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/entities/__init__.py | 2 + .../platform/entities/_entities_service.py | 121 +++++++++ .../src/uipath/platform/entities/entities.py | 19 ++ .../tests/services/test_entities_service.py | 243 +++++++++++++++++- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 7 files changed, 387 insertions(+), 4 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 39a8feb36..a74bd7a5c 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.26" +version = "0.1.27" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/entities/__init__.py b/packages/uipath-platform/src/uipath/platform/entities/__init__.py index 6c9ac7f9f..aca80997b 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/entities/__init__.py @@ -5,6 +5,7 @@ from ._entities_service import EntitiesService from .entities import ( + ChoiceSetValue, DataFabricEntityItem, Entity, EntityField, @@ -24,6 +25,7 @@ ) __all__ = [ + "ChoiceSetValue", "DataFabricEntityItem", "EntitiesService", "Entity", diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py index 09987002d..a095f430d 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py @@ -1,3 +1,4 @@ +import json as json_module import logging from typing import Any, Dict, List, Optional, Type @@ -24,6 +25,7 @@ fetch_resolved_entities_async, ) from .entities import ( + ChoiceSetValue, DataFabricEntityItem, Entity, EntityRecord, @@ -282,6 +284,99 @@ async def list_entities_async(self) -> List[Entity]: entities_data = response.json() return [Entity.model_validate(entity) for entity in entities_data] + @traced(name="list_choicesets", run_type="uipath") + def list_choicesets(self) -> List[Entity]: + """List all choice sets in Data Service. + + Returns: + List[Entity]: A list of all choice set entities. + + Examples: + List all choice sets:: + + choicesets = entities_service.list_choicesets() + for cs in choicesets: + print(f"{cs.display_name} ({cs.id})") + """ + spec = self._list_choicesets_spec() + response = self.request(spec.method, spec.endpoint) + return [Entity.model_validate(item) for item in response.json()] + + @traced(name="list_choicesets", run_type="uipath") + async def list_choicesets_async(self) -> List[Entity]: + """Asynchronously list all choice sets in Data Service. + + Returns: + List[Entity]: A list of all choice set entities. + """ + spec = self._list_choicesets_spec() + response = await self.request_async(spec.method, spec.endpoint) + return [Entity.model_validate(item) for item in response.json()] + + @traced(name="get_choiceset_values", run_type="uipath") + def get_choiceset_values( + self, + choiceset_id: str, + start: int | None = None, + limit: int | None = None, + ) -> List[ChoiceSetValue]: + """Get the values of a choice set by its ID. + + Args: + choiceset_id: The unique identifier of the choice set. + start: Optional offset for pagination. + limit: Optional page size for pagination. + + Returns: + List[ChoiceSetValue]: The values in the choice set, each containing + id, name, display_name, and number_id. + + Examples: + Get all values in a choice set:: + + values = entities_service.get_choiceset_values("choiceset-id") + for v in values: + print(f"{v.number_id}: {v.display_name}") + """ + spec = self._get_choiceset_values_spec(choiceset_id, start=start, limit=limit) + response = self.request( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + data = response.json() + raw_values = data.get("jsonValue", "[]") + items = ( + json_module.loads(raw_values) if isinstance(raw_values, str) else raw_values + ) + return [ChoiceSetValue.model_validate(item) for item in items] + + @traced(name="get_choiceset_values", run_type="uipath") + async def get_choiceset_values_async( + self, + choiceset_id: str, + start: int | None = None, + limit: int | None = None, + ) -> List[ChoiceSetValue]: + """Asynchronously get the values of a choice set by its ID. + + Args: + choiceset_id: The unique identifier of the choice set. + start: Optional offset for pagination. + limit: Optional page size for pagination. + + Returns: + List[ChoiceSetValue]: The values in the choice set. + """ + spec = self._get_choiceset_values_spec(choiceset_id, start=start, limit=limit) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + data = response.json() + raw_values = data.get("jsonValue", "[]") + items = ( + json_module.loads(raw_values) if isinstance(raw_values, str) else raw_values + ) + return [ChoiceSetValue.model_validate(item) for item in items] + @traced(name="entity_list_records", run_type="uipath") def list_records( self, @@ -1173,6 +1268,32 @@ def _delete_batch_spec(self, entity_key: str, record_ids: List[str]) -> RequestS json=record_ids, ) + def _list_choicesets_spec(self) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint("datafabric_/api/Entity/choiceset"), + ) + + def _get_choiceset_values_spec( + self, + choiceset_id: str, + start: int | None = None, + limit: int | None = None, + ) -> RequestSpec: + params: dict[str, Any] = {} + if start is not None: + params["start"] = start + if limit is not None: + params["limit"] = limit + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{choiceset_id}/query_expansion" + ), + params=params, + json={}, + ) + def _validate_sql_query(self, sql_query: str) -> None: query = sql_query.strip().rstrip(";").strip() if not query: diff --git a/packages/uipath-platform/src/uipath/platform/entities/entities.py b/packages/uipath-platform/src/uipath/platform/entities/entities.py index b14f308d7..f3fb862f8 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/entities.py +++ b/packages/uipath-platform/src/uipath/platform/entities/entities.py @@ -222,6 +222,25 @@ class SourceJoinCriteria(BaseModel): related_source_field_name: str = Field(alias="relatedSourceFieldName") +class ChoiceSetValue(BaseModel): + """Model representing a single value within a choice set.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + ) + + id: str = Field(alias="Id") + name: str = Field(alias="Name") + display_name: str = Field(alias="DisplayName") + number_id: int = Field(alias="NumberId") + created_time: str | None = Field(default=None, alias="CreateTime") + updated_time: str | None = Field(default=None, alias="UpdateTime") + created_by: str | None = Field(default=None, alias="CreatedBy") + updated_by: str | None = Field(default=None, alias="UpdatedBy") + record_owner: str | None = Field(default=None, alias="RecordOwner") + + class EntityRecord(BaseModel): """Model representing a record within an entity.""" diff --git a/packages/uipath-platform/tests/services/test_entities_service.py b/packages/uipath-platform/tests/services/test_entities_service.py index d018dd639..71c3ccd5b 100644 --- a/packages/uipath-platform/tests/services/test_entities_service.py +++ b/packages/uipath-platform/tests/services/test_entities_service.py @@ -1,3 +1,4 @@ +import json import re import uuid from dataclasses import make_dataclass @@ -12,7 +13,7 @@ EntityResourceOverwrite, _resource_overwrites, ) -from uipath.platform.entities import DataFabricEntityItem, Entity +from uipath.platform.entities import ChoiceSetValue, DataFabricEntityItem, Entity from uipath.platform.entities._entities_service import EntitiesService @@ -810,3 +811,243 @@ def test_query_entity_records_uses_folder_id_directly_without_resolution( }, ] } + + def test_list_choicesets( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/choiceset", + status_code=200, + json=[ + { + "name": "Status", + "displayName": "Status", + "entityType": "ChoiceSet", + "description": "Status choices", + "isRbacEnabled": False, + "id": "cs-001", + }, + { + "name": "Priority", + "displayName": "Priority", + "entityType": "ChoiceSet", + "description": "Priority levels", + "isRbacEnabled": False, + "id": "cs-002", + }, + ], + ) + + choicesets = service.list_choicesets() + + assert isinstance(choicesets, list) + assert len(choicesets) == 2 + assert choicesets[0].name == "Status" + assert choicesets[0].entity_type == "ChoiceSet" + assert choicesets[0].id == "cs-001" + assert choicesets[1].name == "Priority" + + sent_request = httpx_mock.get_request() + assert sent_request is not None + assert sent_request.method == "GET" + assert str(sent_request.url).endswith("/datafabric_/api/Entity/choiceset") + + @pytest.mark.anyio + async def test_list_choicesets_async( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/choiceset", + status_code=200, + json=[ + { + "name": "Role", + "displayName": "Role", + "entityType": "ChoiceSet", + "isRbacEnabled": False, + "id": "cs-003", + }, + ], + ) + + choicesets = await service.list_choicesets_async() + + assert len(choicesets) == 1 + assert choicesets[0].name == "Role" + assert choicesets[0].id == "cs-003" + + def test_get_choiceset_values( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + choiceset_id = "cs-001" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{choiceset_id}/query_expansion", + status_code=200, + json={ + "totalRecordCount": 3, + "jsonValue": json.dumps( + [ + { + "Id": "v1", + "Name": "Active", + "DisplayName": "Active", + "NumberId": 0, + "CreateTime": "2026-01-01T00:00:00Z", + "UpdateTime": "2026-01-01T00:00:00Z", + }, + { + "Id": "v2", + "Name": "Inactive", + "DisplayName": "Inactive", + "NumberId": 1, + "CreateTime": "2026-01-01T00:00:00Z", + "UpdateTime": "2026-01-01T00:00:00Z", + }, + { + "Id": "v3", + "Name": "Pending", + "DisplayName": "Pending", + "NumberId": 2, + }, + ] + ), + }, + ) + + values = service.get_choiceset_values(choiceset_id) + + assert isinstance(values, list) + assert len(values) == 3 + assert isinstance(values[0], ChoiceSetValue) + assert values[0].id == "v1" + assert values[0].name == "Active" + assert values[0].display_name == "Active" + assert values[0].number_id == 0 + assert values[1].number_id == 1 + assert values[2].name == "Pending" + assert values[2].created_by is None + + sent_request = httpx_mock.get_request() + assert sent_request is not None + assert sent_request.method == "POST" + + def test_get_choiceset_values_with_pagination( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + choiceset_id = "cs-001" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{choiceset_id}/query_expansion?start=0&limit=2", + status_code=200, + json={ + "totalRecordCount": 5, + "jsonValue": json.dumps( + [ + { + "Id": "v1", + "Name": "Active", + "DisplayName": "Active", + "NumberId": 0, + }, + { + "Id": "v2", + "Name": "Inactive", + "DisplayName": "Inactive", + "NumberId": 1, + }, + ] + ), + }, + ) + + values = service.get_choiceset_values(choiceset_id, start=0, limit=2) + + assert len(values) == 2 + assert values[0].name == "Active" + assert values[1].name == "Inactive" + + sent_request = httpx_mock.get_request() + assert sent_request is not None + assert "start=0" in str(sent_request.url) + assert "limit=2" in str(sent_request.url) + + @pytest.mark.anyio + async def test_get_choiceset_values_async( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + choiceset_id = "cs-002" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{choiceset_id}/query_expansion", + status_code=200, + json={ + "totalRecordCount": 1, + "jsonValue": json.dumps( + [ + { + "Id": "v1", + "Name": "ReadOnly", + "DisplayName": "Read Only", + "NumberId": 0, + }, + ] + ), + }, + ) + + values = await service.get_choiceset_values_async(choiceset_id) + + assert len(values) == 1 + assert values[0].display_name == "Read Only" + assert values[0].number_id == 0 + + def test_get_choiceset_values_empty( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + choiceset_id = "cs-empty" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{choiceset_id}/query_expansion", + status_code=200, + json={ + "totalRecordCount": 0, + "jsonValue": "[]", + }, + ) + + values = service.get_choiceset_values(choiceset_id) + + assert values == [] diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 2c517d570..bbc761627 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.26" +version = "0.1.27" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 7a694afc0..e1f1b763a 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.26" +version = "0.1.27" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From a99fc062c033248aa21f4cf0a26d74f8a06aed09 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Tue, 14 Apr 2026 15:18:45 +0200 Subject: [PATCH 016/191] ci: trigger docs rebuild on platform/core source changes (#1565) --- .github/workflows/publish-docs.yml | 4 ++++ packages/uipath-platform/pyproject.toml | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 79eec3cc1..417cd09c9 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -9,6 +9,10 @@ on: - "packages/uipath/docs/**" - "packages/uipath/mkdocs.yml" - "packages/uipath/pyproject.toml" + - "packages/uipath-platform/src/**" + - "packages/uipath-platform/pyproject.toml" + - "packages/uipath-core/src/**" + - "packages/uipath-core/pyproject.toml" repository_dispatch: types: [publish-docs] diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index a74bd7a5c..61d507e60 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.27" +version = "0.1.28" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index bbc761627..dce3eb8e9 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.27" +version = "0.1.28" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index e1f1b763a..93c875332 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.27" +version = "0.1.28" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 54f32938cbe988546cdf2b60aa7ad8e4ae413a45 Mon Sep 17 00:00:00 2001 From: Dushyant Pathak Date: Wed, 15 Apr 2026 21:42:25 +0530 Subject: [PATCH 017/191] feat: add ArgumentEmail and ArgumentGroupName escalation recipient types (#1521) --- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/agent/models/agent.py | 35 ++++++++++++- .../uipath/tests/agent/models/test_agent.py | 50 ++++++++++++++++++- packages/uipath/uv.lock | 2 +- 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 7a7bc20ee..626941f70 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.48" +version = "2.10.49" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 679ddb808..7694a861e 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -135,6 +135,8 @@ class AgentEscalationRecipientType(str, CaseInsensitiveEnum): ASSET_USER_EMAIL = "AssetUserEmail" GROUP_NAME = "GroupName" ASSET_GROUP_NAME = "AssetGroupName" + ARGUMENT_EMAIL = "ArgumentEmail" + ARGUMENT_GROUP_NAME = "ArgumentGroupName" class AgentContextRetrievalMode(str, CaseInsensitiveEnum): @@ -481,6 +483,8 @@ class AgentA2aResourceConfig(BaseAgentResourceConfig): 5: AgentEscalationRecipientType.GROUP_NAME, "staticgroupname": AgentEscalationRecipientType.GROUP_NAME, 6: AgentEscalationRecipientType.ASSET_GROUP_NAME, + 7: AgentEscalationRecipientType.ARGUMENT_EMAIL, + 8: AgentEscalationRecipientType.ARGUMENT_GROUP_NAME, } @@ -536,8 +540,37 @@ class AssetRecipient(BaseEscalationRecipient): folder_path: str = Field(..., alias="folderPath") +class ArgumentEmailRecipient(BaseEscalationRecipient): + """Argument email recipient resolved from a named input argument. + + The argument_path supports dot-notation for nested input fields (e.g. "user.email"). + """ + + type: Literal[AgentEscalationRecipientType.ARGUMENT_EMAIL,] = Field( + ..., alias="type" + ) + argument_path: str = Field(..., alias="argumentName") + + +class ArgumentGroupNameRecipient(BaseEscalationRecipient): + """Argument group name recipient resolved from a named input argument. + + The argument_path supports dot-notation for nested input fields (e.g. "team.groupName"). + """ + + type: Literal[AgentEscalationRecipientType.ARGUMENT_GROUP_NAME,] = Field( + ..., alias="type" + ) + argument_path: str = Field(..., alias="argumentName") + + AgentEscalationRecipient = Annotated[ - Union[StandardRecipient, AssetRecipient], + Union[ + StandardRecipient, + AssetRecipient, + ArgumentEmailRecipient, + ArgumentGroupNameRecipient, + ], Field(discriminator="type"), BeforeValidator(_normalize_recipient_type), ] diff --git a/packages/uipath/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py index 750564deb..d00e5a42b 100644 --- a/packages/uipath/tests/agent/models/test_agent.py +++ b/packages/uipath/tests/agent/models/test_agent.py @@ -1,7 +1,7 @@ from typing import Any import pytest -from pydantic import TypeAdapter +from pydantic import TypeAdapter, ValidationError from uipath.agent.models.agent import ( AgentA2aResourceConfig, @@ -43,6 +43,8 @@ AgentUnknownToolResourceConfig, AgentWordOperator, AgentWordRule, + ArgumentEmailRecipient, + ArgumentGroupNameRecipient, AssetRecipient, BatchTransformFileExtension, BatchTransformWebSearchGrounding, @@ -3789,3 +3791,49 @@ def test_a2a_resource_case_insensitive(self): ] assert len(a2a_resources) == 1 assert isinstance(a2a_resources[0], AgentA2aResourceConfig) + + +class TestArgumentRecipientDeserialization: + def test_argument_email_recipient_by_type_int(self): + payload = {"type": 7, "argumentName": "assigneeEmail"} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, ArgumentEmailRecipient) + assert recipient.argument_path == "assigneeEmail" + assert recipient.type == AgentEscalationRecipientType.ARGUMENT_EMAIL + + def test_argument_group_name_recipient_by_type_int(self): + payload = {"type": 8, "argumentName": "assigneeGroup"} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, ArgumentGroupNameRecipient) + assert recipient.argument_path == "assigneeGroup" + assert recipient.type == AgentEscalationRecipientType.ARGUMENT_GROUP_NAME + + def test_argument_email_recipient_by_type_string(self): + payload = {"type": "ArgumentEmail", "argumentName": "emailArg"} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, ArgumentEmailRecipient) + assert recipient.argument_path == "emailArg" + + def test_argument_group_name_recipient_by_type_string(self): + payload = {"type": "ArgumentGroupName", "argumentName": "groupArg"} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, ArgumentGroupNameRecipient) + assert recipient.argument_path == "groupArg" + + def test_argument_email_recipient_missing_argument_name_raises(self): + payload = {"type": 7} + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_argument_group_name_recipient_missing_argument_name_raises(self): + payload = {"type": 8} + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 93c875332..6536f439a 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.48" +version = "2.10.49" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 298ca3a89ae408e0ea1c4ba29a9e3ae70353eda2 Mon Sep 17 00:00:00 2001 From: Dhananjay Suresh Date: Thu, 16 Apr 2026 12:24:52 -0400 Subject: [PATCH 018/191] feat: add RunAsMe support to ProcessesService (#1567) Co-authored-by: Claude Opus 4.6 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../orchestrator/_processes_service.py | 10 ++ .../tests/services/test_processes_service.py | 93 +++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 5 files changed, 106 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 61d507e60..1b2b9a9aa 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.28" +version = "0.1.29" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_processes_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_processes_service.py index 10b6010e2..73b4122e1 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_processes_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_processes_service.py @@ -50,6 +50,7 @@ def invoke( folder_path: Optional[str] = None, attachments: Optional[list[Attachment]] = None, parent_operation_id: Optional[str] = None, + run_as_me: Optional[bool] = None, **kwargs: Any, ) -> Job: """Start execution of a process by its name. @@ -63,6 +64,7 @@ def invoke( folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. parent_operation_id (Optional[str]): The parent operation ID for BTS tracking correlation. + run_as_me (Optional[bool]): If True, the job will run under the calling user's identity. Returns: Job: The job execution details. @@ -100,6 +102,7 @@ def invoke( folder_path=folder_path, parent_span_id=kwargs.get("parent_span_id"), parent_operation_id=parent_operation_id, + run_as_me=run_as_me, ) response = self.request( spec.method, @@ -123,6 +126,7 @@ async def invoke_async( folder_path: Optional[str] = None, attachments: Optional[list[Attachment]] = None, parent_operation_id: Optional[str] = None, + run_as_me: Optional[bool] = None, **kwargs: Any, ) -> Job: """Asynchronously start execution of a process by its name. @@ -136,6 +140,7 @@ async def invoke_async( folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. parent_operation_id (Optional[str]): The parent operation ID for BTS tracking correlation. + run_as_me (Optional[bool]): If True, the job will run under the calling user's identity. Returns: Job: The job execution details. @@ -168,6 +173,7 @@ async def main(): folder_path=folder_path, parent_span_id=kwargs.get("parent_span_id"), parent_operation_id=parent_operation_id, + run_as_me=run_as_me, ) response = await self.request_async( @@ -313,6 +319,7 @@ def _invoke_spec( folder_path: Optional[str] = None, parent_span_id: Optional[str] = None, parent_operation_id: Optional[str] = None, + run_as_me: Optional[bool] = None, ) -> RequestSpec: payload: Dict[str, Any] = {"ReleaseName": name, **(input_data or {})} self._add_tracing(payload, UiPathConfig.trace_id, parent_span_id) @@ -320,6 +327,9 @@ def _invoke_spec( if parent_operation_id: payload["ParentOperationId"] = parent_operation_id + if run_as_me is not None: + payload["RunAsMe"] = run_as_me + request_spec = RequestSpec( method="POST", endpoint=Endpoint( diff --git a/packages/uipath-platform/tests/services/test_processes_service.py b/packages/uipath-platform/tests/services/test_processes_service.py index 85b2e3691..15b2c9c7e 100644 --- a/packages/uipath-platform/tests/services/test_processes_service.py +++ b/packages/uipath-platform/tests/services/test_processes_service.py @@ -471,3 +471,96 @@ async def test_invoke_async_over_10k_limit_input( job_request.headers[HEADER_USER_AGENT] == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ProcessesService.invoke_async/{version}" ) + + def test_invoke_with_run_as_me_true( + self, + httpx_mock: HTTPXMock, + service: ProcessesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + process_name = "test-process" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs", + status_code=200, + json={ + "value": [ + { + "Key": "test-job-key", + "State": "Running", + "Id": 123, + "FolderKey": "test-folder-key", + } + ] + }, + ) + + service.invoke(process_name, run_as_me=True) + + sent_request = httpx_mock.get_request() + assert sent_request is not None + payload = json.loads(sent_request.content.decode("utf-8")) + assert payload["startInfo"]["RunAsMe"] is True + + def test_invoke_with_run_as_me_false( + self, + httpx_mock: HTTPXMock, + service: ProcessesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + process_name = "test-process" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs", + status_code=200, + json={ + "value": [ + { + "Key": "test-job-key", + "State": "Running", + "Id": 123, + "FolderKey": "test-folder-key", + } + ] + }, + ) + + service.invoke(process_name, run_as_me=False) + + sent_request = httpx_mock.get_request() + assert sent_request is not None + payload = json.loads(sent_request.content.decode("utf-8")) + assert payload["startInfo"]["RunAsMe"] is False + + def test_invoke_without_run_as_me_excludes_from_payload( + self, + httpx_mock: HTTPXMock, + service: ProcessesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + process_name = "test-process" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs", + status_code=200, + json={ + "value": [ + { + "Key": "test-job-key", + "State": "Running", + "Id": 123, + "FolderKey": "test-folder-key", + } + ] + }, + ) + + service.invoke(process_name) + + sent_request = httpx_mock.get_request() + assert sent_request is not None + payload = json.loads(sent_request.content.decode("utf-8")) + assert "RunAsMe" not in payload["startInfo"] diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index dce3eb8e9..e9a3d8d75 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.28" +version = "0.1.29" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 6536f439a..f61946b1a 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.28" +version = "0.1.29" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 4613a600c6c63147fa2412649278d9f53408dd5e Mon Sep 17 00:00:00 2001 From: Gabriel Martin <91462031+gcuip@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:48:14 +0200 Subject: [PATCH 019/191] feat: add support for folder scoped ephemeral index creation [ECS-1745] (#1574) --- packages/uipath-platform/pyproject.toml | 2 +- .../_context_grounding_service.py | 26 +++++++- .../test_context_grounding_service.py | 65 +++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 5 files changed, 91 insertions(+), 6 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 1b2b9a9aa..73f552851 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.29" +version = "0.1.30" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py b/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py index 75e525d3b..e577c5082 100644 --- a/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py +++ b/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py @@ -598,20 +598,29 @@ async def create_index_async( @resource_override(resource_type="index") @traced(name="contextgrounding_create_ephemeral_index", run_type="uipath") def create_ephemeral_index( - self, usage: EphemeralIndexUsage, attachments: List[str] + self, + usage: EphemeralIndexUsage, + attachments: List[str], + folder_key: str | None = None, + folder_path: str | None = None, ) -> ContextGroundingIndex: """Create a new ephemeral context grounding index. Args: usage (EphemeralIndexUsage): The task type for the ephemeral index (DeepRAG or BatchRAG) attachments (list[str]): The list of attachments ids from which the ephemeral index will be created + folder_key (Optional[str]): The folder key to scope the ephemeral index to. + folder_path (Optional[str]): The folder path to scope the ephemeral index to (resolved to a key if folder_key is not provided). Returns: ContextGroundingIndex: The created index information. """ + if folder_key is not None or folder_path is not None: + folder_key = self._resolve_folder_key(folder_key, folder_path) spec = self._create_ephemeral_spec( usage, attachments, + folder_key=folder_key, ) response = self.request( @@ -626,20 +635,29 @@ def create_ephemeral_index( @resource_override(resource_type="index") @traced(name="contextgrounding_create_ephemeral_index", run_type="uipath") async def create_ephemeral_index_async( - self, usage: EphemeralIndexUsage, attachments: List[str] + self, + usage: EphemeralIndexUsage, + attachments: List[str], + folder_key: str | None = None, + folder_path: str | None = None, ) -> ContextGroundingIndex: """Create a new ephemeral context grounding index. Args: usage (EphemeralIndexUsage): The task type for the ephemeral index (DeepRAG or BatchRAG) attachments (list[str]): The list of attachments ids from which the ephemeral index will be created + folder_key (Optional[str]): The folder key to scope the ephemeral index to. + folder_path (Optional[str]): The folder path to scope the ephemeral index to (resolved to a key if folder_key is not provided). Returns: ContextGroundingIndex: The created index information. """ + if folder_key is not None or folder_path is not None: + folder_key = self._resolve_folder_key(folder_key, folder_path) spec = self._create_ephemeral_spec( usage, attachments, + folder_key=folder_key, ) response = await self.request_async( @@ -1991,12 +2009,14 @@ def _create_ephemeral_spec( self, usage: str, attachments: List[str], + folder_key: str | None = None, ) -> RequestSpec: """Create request spec for ephemeral index creation. Args: usage (str): The task in which the ephemeral index will be used for attachments (list[str]): The list of attachments ids from which the ephemeral index will be created + folder_key (Optional[str]): The folder key to scope the ephemeral index to. Returns: RequestSpec for the create index request @@ -2012,7 +2032,7 @@ def _create_ephemeral_spec( method="POST", endpoint=Endpoint("/ecs_/v2/indexes/createephemeral"), json=payload.model_dump(by_alias=True, exclude_none=True), - headers={}, + headers={**header_folder(folder_key, None)}, ) def _build_data_source(self, source: SourceConfig) -> Dict[str, Any]: diff --git a/packages/uipath-platform/tests/services/test_context_grounding_service.py b/packages/uipath-platform/tests/services/test_context_grounding_service.py index 135ac281b..94626b8a1 100644 --- a/packages/uipath-platform/tests/services/test_context_grounding_service.py +++ b/packages/uipath-platform/tests/services/test_context_grounding_service.py @@ -2749,6 +2749,71 @@ async def test_create_ephemeral_index_async( == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.create_ephemeral_index_async/{version}" ) + def test_create_ephemeral_index_with_folder_key( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + import uuid + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/createephemeral", + status_code=200, + json={ + "id": "ephemeral-index-id", + "name": "ephemeral-index", + "lastIngestionStatus": "Queued", + }, + ) + + attachment_ids = [str(uuid.uuid4())] + service.create_ephemeral_index( + usage="DeepRAG", + attachments=attachment_ids, + folder_key="test-folder-key", + ) + + sent_requests = httpx_mock.get_requests() + assert sent_requests is not None + assert "x-uipath-folderkey" in sent_requests[0].headers + assert sent_requests[0].headers["x-uipath-folderkey"] == "test-folder-key" + + @pytest.mark.anyio + async def test_create_ephemeral_index_async_with_folder_key( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + import uuid + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/createephemeral", + status_code=200, + json={ + "id": "ephemeral-index-id", + "name": "ephemeral-index", + "lastIngestionStatus": "Queued", + }, + ) + + attachment_ids = [str(uuid.uuid4())] + await service.create_ephemeral_index_async( + usage="DeepRAG", + attachments=attachment_ids, + folder_key="test-folder-key", + ) + + sent_requests = httpx_mock.get_requests() + assert sent_requests is not None + assert "x-uipath-folderkey" in sent_requests[0].headers + assert sent_requests[0].headers["x-uipath-folderkey"] == "test-folder-key" + @pytest.mark.anyio async def test_download_batch_transform_result_async_creates_nested_directories( self, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index e9a3d8d75..7d31cdfd9 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.29" +version = "0.1.30" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index f61946b1a..f50030cb8 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.29" +version = "0.1.30" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From b8dba250d95341c9d408a523478ef910e395fe97 Mon Sep 17 00:00:00 2001 From: Andrei Petraru Date: Fri, 17 Apr 2026 17:36:33 +0300 Subject: [PATCH 020/191] docs(guardrails): expand guardrails page with all validators and examples [AL-290] (#1570) --- packages/uipath/docs/core/guardrails.md | 469 ++++++++++++++++++++++++ 1 file changed, 469 insertions(+) diff --git a/packages/uipath/docs/core/guardrails.md b/packages/uipath/docs/core/guardrails.md index 40aa69abc..a7e91f43f 100644 --- a/packages/uipath/docs/core/guardrails.md +++ b/packages/uipath/docs/core/guardrails.md @@ -1 +1,470 @@ +# Guardrails + +Guardrails are safeguards applied before and/or after execution to inspect inputs and outputs for policy violations — PII, harmful content, prompt injection, intellectual property, and custom rules — and respond by logging, blocking, or modifying the data. + +They can be applied at three scopes: + +- **Tool** — individual tool functions called by an agent +- **LLM** — LLM factory functions or chat model objects (e.g. LangChain `BaseChatModel`) +- **Agent** — agent-level methods and nodes + +The `@guardrail` decorator works with plain Python functions, async functions, and any LangChain/LangGraph object recognised by a registered framework adapter. + +## Usage + +Apply the `@guardrail` decorator to any callable — tool functions, LLM factories, agent factories, or async agent nodes. The decorator intercepts calls at the configured stage and evaluates the data against the provided validator. + +**Tool function:** + +```python +from uipath.platform.guardrails import ( + BlockAction, + GuardrailExecutionStage, + PIIDetectionEntity, + PIIDetectionEntityType, + PIIValidator, + guardrail, +) + +@guardrail( + validator=PIIValidator( + entities=[PIIDetectionEntity(PIIDetectionEntityType.EMAIL, threshold=0.5)] + ), + action=BlockAction(), + name="No PII in output", + stage=GuardrailExecutionStage.POST, +) +def analyze_joke(joke: str) -> str: + ... +``` + +When using LangChain's `@tool`, `@guardrail` must be placed **above** `@tool`: + +```python +from langchain_core.tools import tool + +@guardrail( + validator=PromptInjectionValidator(threshold=0.5), + action=BlockAction(), + name="No prompt injection", + stage=GuardrailExecutionStage.PRE, +) +@tool # @guardrail wraps the already-decorated tool object +def analyze_joke(joke: str) -> str: + ... +``` + +**LLM factory function:** + +```python +@guardrail( + validator=PromptInjectionValidator(threshold=0.5), + action=BlockAction(), + name="LLM Prompt Injection Detection", + stage=GuardrailExecutionStage.PRE, +) +def create_llm(): + return UiPathChat(model="gpt-4o-2024-08-06") +``` + +**Agent factory or async node:** + +```python +@guardrail( + validator=PIIValidator( + entities=[PIIDetectionEntity(PIIDetectionEntityType.PERSON, threshold=0.5)] + ), + action=BlockAction( + title="Person name detection", + detail="Person name detected and is not allowed", + ), + name="Agent PII Detection", + stage=GuardrailExecutionStage.PRE, +) +async def joke_node(state: Input) -> Output: + ... +``` + +## Execution Stages + +The `stage` parameter controls when the guardrail evaluates. Not all validators support all stages. + +| Stage | When evaluated | Supported by | +|-------|---------------|--------------| +| `PRE` | Before the function runs | All validators | +| `POST` | After the function runs | All except `PromptInjectionValidator`, `UserPromptAttacksValidator` | +| `PRE_AND_POST` | Both before and after | `PIIValidator`, `HarmfulContentValidator`, `CustomValidator` | + +## Built-in Validators + +Built-in validators are backed by the UiPath Guardrails API (powered by Azure Content Safety). They require a UiPath connection with the appropriate entitlements. + +### PII Detection + +Detects personally identifiable information in text. Supports 18 entity types with per-entity confidence thresholds. + +```python +from uipath.platform.guardrails import ( + BlockAction, + PIIDetectionEntity, + PIIDetectionEntityType, + PIIValidator, + guardrail, +) + +@guardrail( + validator=PIIValidator( + entities=[ + PIIDetectionEntity(name=PIIDetectionEntityType.EMAIL, threshold=0.7), + PIIDetectionEntity(name=PIIDetectionEntityType.PHONE_NUMBER, threshold=0.5), + PIIDetectionEntity(name=PIIDetectionEntityType.US_SOCIAL_SECURITY_NUMBER), + ] + ), + action=BlockAction(), + name="No PII", +) +def process_document(content: str) -> str: + ... +``` + +`threshold` is a confidence value between `0.0` and `1.0` (default `0.5`). Lower values increase sensitivity. + +### Harmful Content + +Detects harmful or unsafe content across four Azure Content Safety categories. Each category has a severity threshold from `0` (most sensitive) to `6` (least sensitive), defaulting to `2`. + +```python +from uipath.platform.guardrails import ( + BlockAction, + HarmfulContentEntity, + HarmfulContentEntityType, + HarmfulContentValidator, + guardrail, +) + +@guardrail( + validator=HarmfulContentValidator( + entities=[ + HarmfulContentEntity(name=HarmfulContentEntityType.VIOLENCE, threshold=2), + HarmfulContentEntity(name=HarmfulContentEntityType.HATE, threshold=2), + ] + ), + action=BlockAction(), + name="Safe content only", +) +def generate_response(prompt: str) -> str: + ... +``` + +### Prompt Injection + +Detects prompt injection attacks in user input. Restricted to `PRE` stage only — this is an input concern. + +```python +from uipath.platform.guardrails import ( + BlockAction, + GuardrailExecutionStage, + PromptInjectionValidator, + guardrail, +) + +@guardrail( + validator=PromptInjectionValidator(threshold=0.5), + action=BlockAction(), + name="No prompt injection", + stage=GuardrailExecutionStage.PRE, +) +def run_agent_step(user_input: str) -> str: + ... +``` + +### User Prompt Attacks + +Detects adversarial user prompt patterns (e.g. jailbreak attempts). No configuration parameters required. Restricted to `PRE` stage only. + +```python +from uipath.platform.guardrails import ( + BlockAction, + GuardrailExecutionStage, + UserPromptAttacksValidator, + guardrail, +) + +@guardrail( + validator=UserPromptAttacksValidator(), + action=BlockAction(), + name="No prompt attacks", + stage=GuardrailExecutionStage.PRE, +) +def chat(message: str) -> str: + ... +``` + +### Intellectual Property + +Detects potential intellectual property violations in generated output. Restricted to `POST` stage only — this is an output concern. + +```python +from uipath.platform.guardrails import ( + BlockAction, + GuardrailExecutionStage, + IntellectualPropertyEntityType, + IntellectualPropertyValidator, + guardrail, +) + +@guardrail( + validator=IntellectualPropertyValidator( + entities=[ + IntellectualPropertyEntityType.TEXT, + IntellectualPropertyEntityType.CODE, + ] + ), + action=BlockAction(), + name="No IP violations", + stage=GuardrailExecutionStage.POST, +) +def generate_code(spec: str) -> str: + ... +``` + +## Actions + +Actions define what happens when a violation is detected. + +### LogAction + +Logs the violation and lets execution continue. The original data is unchanged. + +```python +from uipath.platform.guardrails import LogAction, LoggingSeverityLevel + +action = LogAction(severity_level=LoggingSeverityLevel.WARNING) +action = LogAction(severity_level=LoggingSeverityLevel.ERROR, message="PII found in output") +``` + +### BlockAction + +Raises `GuardrailBlockException` to stop execution immediately. Framework adapters (e.g. LangChain) catch this exception and convert it to their own error type. + +```python +from uipath.platform.guardrails import BlockAction + +action = BlockAction() +action = BlockAction(title="PII detected", detail="Email address found in response") +``` + +### Custom Actions + +Subclass `GuardrailAction` to implement custom behaviour, such as content sanitisation: + +```python +from typing import Any +from uipath.core.guardrails import GuardrailValidationResult +from uipath.platform.guardrails import GuardrailAction + +class RedactAction(GuardrailAction): + def handle_validation_result( + self, + result: GuardrailValidationResult, + data: str | dict[str, Any], + guardrail_name: str, + ) -> str | dict[str, Any] | None: + # Return modified data to replace the original, or None to leave unchanged + if isinstance(data, str): + return "[REDACTED]" + return None +``` + +## Custom Validators + +`CustomValidator` applies an in-process rule function without any API call. The rule receives the input dict (PRE stage) or both input and output dicts (POST stage), and returns `True` to signal a violation. + +```python +from uipath.platform.guardrails import BlockAction, CustomValidator, guardrail + +@guardrail( + validator=CustomValidator(rule=lambda data: "forbidden" in str(data).lower()), + action=BlockAction(), + name="No forbidden words", +) +def my_tool(text: str) -> str: + ... +``` + +For POST-stage rules, accept two parameters to inspect both input and output: + +```python +def check_output(input_data: dict, output_data: dict) -> bool: + # Return True to trigger the guardrail + return len(output_data.get("response", "")) > 5000 + +@guardrail( + validator=CustomValidator(rule=check_output), + action=BlockAction(detail="Response exceeds maximum length"), + name="Length limit", + stage=GuardrailExecutionStage.POST, +) +def summarize(query: str) -> dict: + ... +``` + +For full control, subclass `CustomGuardrailValidator` directly. + +## Excluding Parameters + +Use `GuardrailExclude` with `Annotated` to prevent a specific parameter from being included in the guardrail evaluation payload. Useful for internal context objects, credentials, or other data that should never be inspected. + +```python +from typing import Annotated +from uipath.platform.guardrails import BlockAction, GuardrailExclude, PIIValidator, guardrail + +@guardrail( + validator=PIIValidator(entities=[PIIDetectionEntity(name=PIIDetectionEntityType.EMAIL)]), + action=BlockAction(), + name="No PII", +) +def process( + user_message: str, + internal_config: Annotated[dict, GuardrailExclude()], # excluded from guardrail +) -> str: + ... +``` + +## Stacking Guardrails + +Multiple `@guardrail` decorators can be stacked on the same function. Each is evaluated independently at its configured stage. + +```python +@guardrail( + validator=PromptInjectionValidator(), + action=BlockAction(), + name="No injection", + stage=GuardrailExecutionStage.PRE, +) +@guardrail( + validator=PIIValidator(entities=[PIIDetectionEntity(name=PIIDetectionEntityType.EMAIL)]), + action=LogAction(), + name="PII audit", + stage=GuardrailExecutionStage.POST, +) +def handle_request(user_input: str) -> str: + ... +``` + +## Low-level API + +For direct programmatic use without the decorator, the `GuardrailsService` is available on the `UiPath` client: + +```python +from uipath.platform import UiPath +from uipath.platform.guardrails import BuiltInValidatorGuardrail + +sdk = UiPath() +result = sdk.guardrails.evaluate_guardrail( + input_data="Contact me at user@example.com", + guardrail=BuiltInValidatorGuardrail( + id="my-guardrail", + name="PII check", + guardrail_type="builtInValidator", + validator_type="pii_detection", + ), +) +print(result.result, result.reason) +``` + +--- + +## API Reference + +### Service + ::: uipath.platform.guardrails._guardrails_service + options: + members: + - GuardrailsService + +### Decorator + +::: uipath.platform.guardrails.decorators._guardrail + options: + members: + - guardrail + +### Execution Stage + +::: uipath.platform.guardrails.decorators._enums + options: + members: + - GuardrailExecutionStage + - PIIDetectionEntityType + - HarmfulContentEntityType + - IntellectualPropertyEntityType + +### Actions + +::: uipath.platform.guardrails.decorators._models + options: + members: + - GuardrailAction + - PIIDetectionEntity + - HarmfulContentEntity + +::: uipath.platform.guardrails.decorators._actions + options: + members: + - LoggingSeverityLevel + - LogAction + - BlockAction + +::: uipath.platform.guardrails.decorators._exceptions + options: + members: + - GuardrailBlockException + +### Exclude Marker + +::: uipath.platform.guardrails.decorators._core + options: + members: + - GuardrailExclude + +### Validators + +::: uipath.platform.guardrails.decorators.validators._base + options: + members: + - GuardrailValidatorBase + - BuiltInGuardrailValidator + - CustomGuardrailValidator + +::: uipath.platform.guardrails.decorators.validators.pii + options: + members: + - PIIValidator + +::: uipath.platform.guardrails.decorators.validators.harmful_content + options: + members: + - HarmfulContentValidator + +::: uipath.platform.guardrails.decorators.validators.prompt_injection + options: + members: + - PromptInjectionValidator + +::: uipath.platform.guardrails.decorators.validators.intellectual_property + options: + members: + - IntellectualPropertyValidator + +::: uipath.platform.guardrails.decorators.validators.user_prompt_attacks + options: + members: + - UserPromptAttacksValidator + +::: uipath.platform.guardrails.decorators.validators.custom + options: + members: + - CustomValidator + - RuleFunction From 6d1ca972625bd4c1bdb98d1fc88332c42580f1d0 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Fri, 17 Apr 2026 10:44:55 -0700 Subject: [PATCH 021/191] feat: scaffold MemoryService for Agent Episodic Memory (URT migration) (#1467) Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/test-packages.yml | 55 +- packages/uipath-platform/pyproject.toml | 7 +- .../src/uipath/platform/_uipath.py | 5 + .../src/uipath/platform/memory/__init__.py | 39 ++ .../uipath/platform/memory/_memory_service.py | 461 ++++++++++++++++ .../src/uipath/platform/memory/memory.py | 191 +++++++ .../tests/services/test_memory_service.py | 504 ++++++++++++++++++ .../tests/services/test_memory_service_e2e.py | 164 ++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 10 files changed, 1425 insertions(+), 5 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/memory/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/memory/_memory_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/memory/memory.py create mode 100644 packages/uipath-platform/tests/services/test_memory_service.py create mode 100644 packages/uipath-platform/tests/services/test_memory_service_e2e.py diff --git a/.github/workflows/test-packages.yml b/.github/workflows/test-packages.yml index 58e37a42a..dab4b8d7c 100644 --- a/.github/workflows/test-packages.yml +++ b/.github/workflows/test-packages.yml @@ -130,6 +130,55 @@ jobs: working-directory: packages/uipath-platform run: uv run pytest + e2e-uipath-platform: + name: E2E (uipath-platform, memory) + needs: detect-changed-packages + runs-on: ubuntu-latest + steps: + - name: Check if package changed + id: check + shell: bash + run: | + if echo '${{ needs.detect-changed-packages.outputs.packages }}' | jq -e 'index("uipath-platform")' > /dev/null; then + echo "skip=false" >> $GITHUB_OUTPUT + else + echo "skip=true" >> $GITHUB_OUTPUT + fi + + - name: Skip + if: steps.check.outputs.skip == 'true' + shell: bash + run: echo "Skipping - no changes to uipath-platform" + + - name: Checkout + if: steps.check.outputs.skip != 'true' + uses: actions/checkout@v4 + + - name: Setup uv + if: steps.check.outputs.skip != 'true' + uses: astral-sh/setup-uv@v5 + + - name: Setup Python + if: steps.check.outputs.skip != 'true' + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-platform + run: uv sync --all-extras --python 3.11 + + - name: Run E2E memory tests + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-platform + env: + UIPATH_URL: ${{ secrets.ALPHA_BASE_URL }} + UIPATH_CLIENT_ID: ${{ secrets.ALPHA_TEST_CLIENT_ID }} + UIPATH_CLIENT_SECRET: ${{ secrets.ALPHA_TEST_CLIENT_SECRET }} + UIPATH_FOLDER_KEY: ${{ secrets.UIPATH_MEMORY_FOLDER }} + run: uv run pytest tests/services/test_memory_service_e2e.py -m e2e -v --no-cov + test-uipath: name: Test (uipath, ${{ matrix.python-version }}, ${{ matrix.os }}) needs: detect-changed-packages @@ -184,7 +233,7 @@ jobs: test-gate: name: Test - needs: [test-uipath-core, test-uipath-platform, test-uipath] + needs: [test-uipath-core, test-uipath-platform, test-uipath, e2e-uipath-platform] runs-on: ubuntu-latest if: always() steps: @@ -196,4 +245,8 @@ jobs: echo "Tests failed" exit 1 fi + # E2E tests are informational — log but don't block + if [[ "${{ needs.e2e-uipath-platform.result }}" == "failure" ]]; then + echo "⚠️ E2E memory tests failed (non-blocking)" + fi echo "All tests passed" diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 73f552851..9a11c2814 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.30" +version = "0.1.31" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" @@ -98,9 +98,12 @@ warn_required_dynamic_aliases = true [tool.pytest.ini_options] testpaths = ["tests"] python_files = "test_*.py" -addopts = "-ra -q --cov=src/uipath --cov-report=term-missing" +addopts = "-ra -q --cov=src/uipath --cov-report=term-missing -m 'not e2e'" asyncio_default_fixture_loop_scope = "function" asyncio_mode = "auto" +markers = [ + "e2e: end-to-end tests against real ECS/LLMOps (requires UIPATH_URL, UIPATH_ACCESS_TOKEN, UIPATH_FOLDER_KEY)", +] [tool.coverage.report] show_missing = true diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index e1d60fc39..28c7811c1 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -22,6 +22,7 @@ from .entities import EntitiesService from .errors import BaseUrlMissingError, SecretMissingError from .guardrails import GuardrailsService +from .memory import MemoryService from .orchestrator import ( AssetsService, AttachmentsService, @@ -113,6 +114,10 @@ def context_grounding(self) -> ContextGroundingService: self.buckets, ) + @property + def memory(self) -> MemoryService: + return MemoryService(self._config, self._execution_context, self.folders) + @property def documents(self) -> DocumentsService: return DocumentsService(self._config, self._execution_context) diff --git a/packages/uipath-platform/src/uipath/platform/memory/__init__.py b/packages/uipath-platform/src/uipath/platform/memory/__init__.py new file mode 100644 index 000000000..31e364814 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/memory/__init__.py @@ -0,0 +1,39 @@ +"""Init file for memory module.""" + +from ._memory_service import MemoryService +from .memory import ( + CachedRecall, + EscalationMemoryIngestRequest, + EscalationMemoryMatch, + EscalationMemorySearchResponse, + FieldSettings, + MemoryMatch, + MemoryMatchField, + MemorySearchRequest, + MemorySearchResponse, + MemorySpace, + MemorySpaceCreateRequest, + MemorySpaceListResponse, + SearchField, + SearchMode, + SearchSettings, +) + +__all__ = [ + "CachedRecall", + "EscalationMemoryIngestRequest", + "EscalationMemoryMatch", + "EscalationMemorySearchResponse", + "FieldSettings", + "MemoryMatch", + "MemoryMatchField", + "MemorySearchRequest", + "MemorySearchResponse", + "MemoryService", + "MemorySpace", + "MemorySpaceCreateRequest", + "MemorySpaceListResponse", + "SearchField", + "SearchMode", + "SearchSettings", +] diff --git a/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py new file mode 100644 index 000000000..89ec86a25 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py @@ -0,0 +1,461 @@ +"""Memory Spaces service. + +Memory space CRUD (create/list) goes through ECS v2. +Search and escalation memory operations go through LLMOps, which +enriches traces/feedback before forwarding to ECS. +""" + +from typing import Any, Optional + +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._folder_context import FolderContext, header_folder +from ..common._models import Endpoint, RequestSpec +from ..orchestrator._folder_service import FolderService +from .memory import ( + EscalationMemoryIngestRequest, + EscalationMemorySearchResponse, + MemorySearchRequest, + MemorySearchResponse, + MemorySpace, + MemorySpaceCreateRequest, + MemorySpaceListResponse, +) + +_MEMORY_SPACES_BASE = "/ecs_/v2/episodicmemories" +_LLMOPS_AGENT_BASE = "/llmopstenant_/api/Agent/memory" + + +class MemoryService(FolderContext, BaseService): + """Service for Agent Memory Spaces. + + Agent Memory allows agents to persist context across jobs using dynamic + few-shot retrieval. Memory spaces are folder-scoped and managed via ECS. + Search is routed through LLMOps, which handles trace/feedback enrichment + and system prompt injection. Escalation memory enables agents to recall + previously resolved escalation outcomes. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: FolderService, + ) -> None: + super().__init__(config=config, execution_context=execution_context) + self._folders_service = folders_service + + # ── Memory space operations (ECS) ────────────────────────────────── + + @traced(name="memory_create", run_type="uipath") + def create( + self, + name: str, + description: Optional[str] = None, + is_encrypted: Optional[bool] = None, + folder_key: Optional[str] = None, + ) -> MemorySpace: + """Create a new memory space. + + Args: + name: The name of the memory space (max 128 chars). + description: Optional description (max 1024 chars). + is_encrypted: Whether the memory space should be encrypted. + folder_key: The folder key for the operation. + + Returns: + MemorySpace: The created memory space. + """ + spec = self._create_spec(name, description, is_encrypted, folder_key) + response = self.request( + spec.method, + spec.endpoint, + json=spec.json, + headers=spec.headers, + ).json() + return MemorySpace.model_validate(response) + + @traced(name="memory_create", run_type="uipath") + async def create_async( + self, + name: str, + description: Optional[str] = None, + is_encrypted: Optional[bool] = None, + folder_key: Optional[str] = None, + ) -> MemorySpace: + """Asynchronously create a new memory space. + + Args: + name: The name of the memory space (max 128 chars). + description: Optional description (max 1024 chars). + is_encrypted: Whether the memory space should be encrypted. + folder_key: The folder key for the operation. + + Returns: + MemorySpace: The created memory space. + """ + spec = self._create_spec(name, description, is_encrypted, folder_key) + response = ( + await self.request_async( + spec.method, + spec.endpoint, + json=spec.json, + headers=spec.headers, + ) + ).json() + return MemorySpace.model_validate(response) + + @traced(name="memory_list", run_type="uipath") + def list( + self, + filter: Optional[str] = None, + orderby: Optional[str] = None, + top: Optional[int] = None, + skip: Optional[int] = None, + folder_key: Optional[str] = None, + ) -> MemorySpaceListResponse: + """List memory spaces with optional OData query parameters. + + Args: + filter: OData $filter expression. + orderby: OData $orderby expression. + top: Maximum number of results. + skip: Number of results to skip. + folder_key: The folder key for the operation. + + Returns: + MemorySpaceListResponse: The list of memory spaces. + """ + spec = self._list_spec(filter, orderby, top, skip, folder_key) + response = self.request( + spec.method, + spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + return MemorySpaceListResponse.model_validate(response) + + @traced(name="memory_list", run_type="uipath") + async def list_async( + self, + filter: Optional[str] = None, + orderby: Optional[str] = None, + top: Optional[int] = None, + skip: Optional[int] = None, + folder_key: Optional[str] = None, + ) -> MemorySpaceListResponse: + """Asynchronously list memory spaces. + + Args: + filter: OData $filter expression. + orderby: OData $orderby expression. + top: Maximum number of results. + skip: Number of results to skip. + folder_key: The folder key for the operation. + + Returns: + MemorySpaceListResponse: The list of memory spaces. + """ + spec = self._list_spec(filter, orderby, top, skip, folder_key) + response = ( + await self.request_async( + spec.method, + spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + return MemorySpaceListResponse.model_validate(response) + + # ── Search (LLMOps) ─────────────────────────────────────────────── + + @traced(name="memory_search", run_type="uipath") + def search( + self, + memory_space_id: str, + request: MemorySearchRequest, + folder_key: Optional[str] = None, + ) -> MemorySearchResponse: + """Search a memory space via LLMOps. + + Returns search results with scores and a systemPromptInjection + string ready for the agent loop. + + Args: + memory_space_id: The GUID of the memory space. + request: The search request payload. + folder_key: The folder key for the operation. + + Returns: + MemorySearchResponse: Results, metadata, and system prompt injection. + """ + spec = self._search_spec(memory_space_id, folder_key) + response = self.request( + spec.method, + spec.endpoint, + json=request.model_dump(by_alias=True, exclude_none=True), + headers=spec.headers, + ).json() + return MemorySearchResponse.model_validate(response) + + @traced(name="memory_search", run_type="uipath") + async def search_async( + self, + memory_space_id: str, + request: MemorySearchRequest, + folder_key: Optional[str] = None, + ) -> MemorySearchResponse: + """Asynchronously search a memory space via LLMOps. + + Returns search results with scores and a systemPromptInjection + string ready for the agent loop. + + Args: + memory_space_id: The GUID of the memory space. + request: The search request payload. + folder_key: The folder key for the operation. + + Returns: + MemorySearchResponse: Results, metadata, and system prompt injection. + """ + spec = self._search_spec(memory_space_id, folder_key) + response = ( + await self.request_async( + spec.method, + spec.endpoint, + json=request.model_dump(by_alias=True, exclude_none=True), + headers=spec.headers, + ) + ).json() + return MemorySearchResponse.model_validate(response) + + # ── Escalation memory (LLMOps) ──────────────────────────────────── + + @traced(name="memory_escalation_search", run_type="uipath") + def escalation_search( + self, + memory_space_id: str, + request: MemorySearchRequest, + folder_key: Optional[str] = None, + ) -> EscalationMemorySearchResponse: + """Search escalation memory for previously resolved outcomes. + + Allows agents to recall past escalation resolutions to avoid + re-escalating for similar situations. + + Args: + memory_space_id: The GUID of the memory space. + request: The search request payload (same as regular search). + folder_key: The folder key for the operation. + + Returns: + EscalationMemorySearchResponse: Matched escalation outcomes. + """ + spec = self._escalation_search_spec(memory_space_id, folder_key) + response = self.request( + spec.method, + spec.endpoint, + json=request.model_dump(by_alias=True, exclude_none=True), + headers=spec.headers, + ).json() + return EscalationMemorySearchResponse.model_validate(response) + + @traced(name="memory_escalation_search", run_type="uipath") + async def escalation_search_async( + self, + memory_space_id: str, + request: MemorySearchRequest, + folder_key: Optional[str] = None, + ) -> EscalationMemorySearchResponse: + """Asynchronously search escalation memory for previously resolved outcomes. + + Allows agents to recall past escalation resolutions to avoid + re-escalating for similar situations. + + Args: + memory_space_id: The GUID of the memory space. + request: The search request payload (same as regular search). + folder_key: The folder key for the operation. + + Returns: + EscalationMemorySearchResponse: Matched escalation outcomes. + """ + spec = self._escalation_search_spec(memory_space_id, folder_key) + response = ( + await self.request_async( + spec.method, + spec.endpoint, + json=request.model_dump(by_alias=True, exclude_none=True), + headers=spec.headers, + ) + ).json() + return EscalationMemorySearchResponse.model_validate(response) + + @traced(name="memory_escalation_ingest", run_type="uipath") + def escalation_ingest( + self, + memory_space_id: str, + request: EscalationMemoryIngestRequest, + folder_key: Optional[str] = None, + ) -> None: + """Ingest a resolved escalation outcome into memory. + + Persists the outcome so future agent runs can recall it + without re-escalating. + + Args: + memory_space_id: The GUID of the memory space. + request: The escalation ingest payload. + folder_key: The folder key for the operation. + """ + spec = self._escalation_ingest_spec(memory_space_id, folder_key) + self.request( + spec.method, + spec.endpoint, + json=request.model_dump(by_alias=True, exclude_none=True), + headers=spec.headers, + ) + + @traced(name="memory_escalation_ingest", run_type="uipath") + async def escalation_ingest_async( + self, + memory_space_id: str, + request: EscalationMemoryIngestRequest, + folder_key: Optional[str] = None, + ) -> None: + """Asynchronously ingest a resolved escalation outcome into memory. + + Persists the outcome so future agent runs can recall it + without re-escalating. + + Args: + memory_space_id: The GUID of the memory space. + request: The escalation ingest payload. + folder_key: The folder key for the operation. + """ + spec = self._escalation_ingest_spec(memory_space_id, folder_key) + await self.request_async( + spec.method, + spec.endpoint, + json=request.model_dump(by_alias=True, exclude_none=True), + headers=spec.headers, + ) + + # ── Private spec builders ───────────────────────────────────────── + + def _resolve_folder( + self, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Optional[str]: + """Resolve the folder key, supporting folder_path lookup for serverless. + + Priority: + 1. Explicit folder_key argument + 2. Explicit folder_path argument → resolve via FolderService + 3. UIPATH_FOLDER_KEY env var (via FolderContext._folder_key) + 4. UIPATH_FOLDER_PATH env var → resolve via FolderService + """ + if folder_key is None and folder_path is not None: + folder_key = self._folders_service.retrieve_key(folder_path=folder_path) + + if folder_key is None and folder_path is None: + folder_key = self._folder_key or ( + self._folders_service.retrieve_key(folder_path=self._folder_path) + if self._folder_path + else None + ) + + return folder_key + + # -- ECS specs -- + + def _create_spec( + self, + name: str, + description: Optional[str], + is_encrypted: Optional[bool], + folder_key: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key) + body = MemorySpaceCreateRequest( + name=name, + description=description, + is_encrypted=is_encrypted, + ) + return RequestSpec( + method="POST", + endpoint=Endpoint(f"{_MEMORY_SPACES_BASE}/create"), + json=body.model_dump(by_alias=True, exclude_none=True), + headers={**header_folder(folder_key, None)}, + ) + + def _list_spec( + self, + filter: Optional[str], + orderby: Optional[str], + top: Optional[int], + skip: Optional[int], + folder_key: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key) + params: dict[str, Any] = {} + if filter is not None: + params["$filter"] = filter + if orderby is not None: + params["$orderby"] = orderby + if top is not None: + params["$top"] = top + if skip is not None: + params["$skip"] = skip + return RequestSpec( + method="GET", + endpoint=Endpoint(_MEMORY_SPACES_BASE), + params=params, + headers={**header_folder(folder_key, None)}, + ) + + # -- LLMOps specs -- + + def _search_spec( + self, + memory_space_id: str, + folder_key: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key) + return RequestSpec( + method="POST", + endpoint=Endpoint(f"{_LLMOPS_AGENT_BASE}/{memory_space_id}/search"), + headers={**header_folder(folder_key, None)}, + ) + + def _escalation_search_spec( + self, + memory_space_id: str, + folder_key: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key) + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"{_LLMOPS_AGENT_BASE}/{memory_space_id}/escalation/search" + ), + headers={**header_folder(folder_key, None)}, + ) + + def _escalation_ingest_spec( + self, + memory_space_id: str, + folder_key: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key) + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"{_LLMOPS_AGENT_BASE}/{memory_space_id}/escalation/ingest" + ), + headers={**header_folder(folder_key, None)}, + ) diff --git a/packages/uipath-platform/src/uipath/platform/memory/memory.py b/packages/uipath-platform/src/uipath/platform/memory/memory.py new file mode 100644 index 000000000..aadffbc79 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/memory/memory.py @@ -0,0 +1,191 @@ +"""Pydantic models for the Memory Spaces API. + +Memory space CRUD goes through ECS v2. Search goes through LLMOps, +which enriches traces/feedback before forwarding to ECS. +Escalation memory operations also go through LLMOps. +""" + +from enum import Enum +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + +# ── Enums ────────────────────────────────────────────────────────────── + + +class SearchMode(str, Enum): + """Search mode for memory space queries.""" + + Hybrid = "Hybrid" + Semantic = "Semantic" + + +# ── Shared field models (used by both ECS and LLMOps) ───────────────── + + +class FieldSettings(BaseModel): + """Per-field search settings (optional overrides).""" + + model_config = ConfigDict(populate_by_name=True) + + weight: float = Field(default=1.0, alias="weight", ge=0.0, le=1.0) + threshold: Optional[float] = Field(None, alias="threshold", ge=0.0, le=1.0) + search_mode: Optional[SearchMode] = Field(None, alias="searchMode") + + +class SearchField(BaseModel): + """A field in a search request, with per-field settings.""" + + model_config = ConfigDict(populate_by_name=True) + + key_path: List[str] = Field(..., alias="keyPath", min_length=1) + value: str = Field(..., alias="value", min_length=1) + settings: FieldSettings = Field(default_factory=FieldSettings, alias="settings") + + +class SearchSettings(BaseModel): + """Top-level search settings.""" + + model_config = ConfigDict(populate_by_name=True) + + threshold: float = Field(default=0.0, alias="threshold", ge=0.0, le=1.0) + result_count: int = Field(default=1, alias="resultCount", ge=1, le=10) + search_mode: SearchMode = Field(..., alias="searchMode") + + +class MemoryMatchField(BaseModel): + """A field within a search result, with scoring details.""" + + model_config = ConfigDict(populate_by_name=True) + + key_path: List[str] = Field(..., alias="keyPath") + value: str = Field(..., alias="value") + weight: float = Field(..., alias="weight") + score: float = Field(..., alias="score") + weighted_score: float = Field(..., alias="weightedScore") + + +# ── ECS request models (memory space CRUD) ──────────────────────────── + + +class MemorySpaceCreateRequest(BaseModel): + """Request payload for creating a memory space (ECS).""" + + model_config = ConfigDict(populate_by_name=True) + + name: str = Field(..., alias="name", max_length=128, min_length=1) + description: Optional[str] = Field(None, alias="description", max_length=1024) + is_encrypted: Optional[bool] = Field(None, alias="isEncrypted") + + +# ── ECS response models ─────────────────────────────────────────────── + + +class MemorySpace(BaseModel): + """A memory space (folder-scoped, from ECS).""" + + model_config = ConfigDict(populate_by_name=True) + + id: str = Field(..., alias="id") + name: str = Field(..., alias="name") + description: Optional[str] = Field(None, alias="description") + last_queried: Optional[str] = Field(None, alias="lastQueried") + memories_count: int = Field(default=0, alias="memoriesCount") + folder_key: str = Field(..., alias="folderKey") + created_by_user_id: Optional[str] = Field(None, alias="createdByUserId") + is_encrypted: bool = Field(default=False, alias="isEncrypted") + + +class MemorySpaceListResponse(BaseModel): + """OData response from listing memory spaces (ECS).""" + + model_config = ConfigDict(populate_by_name=True) + + value: List[MemorySpace] = Field(default_factory=list, alias="value") + + +# ── LLMOps search models ────────────────────────────────────────────── + + +class MemorySearchRequest(BaseModel): + """Request payload for searching memory via LLMOps. + + Includes definitionSystemPrompt so LLMOps can generate the + systemPromptInjection for the agent loop. + """ + + model_config = ConfigDict(populate_by_name=True) + + fields: List[SearchField] = Field(..., alias="fields", min_length=1, max_length=20) + settings: SearchSettings = Field(..., alias="settings") + definition_system_prompt: Optional[str] = Field( + None, alias="definitionSystemPrompt" + ) + + +class MemoryMatch(BaseModel): + """A single matched memory from a search operation (LLMOps).""" + + model_config = ConfigDict(populate_by_name=True) + + memory_item_id: str = Field(..., alias="memoryItemId") + score: float = Field(..., alias="score") + semantic_score: float = Field(..., alias="semanticScore") + weighted_score: float = Field(..., alias="weightedScore") + fields: List[MemoryMatchField] = Field(..., alias="fields") + span: Optional[Any] = Field(None, alias="span") + feedback: Optional[Any] = Field(None, alias="feedback") + + +class MemorySearchResponse(BaseModel): + """Response from LLMOps search, including system prompt injection.""" + + model_config = ConfigDict(populate_by_name=True) + + results: List[MemoryMatch] = Field(default_factory=list, alias="results") + metadata: Dict[str, str] = Field(default_factory=dict, alias="metadata") + system_prompt_injection: str = Field("", alias="systemPromptInjection") + + +# ── LLMOps escalation memory models ────────────────────────────────── + + +class EscalationMemoryIngestRequest(BaseModel): + """Request payload for ingesting an escalation outcome into memory. + + Used by the escalation tool to persist resolved outcomes so + future runs can recall them without re-escalating. + """ + + model_config = ConfigDict(populate_by_name=True) + + span_id: str = Field(..., alias="spanId") + trace_id: str = Field(..., alias="traceId") + answer: str = Field(..., alias="answer") + attributes: str = Field(..., alias="attributes") + user_id: Optional[str] = Field(None, alias="userId") + + +class CachedRecall(BaseModel): + """A cached escalation answer retrieved from memory.""" + + model_config = ConfigDict(populate_by_name=True) + + output: Optional[Any] = Field(None, alias="output") + outcome: Optional[str] = Field(None, alias="outcome") + + +class EscalationMemoryMatch(BaseModel): + """A single match from an escalation memory search.""" + + model_config = ConfigDict(populate_by_name=True) + + answer: Optional[CachedRecall] = Field(None, alias="answer") + + +class EscalationMemorySearchResponse(BaseModel): + """Response from LLMOps escalation memory search.""" + + model_config = ConfigDict(populate_by_name=True) + + results: Optional[List[EscalationMemoryMatch]] = Field(None, alias="results") diff --git a/packages/uipath-platform/tests/services/test_memory_service.py b/packages/uipath-platform/tests/services/test_memory_service.py new file mode 100644 index 000000000..716e3438c --- /dev/null +++ b/packages/uipath-platform/tests/services/test_memory_service.py @@ -0,0 +1,504 @@ +"""Unit tests for MemoryService with HTTP mocking.""" + +import json + +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.memory import ( + EscalationMemoryIngestRequest, + EscalationMemorySearchResponse, + MemoryMatch, + MemoryMatchField, + MemorySearchRequest, + MemorySearchResponse, + MemorySpace, + MemorySpaceListResponse, + SearchField, + SearchMode, + SearchSettings, +) +from uipath.platform.memory._memory_service import MemoryService +from uipath.platform.orchestrator._folder_service import FolderService + + +@pytest.fixture +def folder_service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, +) -> FolderService: + return FolderService(config=config, execution_context=execution_context) + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folder_service: FolderService, + monkeypatch: pytest.MonkeyPatch, +) -> MemoryService: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "test-folder-key") + return MemoryService( + config=config, + execution_context=execution_context, + folders_service=folder_service, + ) + + +# ── Sample response payloads ────────────────────────────────────────── + +SAMPLE_INDEX = { + "id": "aaaa-bbbb-cccc-dddd", + "name": "test-memory-space", + "description": "A test memory space", + "lastQueried": "2026-03-30T00:00:00Z", + "memoriesCount": 5, + "folderKey": "test-folder-key", + "createdByUserId": "user-123", + "isEncrypted": False, +} + +SAMPLE_LIST_RESPONSE = {"value": [SAMPLE_INDEX]} + +SAMPLE_SEARCH_RESPONSE = { + "results": [ + { + "memoryItemId": "item-001", + "score": 0.95, + "semanticScore": 0.92, + "weightedScore": 0.93, + "fields": [ + { + "keyPath": ["input"], + "value": "What is the capital of France?", + "weight": 1.0, + "score": 0.95, + "weightedScore": 0.95, + } + ], + "span": None, + "feedback": None, + } + ], + "metadata": {"queryTime": "12ms"}, + "systemPromptInjection": "Based on past interactions: Paris is the capital.", +} + +SAMPLE_ESCALATION_SEARCH_RESPONSE = { + "results": [ + { + "answer": { + "output": {"action": "approve", "reason": "meets criteria"}, + "outcome": "approved", + } + } + ], +} + + +class TestMemoryService: + """Unit tests for MemoryService.""" + + class TestCreate: + def test_create_memory_space( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/episodicmemories/create", + status_code=200, + json=SAMPLE_INDEX, + ) + + result = service.create( + name="test-memory-space", + description="A test memory space", + ) + + assert isinstance(result, MemorySpace) + assert result.id == "aaaa-bbbb-cccc-dddd" + assert result.name == "test-memory-space" + assert result.memories_count == 5 + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.method == "POST" + body = json.loads(sent.content) + assert body["name"] == "test-memory-space" + assert body["description"] == "A test memory space" + + def test_create_sends_folder_header( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/episodicmemories/create", + status_code=200, + json=SAMPLE_INDEX, + ) + + service.create(name="test", folder_key="custom-folder-key") + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.headers.get("x-uipath-folderkey") == "custom-folder-key" + + def test_create_with_encryption( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/episodicmemories/create", + status_code=200, + json={**SAMPLE_INDEX, "isEncrypted": True}, + ) + + result = service.create( + name="encrypted-space", + is_encrypted=True, + ) + + assert result.is_encrypted is True + sent = httpx_mock.get_request() + assert sent is not None + body = json.loads(sent.content) + assert body["isEncrypted"] is True + + class TestList: + def test_list_memory_spaces( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/episodicmemories", + status_code=200, + json=SAMPLE_LIST_RESPONSE, + ) + + result = service.list() + + assert isinstance(result, MemorySpaceListResponse) + assert len(result.value) == 1 + assert result.value[0].name == "test-memory-space" + + def test_list_with_odata_params( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/episodicmemories?%24filter=Name+eq+%27test%27&%24orderby=Name+asc&%24top=10&%24skip=5", + status_code=200, + json=SAMPLE_LIST_RESPONSE, + ) + + result = service.list( + filter="Name eq 'test'", + orderby="Name asc", + top=10, + skip=5, + ) + + assert isinstance(result, MemorySpaceListResponse) + + def test_list_empty( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/episodicmemories", + status_code=200, + json={"value": []}, + ) + + result = service.list() + + assert isinstance(result, MemorySpaceListResponse) + assert len(result.value) == 0 + + class TestSearch: + def test_search_memory( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + memory_space_id = "aaaa-bbbb-cccc-dddd" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/Agent/memory/{memory_space_id}/search", + status_code=200, + json=SAMPLE_SEARCH_RESPONSE, + ) + + request = MemorySearchRequest( + fields=[ + SearchField( + key_path=["input"], + value="What is the capital of France?", + ) + ], + settings=SearchSettings( + threshold=0.0, + result_count=5, + search_mode=SearchMode.Hybrid, + ), + definition_system_prompt="You are a helpful assistant.", + ) + + result = service.search( + memory_space_id=memory_space_id, + request=request, + ) + + assert isinstance(result, MemorySearchResponse) + assert len(result.results) == 1 + assert isinstance(result.results[0], MemoryMatch) + assert result.results[0].memory_item_id == "item-001" + assert result.results[0].score == 0.95 + assert isinstance(result.results[0].fields[0], MemoryMatchField) + assert ( + result.system_prompt_injection + == "Based on past interactions: Paris is the capital." + ) + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.method == "POST" + body = json.loads(sent.content) + assert body["fields"][0]["keyPath"] == ["input"] + assert body["settings"]["searchMode"] == "Hybrid" + assert body["definitionSystemPrompt"] == "You are a helpful assistant." + + def test_search_sends_folder_header( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + memory_space_id = "aaaa-bbbb-cccc-dddd" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/Agent/memory/{memory_space_id}/search", + status_code=200, + json=SAMPLE_SEARCH_RESPONSE, + ) + + request = MemorySearchRequest( + fields=[SearchField(key_path=["input"], value="test")], + settings=SearchSettings( + threshold=0.0, + result_count=1, + search_mode=SearchMode.Semantic, + ), + ) + + service.search( + memory_space_id=memory_space_id, + request=request, + folder_key="custom-folder", + ) + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.headers.get("x-uipath-folderkey") == "custom-folder" + + class TestEscalationSearch: + def test_escalation_search( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + memory_space_id = "aaaa-bbbb-cccc-dddd" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/Agent/memory/{memory_space_id}/escalation/search", + status_code=200, + json=SAMPLE_ESCALATION_SEARCH_RESPONSE, + ) + + request = MemorySearchRequest( + fields=[SearchField(key_path=["input"], value="approval request")], + settings=SearchSettings( + threshold=0.0, + result_count=5, + search_mode=SearchMode.Hybrid, + ), + ) + + result = service.escalation_search( + memory_space_id=memory_space_id, + request=request, + ) + + assert isinstance(result, EscalationMemorySearchResponse) + assert result.results is not None + assert len(result.results) == 1 + assert result.results[0].answer is not None + assert result.results[0].answer.outcome == "approved" + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.method == "POST" + assert "/escalation/search" in str(sent.url) + + def test_escalation_search_empty_results( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + memory_space_id = "aaaa-bbbb-cccc-dddd" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/Agent/memory/{memory_space_id}/escalation/search", + status_code=200, + json={"results": None}, + ) + + request = MemorySearchRequest( + fields=[SearchField(key_path=["input"], value="no match")], + settings=SearchSettings( + threshold=0.0, + result_count=1, + search_mode=SearchMode.Hybrid, + ), + ) + + result = service.escalation_search( + memory_space_id=memory_space_id, + request=request, + ) + + assert isinstance(result, EscalationMemorySearchResponse) + assert result.results is None + + class TestEscalationIngest: + def test_escalation_ingest( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + memory_space_id = "aaaa-bbbb-cccc-dddd" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/Agent/memory/{memory_space_id}/escalation/ingest", + status_code=200, + ) + + request = EscalationMemoryIngestRequest( + span_id="span-123", + trace_id="trace-456", + answer='{"action": "approve"}', + attributes='{"input": "approve this?"}', + user_id="user-789", + ) + + service.escalation_ingest( + memory_space_id=memory_space_id, + request=request, + ) + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.method == "POST" + assert "/escalation/ingest" in str(sent.url) + body = json.loads(sent.content) + assert body["spanId"] == "span-123" + assert body["traceId"] == "trace-456" + assert body["answer"] == '{"action": "approve"}' + assert body["attributes"] == '{"input": "approve this?"}' + assert body["userId"] == "user-789" + + def test_escalation_ingest_sends_folder_header( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + memory_space_id = "aaaa-bbbb-cccc-dddd" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/Agent/memory/{memory_space_id}/escalation/ingest", + status_code=200, + ) + + request = EscalationMemoryIngestRequest( + span_id="s1", + trace_id="t1", + answer="yes", + attributes="{}", + ) + + service.escalation_ingest( + memory_space_id=memory_space_id, + request=request, + folder_key="my-folder", + ) + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.headers.get("x-uipath-folderkey") == "my-folder" + + def test_escalation_ingest_excludes_none_user_id( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + memory_space_id = "aaaa-bbbb-cccc-dddd" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/Agent/memory/{memory_space_id}/escalation/ingest", + status_code=200, + ) + + request = EscalationMemoryIngestRequest( + span_id="s1", + trace_id="t1", + answer="yes", + attributes="{}", + ) + + service.escalation_ingest( + memory_space_id=memory_space_id, + request=request, + ) + + sent = httpx_mock.get_request() + assert sent is not None + body = json.loads(sent.content) + assert "userId" not in body diff --git a/packages/uipath-platform/tests/services/test_memory_service_e2e.py b/packages/uipath-platform/tests/services/test_memory_service_e2e.py new file mode 100644 index 000000000..6c7866611 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_memory_service_e2e.py @@ -0,0 +1,164 @@ +"""E2E tests for MemoryService against real ECS + LLMOps endpoints. + +Prerequisites: + uipath auth --alpha # sets UIPATH_URL + UIPATH_ACCESS_TOKEN + export UIPATH_FOLDER_KEY=... # folder GUID with agent memory enabled + +Run: + cd packages/uipath-platform + uv run pytest tests/services/test_memory_service_e2e.py -m e2e -v +""" + +import os +import uuid + +import pytest + +from uipath.platform import UiPath +from uipath.platform.memory import ( + EscalationMemorySearchResponse, + MemorySearchRequest, + MemorySearchResponse, + MemorySpace, + MemorySpaceListResponse, + SearchField, + SearchMode, + SearchSettings, +) + +pytestmark = pytest.mark.e2e + + +def _require_env(name: str) -> str: + value = os.environ.get(name) + if not value: + pytest.skip(f"Environment variable {name} is not set") + return value + + +@pytest.fixture(scope="module") +def sdk() -> UiPath: + """Create a real UiPath client from env vars. + + Supports two auth modes: + - Token-based: UIPATH_URL + UIPATH_ACCESS_TOKEN (from `uipath auth`) + - Client credentials: UIPATH_URL + UIPATH_CLIENT_ID + UIPATH_CLIENT_SECRET (CI) + """ + _require_env("UIPATH_URL") + client_id = os.environ.get("UIPATH_CLIENT_ID") + client_secret = os.environ.get("UIPATH_CLIENT_SECRET") + if client_id and client_secret: + return UiPath(client_id=client_id, client_secret=client_secret) + _require_env("UIPATH_ACCESS_TOKEN") + return UiPath() + + +@pytest.fixture(scope="module") +def folder_key() -> str: + return _require_env("UIPATH_FOLDER_KEY") + + +@pytest.fixture(scope="module") +def memory_index(sdk: UiPath, folder_key: str): # noqa: ANN201 + """Create a test memory index and clean it up after all tests.""" + unique_name = f"sdk-e2e-test-{uuid.uuid4().hex[:8]}" + index = sdk.memory.create( + name=unique_name, + description="Created by E2E test — safe to delete", + folder_key=folder_key, + ) + yield index + + +class TestMemoryServiceE2E: + """E2E tests for MemoryService lifecycle. + + Requires: UIPATH_URL, UIPATH_ACCESS_TOKEN, UIPATH_FOLDER_KEY + """ + + # ── Index CRUD (ECS) ────────────────────────────────────────── + + def test_create_index(self, memory_index: MemorySpace) -> None: + """Verify index creation returns a well-formed MemorySpace.""" + assert memory_index.id, "Index ID should be set" + assert memory_index.name.startswith("sdk-e2e-test-") + assert memory_index.folder_key, "Folder key should be populated" + assert memory_index.memories_count == 0 + + def test_list_indexes( + self, + sdk: UiPath, + memory_index: MemorySpace, + folder_key: str, + ) -> None: + """Verify list with OData filter returns our index.""" + result = sdk.memory.list( + filter=f"Name eq '{memory_index.name}'", + folder_key=folder_key, + ) + assert isinstance(result, MemorySpaceListResponse) + names = [idx.name for idx in result.value] + assert memory_index.name in names + + # ── Search (LLMOps) ────────────────────────────────────────── + + def test_search_empty_index( + self, + sdk: UiPath, + memory_index: MemorySpace, + folder_key: str, + ) -> None: + """Search an empty index — should return empty results and systemPromptInjection.""" + request = MemorySearchRequest( + fields=[ + SearchField( + key_path=["input"], + value="test query", + ) + ], + settings=SearchSettings( + threshold=0.0, + result_count=5, + search_mode=SearchMode.Hybrid, + ), + definition_system_prompt="You are a helpful assistant.", + ) + result = sdk.memory.search( + memory_space_id=memory_index.id, + request=request, + folder_key=folder_key, + ) + assert isinstance(result, MemorySearchResponse) + assert isinstance(result.results, list) + assert isinstance(result.metadata, dict) + assert isinstance(result.system_prompt_injection, str) + + # ── Escalation search (LLMOps) ──────────────────────────────── + + def test_escalation_search_empty_index( + self, + sdk: UiPath, + memory_index: MemorySpace, + folder_key: str, + ) -> None: + """Search escalation memory on empty index — should return valid response.""" + request = MemorySearchRequest( + fields=[ + SearchField( + key_path=["input"], + value="test escalation query", + ) + ], + settings=SearchSettings( + threshold=0.0, + result_count=5, + search_mode=SearchMode.Hybrid, + ), + definition_system_prompt="You are a helpful assistant.", + ) + result = sdk.memory.escalation_search( + memory_space_id=memory_index.id, + request=request, + folder_key=folder_key, + ) + assert isinstance(result, EscalationMemorySearchResponse) diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 7d31cdfd9..380597fbf 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.30" +version = "0.1.31" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index f50030cb8..c1419b45b 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.30" +version = "0.1.31" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 96828960c7ae9a5669270151f3bee8af37c6f3f1 Mon Sep 17 00:00:00 2001 From: UIPath-Harshit Date: Mon, 20 Apr 2026 15:59:12 +0530 Subject: [PATCH 022/191] feat: improve SQL validation for aggregate functions and entity model resilience (#1576) Co-authored-by: Claude Opus 4.6 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../platform/entities/_entities_service.py | 123 +++++++++++++++--- .../src/uipath/platform/entities/entities.py | 38 ++++-- .../tests/services/test_entities_service.py | 49 ++++++- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 6 files changed, 183 insertions(+), 33 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 9a11c2814..3616306cd 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.31" +version = "0.1.32" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py index a095f430d..951a4b07b 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py @@ -4,8 +4,8 @@ import sqlparse from httpx import Response -from sqlparse.sql import Parenthesis, Where -from sqlparse.tokens import DML, Keyword, Wildcard +from sqlparse.sql import Function, Identifier, IdentifierList, Parenthesis, Where +from sqlparse.tokens import DML, Keyword, Whitespace, Wildcard from uipath.core.tracing import traced from ..common._base_service import BaseService @@ -49,6 +49,7 @@ "GROUPING", "PARTITION", ] +_AGGREGATE_FUNCTIONS = ("COUNT", "SUM", "AVG", "MIN", "MAX") class EntitiesService(BaseService): @@ -177,6 +178,7 @@ def retrieve_by_name( spec = self._retrieve_by_name_spec(entity_name) headers = self._folder_key_headers(folder_key) response = self.request(spec.method, spec.endpoint, headers=headers) + return Entity.model_validate(response.json()) @traced(name="entity_retrieve_by_name", run_type="uipath") @@ -196,6 +198,7 @@ async def retrieve_by_name_async( spec = self._retrieve_by_name_spec(entity_name) headers = self._folder_key_headers(folder_key) response = await self.request_async(spec.method, spec.endpoint, headers=headers) + return Entity.model_validate(response.json()) @traced(name="list_entities", run_type="uipath") @@ -1333,18 +1336,102 @@ def _validate_sql_query(self, sql_query: str) -> None: has_where = any(isinstance(t, Where) for t in stmt.tokens) has_limit = "LIMIT" in keywords - if not has_where and not has_limit: - raise ValueError("Queries without WHERE must include a LIMIT clause.") + has_from = "FROM" in keywords + + if not has_from: + raise ValueError("Queries must include a FROM clause.") projection = self._projection_tokens(stmt) - has_wildcard = any(t.ttype is Wildcard for t in projection) - if has_wildcard and not has_where: - raise ValueError("SELECT * without filtering is not allowed.") + + if self._projection_has_count_star(projection): + raise ValueError( + "COUNT(*) is not supported. Use COUNT(column_name) instead." + ) + + has_aggregate = self._projection_has_aggregate(projection) + + if not has_where and not has_limit and not has_aggregate: + raise ValueError("Queries without WHERE must include a LIMIT clause.") + + has_bare_wildcard = self._projection_has_bare_wildcard(projection) + if has_bare_wildcard: + raise ValueError("SELECT * is not allowed. Specify column names instead.") if not has_where and self._projection_column_count(projection) > 4: raise ValueError( "Selecting more than 4 columns without filtering is not allowed." ) + @staticmethod + def _projection_has_aggregate( + projection: list[sqlparse.sql.Token], + ) -> bool: + """Check whether the projection contains an aggregate function call.""" + + def _has_agg(token: sqlparse.sql.Token) -> bool: + if isinstance(token, Function): + return token.get_name().upper() in _AGGREGATE_FUNCTIONS + if isinstance(token, Identifier): + return any(_has_agg(child) for child in token.tokens) + return False + + for node in projection: + if _has_agg(node): + return True + if isinstance(node, IdentifierList): + if any(_has_agg(child) for child in node.tokens): + return True + return False + + @staticmethod + def _projection_has_count_star( + projection: list[sqlparse.sql.Token], + ) -> bool: + """Check whether projection contains COUNT(*).""" + + def _is_count_star(func: Function) -> bool: + if func.get_name().upper() != "COUNT": + return False + return any(t.ttype is Wildcard for t in func.flatten()) + + def _has_count_star(token: sqlparse.sql.Token) -> bool: + if isinstance(token, Function): + return _is_count_star(token) + if isinstance(token, Identifier): + return any(_has_count_star(child) for child in token.tokens) + return False + + for node in projection: + if _has_count_star(node): + return True + if isinstance(node, IdentifierList): + if any(_has_count_star(child) for child in node.tokens): + return True + return False + + @staticmethod + def _projection_has_bare_wildcard( + projection: list[sqlparse.sql.Token], + ) -> bool: + """Check for a bare ``*`` or qualified ``table.*`` outside a function.""" + + def _identifier_has_wildcard(ident: Identifier) -> bool: + return any(t.ttype is Wildcard for t in ident.tokens) + + for node in projection: + if node.ttype is Wildcard: + return True + if isinstance(node, Identifier) and _identifier_has_wildcard(node): + return True + if isinstance(node, IdentifierList): + for child in node.tokens: + if child.ttype is Wildcard: + return True + if isinstance(child, Identifier) and _identifier_has_wildcard( + child + ): + return True + return False + @staticmethod def _has_subquery(stmt: sqlparse.sql.Statement) -> bool: """Recursively walk the AST looking for SELECT inside parentheses.""" @@ -1369,16 +1456,18 @@ def _walk(token: sqlparse.sql.Token) -> bool: def _projection_tokens( stmt: sqlparse.sql.Statement, ) -> list[sqlparse.sql.Token]: - """Extract tokens between the first SELECT and FROM.""" + """Extract non-flattened AST nodes between the first SELECT and FROM.""" tokens: list[sqlparse.sql.Token] = [] collecting = False - for token in stmt.flatten(): + for token in stmt.tokens: if token.ttype is DML and token.normalized == "SELECT": collecting = True continue - if token.ttype is Keyword and token.normalized == "FROM": + if token.ttype is Keyword and token.normalized in ("FROM", "INTO"): break - if collecting: + if token.ttype is Keyword and token.normalized == "DISTINCT": + continue + if collecting and token.ttype is not Whitespace: tokens.append(token) return tokens @@ -1386,10 +1475,14 @@ def _projection_tokens( def _projection_column_count( projection: list[sqlparse.sql.Token], ) -> int: - text = "".join(t.value for t in projection).strip() - if not text: - return 0 - return len([part for part in text.split(",") if part.strip()]) + for node in projection: + if isinstance(node, IdentifierList): + return len(list(node.get_identifiers())) + if isinstance(node, (Identifier, Function)): + return 1 + if node.ttype is Wildcard: + return 1 + return 0 # Resolve the forward reference to EntitiesService in EntitySetResolution. diff --git a/packages/uipath-platform/src/uipath/platform/entities/entities.py b/packages/uipath-platform/src/uipath/platform/entities/entities.py index f3fb862f8..48c8dce07 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/entities.py +++ b/packages/uipath-platform/src/uipath/platform/entities/entities.py @@ -16,7 +16,7 @@ get_origin, ) -from pydantic import BaseModel, ConfigDict, Field, create_model +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, create_model if TYPE_CHECKING: from ._entities_service import EntitiesService @@ -140,7 +140,7 @@ class FieldMetadata(BaseModel): reference_field: Optional["EntityField"] = Field( default=None, alias="referenceField" ) - reference_type: ReferenceType = Field(alias="referenceType") + reference_type: Optional[ReferenceType] = Field(default=None, alias="referenceType") sql_type: "FieldDataType" = Field(alias="sqlType") is_required: bool = Field(alias="isRequired") display_name: str = Field(alias="displayName") @@ -212,14 +212,21 @@ class SourceJoinCriteria(BaseModel): model_config = ConfigDict( validate_by_name=True, validate_by_alias=True, + extra="allow", + ) + id: Optional[str] = None + entity_id: Optional[str] = Field(default=None, alias="entityId") + join_field_name: Optional[str] = Field(default=None, alias="joinFieldName") + join_type: Optional[str] = Field(default=None, alias="joinType") + related_source_object_id: Optional[str] = Field( + default=None, alias="relatedSourceObjectId" + ) + related_source_object_field_name: Optional[str] = Field( + default=None, alias="relatedSourceObjectFieldName" + ) + related_source_field_name: Optional[str] = Field( + default=None, alias="relatedSourceFieldName" ) - id: str - entity_id: str = Field(alias="entityId") - join_field_name: str = Field(alias="joinFieldName") - join_type: str = Field(alias="joinType") - related_source_object_id: str = Field(alias="relatedSourceObjectId") - related_source_object_field_name: str = Field(alias="relatedSourceObjectFieldName") - related_source_field_name: str = Field(alias="relatedSourceFieldName") class ChoiceSetValue(BaseModel): @@ -326,11 +333,16 @@ class Entity(BaseModel): entity_type: str = Field(alias="entityType") description: Optional[str] = Field(default=None, alias="description") fields: Optional[List[FieldMetadata]] = Field(default=None, alias="fields") - external_fields: Optional[List[ExternalSourceFields]] = Field( - default=None, alias="externalFields" + external_fields: Optional[ + List[ExternalField | ExternalSourceFields | Dict[str, Any]] + ] = Field( + default=None, + alias="externalFields", ) - source_join_criteria: Optional[List[SourceJoinCriteria]] = Field( - default=None, alias="sourceJoinCriteria" + source_join_criteria: Optional[List[SourceJoinCriteria | Dict[str, Any]]] = Field( + default=None, + validation_alias=AliasChoices("sourceJoinCriteria", "sourceJoinCriterias"), + alias="sourceJoinCriteria", ) record_count: Optional[int] = Field(default=None, alias="recordCount") storage_size_in_mb: Optional[float] = Field(default=None, alias="storageSizeInMB") diff --git a/packages/uipath-platform/tests/services/test_entities_service.py b/packages/uipath-platform/tests/services/test_entities_service.py index 71c3ccd5b..29ce6fb79 100644 --- a/packages/uipath-platform/tests/services/test_entities_service.py +++ b/packages/uipath-platform/tests/services/test_entities_service.py @@ -308,7 +308,12 @@ def test_retrieve_records_without_start_and_limit( [ "SELECT id FROM Customers WHERE id = 1", "SELECT id, name FROM Customers LIMIT 10", - "SELECT * FROM Customers WHERE status = 'Active'", + "SELECT COUNT(id) FROM Customers", + "SELECT SUM(amount) FROM Orders", + "SELECT AVG(price) FROM Products", + "SELECT MIN(created), MAX(created) FROM Events", + "SELECT COUNT(id) AS total, SUM(amount) AS amt FROM Orders", + "SELECT COUNT(id), name FROM Customers LIMIT 10", "SELECT id, name, email, phone FROM Customers LIMIT 5", "SELECT DISTINCT id FROM Customers WHERE id > 100", "SELECT id FROM Customers WHERE name = 'foo;bar'", @@ -356,9 +361,49 @@ def test_validate_sql_query_allows_supported_select_queries( "SELECT id FROM Customers", "Queries without WHERE must include a LIMIT clause.", ), + ( + "SELECT UPPER(name) FROM Customers", + "Queries without WHERE must include a LIMIT clause.", + ), + ( + "SELECT COALESCE(name, 'N/A') FROM Customers", + "Queries without WHERE must include a LIMIT clause.", + ), + ( + "SELECT 1 LIMIT 1", + "Queries must include a FROM clause.", + ), + ( + "SELECT COUNT(*) FROM Customers", + "COUNT(*) is not supported. Use COUNT(column_name) instead.", + ), + ( + "SELECT COUNT(*), name FROM Customers LIMIT 10", + "COUNT(*) is not supported. Use COUNT(column_name) instead.", + ), + ( + "SELECT COUNT(*) AS total FROM Customers", + "COUNT(*) is not supported. Use COUNT(column_name) instead.", + ), ( "SELECT * FROM Customers LIMIT 10", - "SELECT * without filtering is not allowed.", + "SELECT * is not allowed. Specify column names instead.", + ), + ( + "SELECT Customers.* FROM Customers LIMIT 10", + "SELECT * is not allowed. Specify column names instead.", + ), + ( + "SELECT t.* FROM Customers t LIMIT 10", + "SELECT * is not allowed. Specify column names instead.", + ), + ( + "SELECT * FROM Customers WHERE status = 'Active'", + "SELECT * is not allowed. Specify column names instead.", + ), + ( + "SELECT Customers.* FROM Customers WHERE status = 'Active'", + "SELECT * is not allowed. Specify column names instead.", ), ( "SELECT id, name, email, phone, address FROM Customers LIMIT 10", diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 380597fbf..5a0d80299 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.31" +version = "0.1.32" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index c1419b45b..d725f853b 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.31" +version = "0.1.32" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From ff4b67d3912e47369dd0938f05cc2ff5ddba5edf Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Tue, 21 Apr 2026 10:53:17 +0200 Subject: [PATCH 023/191] docs: explain auto-generated .mermaid files (#1506) (#1579) --- packages/uipath/docs/cli/index.md | 6 +++++ packages/uipath/pyproject.toml | 2 +- packages/uipath/src/uipath/_cli/cli_init.py | 7 ++++++ packages/uipath/tests/cli/test_init.py | 26 +++++++++++++++++++++ packages/uipath/uv.lock | 2 +- 5 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/uipath/docs/cli/index.md b/packages/uipath/docs/cli/index.md index deb875353..336040742 100644 --- a/packages/uipath/docs/cli/index.md +++ b/packages/uipath/docs/cli/index.md @@ -82,6 +82,12 @@ Running `uipath init` will process these function definitions and create the cor ✓ Created '.uipath/studio_metadata.json' file. ✓ Created: CLAUDE.md, CLI_REFERENCE.md, SDK_REFERENCE.md, AGENTS.md, REQUIRED_STRUCTURE.md. ``` + +/// info +### About the `.mermaid` files + +`uipath init` generates one `.mermaid` file per function/agent containing a static call graph, rendered in the UiPath Orchestrator UI. These files are regenerated on every `uipath init`. +/// --- ::: mkdocs-click diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 626941f70..cba365f57 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.49" +version = "2.10.50" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/cli_init.py b/packages/uipath/src/uipath/_cli/cli_init.py index f86e30f06..80396d8ff 100644 --- a/packages/uipath/src/uipath/_cli/cli_init.py +++ b/packages/uipath/src/uipath/_cli/cli_init.py @@ -277,6 +277,12 @@ def write_studio_metadata_file(directory: str) -> None: ) +MERMAID_FILE_HEADER = ( + "%% AUTO-GENERATED by `uipath init`. Do not edit manually.\n" + "%% Regenerated on every `uipath init`.\n" +) + + def write_mermaid_files(entry_points: list[UiPathRuntimeSchema]) -> list[Path]: """Write mermaid diagram files for each entrypoint. @@ -299,6 +305,7 @@ def write_mermaid_files(entry_points: list[UiPathRuntimeSchema]) -> list[Path]: mermaid_file_path = Path(os.getcwd()) / f"{ep.file_path}.mermaid" with open(mermaid_file_path, "w") as f: + f.write(MERMAID_FILE_HEADER) f.write(str(chart)) mermaid_paths.append(mermaid_file_path) diff --git a/packages/uipath/tests/cli/test_init.py b/packages/uipath/tests/cli/test_init.py index fa5b47ab2..59d4eaaa8 100644 --- a/packages/uipath/tests/cli/test_init.py +++ b/packages/uipath/tests/cli/test_init.py @@ -659,3 +659,29 @@ def test_init_does_not_overwrite_existing_studio_metadata( metadata = json.load(f) assert metadata["schemaVersion"] == 99 assert metadata["codeVersion"] == "5.0.0" + + +class TestWriteMermaidFiles: + def test_mermaid_file_starts_with_header_comment( + self, runner: CliRunner, temp_dir: str + ) -> None: + """Generated .mermaid files begin with the clarifying header comment.""" + from uipath._cli.cli_init import MERMAID_FILE_HEADER, write_mermaid_files + from uipath.runtime.schema import UiPathRuntimeGraph, UiPathRuntimeSchema + + ep = UiPathRuntimeSchema( + filePath="main.py", + uniqueId="main", + type="function", + input={}, + output={}, + graph=UiPathRuntimeGraph(), + ) + + with runner.isolated_filesystem(temp_dir=temp_dir): + paths = write_mermaid_files([ep]) + assert len(paths) == 1 + contents = paths[0].read_text() + assert contents.startswith(MERMAID_FILE_HEADER) + assert "AUTO-GENERATED" in contents + assert "uipath init" in contents diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index d725f853b..aed4bcb4a 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.49" +version = "2.10.50" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From dbf6bffd690e1d713cb7e80ae8ea4f3b9fff5b47 Mon Sep 17 00:00:00 2001 From: Josh Park <50765702+JoshParkSJ@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:37:14 -0400 Subject: [PATCH 024/191] feat(voice): voice streamed tool calls for improved latency (#1559) Co-authored-by: Claude Opus 4.7 (1M context) --- .../scripts/test_check_version_uniqueness.py | 1 - packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/chat/__init__.py | 9 + .../uipath-core/src/uipath/core/chat/voice.py | 30 ++ packages/uipath-core/uv.lock | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 2 +- .../src/uipath/_cli/_chat/_voice_bridge.py | 257 ++++++++++++++++++ .../tests/cli/chat/test_voice_bridge.py | 137 ++++++++++ packages/uipath/uv.lock | 4 +- 10 files changed, 439 insertions(+), 7 deletions(-) create mode 100644 packages/uipath-core/src/uipath/core/chat/voice.py create mode 100644 packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py create mode 100644 packages/uipath/tests/cli/chat/test_voice_bridge.py diff --git a/.github/scripts/test_check_version_uniqueness.py b/.github/scripts/test_check_version_uniqueness.py index 94a2b3cc0..af4298af1 100644 --- a/.github/scripts/test_check_version_uniqueness.py +++ b/.github/scripts/test_check_version_uniqueness.py @@ -5,7 +5,6 @@ import urllib.error from unittest import mock -import pytest from check_version_uniqueness import ( get_package_info, diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 1a5c34ab7..387818039 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.11" +version = "0.5.12" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/chat/__init__.py b/packages/uipath-core/src/uipath/core/chat/__init__.py index 476cb9352..c2e9f025b 100644 --- a/packages/uipath-core/src/uipath/core/chat/__init__.py +++ b/packages/uipath-core/src/uipath/core/chat/__init__.py @@ -114,6 +114,11 @@ UiPathConversationToolCallResult, UiPathConversationToolCallStartEvent, ) +from .voice import ( + UiPathVoiceToolCallMessage, + UiPathVoiceToolCallRequest, + UiPathVoiceToolCallResult, +) __all__ = [ # Root @@ -189,4 +194,8 @@ "UiPathConversationAsyncInputStreamEvent", # Meta "UiPathConversationMetaEvent", + # Voice + "UiPathVoiceToolCallRequest", + "UiPathVoiceToolCallMessage", + "UiPathVoiceToolCallResult", ] diff --git a/packages/uipath-core/src/uipath/core/chat/voice.py b/packages/uipath-core/src/uipath/core/chat/voice.py new file mode 100644 index 000000000..7b1adf7e8 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/chat/voice.py @@ -0,0 +1,30 @@ +"""Voice tool-call wire models (CAS socket.io).""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class _VoiceWire(BaseModel): + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathVoiceToolCallRequest(_VoiceWire): + """Single tool call in a batch.""" + + call_id: str = Field(..., alias="callId") + tool_name: str = Field(..., alias="toolName") + args: dict[str, Any] + + +class UiPathVoiceToolCallMessage(_VoiceWire): + """Batch of tool calls from CAS.""" + + calls: list[UiPathVoiceToolCallRequest] = Field(..., min_length=1) + + +class UiPathVoiceToolCallResult(_VoiceWire): + """Result of a single tool call.""" + + result: str + is_error: bool = Field(..., alias="isError") diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index e17802fc7..807653ed8 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -1007,7 +1007,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.11" +version = "0.5.12" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 5a0d80299..93c33a7d7 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1056,7 +1056,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.11" +version = "0.5.12" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index cba365f57..ee647db03 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.50" +version = "2.10.51" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py new file mode 100644 index 000000000..6164b9f3d --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py @@ -0,0 +1,257 @@ +"""Voice tool-call session — persistent socket.io connection to CAS.""" + +from __future__ import annotations + +import asyncio +import logging +import os +from collections.abc import Awaitable, Callable +from enum import Enum +from typing import Any +from urllib.parse import urlparse + +from pydantic import ValidationError + +from uipath.core.chat import ( + UiPathVoiceToolCallMessage, + UiPathVoiceToolCallRequest, + UiPathVoiceToolCallResult, +) +from uipath.runtime.context import UiPathRuntimeContext + +logger = logging.getLogger(__name__) + + +_ATTEMPT_CAS_SOCKET_CONNECTION_TIMEOUT_SECONDS = 15.0 +_INFLIGHT_TOOL_DRAIN_AFTER_AGENT_END_TIMEOUT_SECONDS = 30.0 + + +class VoiceToolCallSessionError(RuntimeError): + pass + + +class VoiceSessionEndReason(str, Enum): + COMPLETED = "completed" + DISCONNECTED = "disconnected" + READY_EMIT_FAILED = "ready_emit_failed" + + +class VoiceEvent(str, Enum): + """CAS voice-session protocol events (excludes socket.io lifecycle).""" + + TOOL_CALL = "voice_tool_call" # received + SESSION_ENDED = "voice_session_ended" # received + TOOLS_READY = "voice_tools_ready" # sent + TOOL_RESULT = "voice_tool_result" # sent + + +ToolHandler = Callable[ + [UiPathVoiceToolCallRequest], Awaitable[UiPathVoiceToolCallResult] +] + + +class VoiceToolCallSession: + """Socket.io session with CAS for tool-call traffic. + + Receives `voice_tool_call` batches, emits one `voice_tool_result` per + `callId`, exits on `voice_session_ended` or disconnect. CAS pulls + agent config from Orchestrator directly; this session carries only + tool calls. + """ + + def __init__( + self, + url: str, + socketio_path: str, + headers: dict[str, str], + tool_handler: ToolHandler, + ) -> None: + self._url = url + self._socketio_path = socketio_path + self._headers = headers + self._tool_handler = tool_handler + self._client: Any = None + self._done = asyncio.Event() + self._in_flight: set[asyncio.Task[None]] = set() + self._end_reason: VoiceSessionEndReason | None = None + + async def run(self) -> VoiceSessionEndReason: + """Connect, dispatch tool calls until session ends, then disconnect. + + Raises: + VoiceToolCallSessionError: If connecting to CAS fails. + """ + from socketio import AsyncClient # type: ignore[import-untyped] + + self._client = AsyncClient(logger=False, engineio_logger=False) + self._client.on("connect", self._handle_connect) + self._client.on("disconnect", self._handle_disconnect) + self._client.on(VoiceEvent.TOOL_CALL, self._handle_tool_call) + self._client.on(VoiceEvent.SESSION_ENDED, self._handle_session_ended) + + try: + await asyncio.wait_for( + self._client.connect( + url=self._url, + socketio_path=self._socketio_path, + headers=self._headers, + transports=["websocket"], + ), + timeout=_ATTEMPT_CAS_SOCKET_CONNECTION_TIMEOUT_SECONDS, + ) + except Exception as exc: + await self._safe_disconnect("after connect-failure") + raise VoiceToolCallSessionError( + f"Failed to connect to CAS voice endpoint: {exc}" + ) from exc + + try: + await self._done.wait() + await self._drain_in_flight() + finally: + await self._safe_disconnect("on shutdown") + + return self._end_reason or VoiceSessionEndReason.DISCONNECTED + + async def _safe_disconnect(self, when: str) -> None: + try: + await self._client.disconnect() + except Exception as exc: + logger.debug("[Voice] disconnect %s raised: %s", when, exc) + + def _end_session(self, reason: VoiceSessionEndReason) -> None: + # First writer wins: a late disconnect must not overwrite COMPLETED. + if self._end_reason is None: + self._end_reason = reason + self._done.set() + + async def _drain_in_flight(self) -> None: + """Wait for in-flight tool tasks to finish, capped by the drain timeout.""" + if not self._in_flight: + return + logger.info( + "[Voice] Session ended with %d in-flight tool task(s); draining (max %.0fs)", + len(self._in_flight), + _INFLIGHT_TOOL_DRAIN_AFTER_AGENT_END_TIMEOUT_SECONDS, + ) + try: + await asyncio.wait_for( + asyncio.gather(*self._in_flight, return_exceptions=True), + timeout=_INFLIGHT_TOOL_DRAIN_AFTER_AGENT_END_TIMEOUT_SECONDS, + ) + except asyncio.TimeoutError: + unfinished = sum(1 for t in self._in_flight if not t.done()) + logger.warning( + "[Voice] %d tool task(s) did not complete within %.0fs of session end", + unfinished, + _INFLIGHT_TOOL_DRAIN_AFTER_AGENT_END_TIMEOUT_SECONDS, + ) + + async def _handle_connect(self) -> None: + logger.info("[Voice] Socket.io connected to CAS") + try: + await self._client.emit(VoiceEvent.TOOLS_READY, {}) + except Exception as exc: + # CAS gates tool dispatch on this event; without it the session is dead. + logger.warning("[Voice] emit voice_tools_ready failed: %s", exc) + self._end_session(VoiceSessionEndReason.READY_EMIT_FAILED) + + async def _handle_disconnect(self) -> None: + logger.info("[Voice] Socket.io disconnected from CAS") + self._end_session(VoiceSessionEndReason.DISCONNECTED) + + async def _handle_tool_call(self, data: dict[str, Any], *_: Any) -> None: + """Spawn a task per call and return — the reader must stay free for `voice_session_ended`.""" + if self._done.is_set(): + return + + try: + message = UiPathVoiceToolCallMessage.model_validate(data) + except ValidationError as exc: + logger.warning("[Voice] invalid voice_tool_call payload: %s", exc) + return + + for call in message.calls: + task = asyncio.create_task(self._execute_tool_call(call)) + self._in_flight.add(task) + task.add_done_callback(self._in_flight.discard) + + async def _execute_tool_call(self, call: UiPathVoiceToolCallRequest) -> None: + """Run one tool call and emit its `voice_tool_result`.""" + logger.info( + "[Voice] voice_tool_call dispatched: %s (%s) args=%s", + call.tool_name, + call.call_id, + call.args, + ) + try: + tool_result = await self._tool_handler(call) + except Exception as exc: + logger.exception("[Voice] Tool call execution failed: %s", call.tool_name) + tool_result = UiPathVoiceToolCallResult(result=str(exc), is_error=True) + + try: + await self._client.emit( + VoiceEvent.TOOL_RESULT, + {"callId": call.call_id, **tool_result.model_dump(by_alias=True)}, + ) + except Exception as exc: + logger.debug( + "[Voice] emit voice_tool_result failed for %s: %s", call.call_id, exc + ) + return + logger.info( + "[Voice] voice_tool_result sent: %s (isError=%s)", + call.call_id, + tool_result.is_error, + ) + + async def _handle_session_ended(self, _data: Any, *_: Any) -> None: + logger.info("[Voice] voice_session_ended received") + self._end_session(VoiceSessionEndReason.COMPLETED) + + +def get_voice_bridge( + context: UiPathRuntimeContext, + tool_handler: ToolHandler, +) -> VoiceToolCallSession: + """Factory for a CAS voice tool-call session. + + Raises: + RuntimeError: If UIPATH_URL is not set or invalid. + """ + assert context.conversation_id is not None, "conversation_id must be set in context" + + if cas_host := os.environ.get("CAS_WEBSOCKET_HOST"): + url = f"ws://{cas_host}?conversationId={context.conversation_id}" + socketio_path = "/socket.io" + logger.warning( + f"CAS_WEBSOCKET_HOST is set. Using websocket_url '{url}{socketio_path}'." + ) + else: + base_url = os.environ.get("UIPATH_URL") + if not base_url: + raise RuntimeError( + "UIPATH_URL environment variable required for conversational mode" + ) + parsed = urlparse(base_url) + if not parsed.netloc: + raise RuntimeError(f"Invalid UIPATH_URL format: {base_url}") + url = f"wss://{parsed.netloc}?conversationId={context.conversation_id}" + socketio_path = "autopilotforeveryone_/websocket_/socket.io" + + headers = { + "Authorization": f"Bearer {os.environ.get('UIPATH_ACCESS_TOKEN', '')}", + "X-UiPath-Internal-TenantId": context.tenant_id + or os.environ.get("UIPATH_TENANT_ID", ""), + "X-UiPath-Internal-AccountId": context.org_id + or os.environ.get("UIPATH_ORGANIZATION_ID", ""), + "X-UiPath-ConversationId": context.conversation_id, + } + + return VoiceToolCallSession( + url=url, + socketio_path=socketio_path, + headers=headers, + tool_handler=tool_handler, + ) diff --git a/packages/uipath/tests/cli/chat/test_voice_bridge.py b/packages/uipath/tests/cli/chat/test_voice_bridge.py new file mode 100644 index 000000000..b945fb6e3 --- /dev/null +++ b/packages/uipath/tests/cli/chat/test_voice_bridge.py @@ -0,0 +1,137 @@ +"""Tests for VoiceToolCallSession and get_voice_bridge.""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from uipath._cli._chat._voice_bridge import ( + VoiceSessionEndReason, + VoiceToolCallSession, + get_voice_bridge, +) +from uipath.core.chat import ( + UiPathVoiceToolCallRequest, + UiPathVoiceToolCallResult, +) + + +def _make_session(tool_handler: Any = None) -> VoiceToolCallSession: + session = VoiceToolCallSession( + url="wss://example/test", + socketio_path="/socket.io", + headers={}, + tool_handler=tool_handler or AsyncMock(), + ) + session._client = MagicMock() + session._client.emit = AsyncMock() + return session + + +class TestEndSession: + def test_first_writer_wins(self) -> None: + """A late DISCONNECTED must not overwrite COMPLETED.""" + session = _make_session() + session._end_session(VoiceSessionEndReason.COMPLETED) + session._end_session(VoiceSessionEndReason.DISCONNECTED) + assert session._end_reason == VoiceSessionEndReason.COMPLETED + assert session._done.is_set() + + async def test_session_ended_sets_completed(self) -> None: + session = _make_session() + await session._handle_session_ended(None) + assert session._end_reason == VoiceSessionEndReason.COMPLETED + + async def test_disconnect_sets_disconnected(self) -> None: + session = _make_session() + await session._handle_disconnect() + assert session._end_reason == VoiceSessionEndReason.DISCONNECTED + + +class TestHandleToolCall: + async def test_dispatches_handler_and_emits_result(self) -> None: + handler = AsyncMock( + return_value=UiPathVoiceToolCallResult(result="ok", is_error=False) + ) + session = _make_session(handler) + + await session._handle_tool_call( + {"calls": [{"callId": "c1", "toolName": "weather", "args": {"city": "SF"}}]} + ) + # Drain the spawned task. + for task in list(session._in_flight): + await task + + handler.assert_awaited_once() + assert handler.await_args is not None + call_arg = handler.await_args.args[0] + assert isinstance(call_arg, UiPathVoiceToolCallRequest) + assert call_arg.call_id == "c1" + assert call_arg.tool_name == "weather" + + session._client.emit.assert_awaited_once_with( + "voice_tool_result", + {"callId": "c1", "result": "ok", "isError": False}, + ) + + async def test_invalid_payload_is_skipped(self) -> None: + handler = AsyncMock() + session = _make_session(handler) + + await session._handle_tool_call({"calls": []}) # min_length=1 violation + + handler.assert_not_awaited() + session._client.emit.assert_not_awaited() + + async def test_noop_after_session_ended(self) -> None: + handler = AsyncMock() + session = _make_session(handler) + session._done.set() + + await session._handle_tool_call( + {"calls": [{"callId": "c1", "toolName": "x", "args": {}}]} + ) + + handler.assert_not_awaited() + assert not session._in_flight + + async def test_handler_exception_emits_error_result(self) -> None: + handler = AsyncMock(side_effect=RuntimeError("boom")) + session = _make_session(handler) + + await session._handle_tool_call( + {"calls": [{"callId": "c1", "toolName": "x", "args": {}}]} + ) + for task in list(session._in_flight): + await task + + session._client.emit.assert_awaited_once_with( + "voice_tool_result", + {"callId": "c1", "result": "boom", "isError": True}, + ) + + +class TestGetVoiceBridge: + def test_raises_when_uipath_url_missing( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("UIPATH_URL", raising=False) + monkeypatch.delenv("CAS_WEBSOCKET_HOST", raising=False) + ctx = MagicMock(conversation_id="conv-1", tenant_id="t", org_id="o") + + with pytest.raises(RuntimeError, match="UIPATH_URL"): + get_voice_bridge(ctx, AsyncMock()) + + def test_headers_fall_back_to_env_when_context_ids_are_none( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Regression: f"{None}" is truthy ("None"), so the `or` fallback was dead.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + monkeypatch.setenv("UIPATH_TENANT_ID", "env-tenant") + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "env-org") + ctx = MagicMock(conversation_id="conv-1", tenant_id=None, org_id=None) + + bridge = get_voice_bridge(ctx, AsyncMock()) + + assert bridge._headers["X-UiPath-Internal-TenantId"] == "env-tenant" + assert bridge._headers["X-UiPath-Internal-AccountId"] == "env-org" diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index aed4bcb4a..6605f470f 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.50" +version = "2.10.51" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2650,7 +2650,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.11" +version = "0.5.12" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, From 58adbab126524a449918c46ee678c67830b313dc Mon Sep 17 00:00:00 2001 From: Popescu Tudor-Cristian <94108303+PopescuTudor@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:35:11 +0300 Subject: [PATCH 025/191] fix: accept remoteA2aAgent in GenericResourceOverwrite (#1581) Co-authored-by: Claude Opus 4.7 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/common/_bindings.py | 9 ++++++- packages/uipath-platform/uv.lock | 2 +- packages/uipath/tests/sdk/test_bindings.py | 25 +++++++++++++++++++ packages/uipath/uv.lock | 2 +- 5 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 3616306cd..758ab3d5f 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.32" +version = "0.1.33" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/common/_bindings.py b/packages/uipath-platform/src/uipath/platform/common/_bindings.py index 321b83c4f..224cae425 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_bindings.py +++ b/packages/uipath-platform/src/uipath/platform/common/_bindings.py @@ -52,7 +52,14 @@ def folder_identifier(self) -> str: class GenericResourceOverwrite(ResourceOverwrite): resource_type: Literal[ - "process", "index", "app", "asset", "bucket", "mcpServer", "queue" + "process", + "index", + "app", + "asset", + "bucket", + "mcpServer", + "queue", + "remoteA2aAgent", ] name: str = Field(alias="name") folder_path: str = Field(alias="folderPath") diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 93c33a7d7..815defe05 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.32" +version = "0.1.33" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/tests/sdk/test_bindings.py b/packages/uipath/tests/sdk/test_bindings.py index d9afd8235..1d51d7413 100644 --- a/packages/uipath/tests/sdk/test_bindings.py +++ b/packages/uipath/tests/sdk/test_bindings.py @@ -391,3 +391,28 @@ def test_parse_connection_with_capitalized_alias(self): assert isinstance(overwrite, ConnectionResourceOverwrite) assert overwrite.connection_id == "conn-456" assert overwrite.folder_key == "folder2" + + +class TestRemoteA2aAgentResourceOverwrite: + """Test that Remote A2A agent resources parse as GenericResourceOverwrite.""" + + def test_remote_a2a_agent_resource_overwrite(self): + overwrite = GenericResourceOverwrite( + resource_type="remoteA2aAgent", + name="basica2a", + folder_path="Customers/ProjectA", + ) + assert overwrite.resource_type == "remoteA2aAgent" + assert overwrite.resource_identifier == "basica2a" + assert overwrite.folder_identifier == "Customers/ProjectA" + + def test_parse_remote_a2a_agent(self): + """Parser accepts a remoteA2aAgent-keyed overwrite without discriminator error.""" + overwrite = ResourceOverwriteParser.parse( + key="remoteA2aAgent.basica2a.solution_folder", + value={"name": "basica2a", "folderPath": "Customers/ProjectA"}, + ) + assert isinstance(overwrite, GenericResourceOverwrite) + assert overwrite.resource_type == "remoteA2aAgent" + assert overwrite.resource_identifier == "basica2a" + assert overwrite.folder_identifier == "Customers/ProjectA" diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 6605f470f..bb7432220 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.32" +version = "0.1.33" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 59d8ca40ffa59e2a9b0975ed62b3809e81bc1988 Mon Sep 17 00:00:00 2001 From: yashwagle1 Date: Tue, 21 Apr 2026 16:50:25 -0700 Subject: [PATCH 026/191] Integrate with AutomationOps and SemanticProxy (#1578) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/_uipath.py | 10 + .../platform/automation_ops/__init__.py | 9 + .../automation_ops/_automation_ops_service.py | 64 +++++ .../platform/semantic_proxy/__init__.py | 36 +++ .../semantic_proxy/_semantic_proxy_service.py | 74 +++++ .../platform/semantic_proxy/pii_utilities.py | 115 ++++++++ .../platform/semantic_proxy/semantic_proxy.py | 91 ++++++ .../services/test_automation_ops_service.py | 159 +++++++++++ .../tests/services/test_pii_utilities.py | 170 +++++++++++ .../services/test_semantic_proxy_service.py | 264 ++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 13 files changed, 995 insertions(+), 3 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/automation_ops/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/automation_ops/_automation_ops_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/semantic_proxy/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/semantic_proxy/_semantic_proxy_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/semantic_proxy/pii_utilities.py create mode 100644 packages/uipath-platform/src/uipath/platform/semantic_proxy/semantic_proxy.py create mode 100644 packages/uipath-platform/tests/services/test_automation_ops_service.py create mode 100644 packages/uipath-platform/tests/services/test_pii_utilities.py create mode 100644 packages/uipath-platform/tests/services/test_semantic_proxy_service.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 758ab3d5f..cfc151484 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.33" +version = "0.1.34" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index 28c7811c1..f81333862 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -8,6 +8,7 @@ from .action_center import TasksService from .agenthub._agenthub_service import AgentHubService from .agenthub._remote_a2a_service import RemoteA2aService +from .automation_ops import AutomationOpsService from .chat import ConversationsService, UiPathLlmChatService, UiPathOpenAIService from .common import ( ApiClient, @@ -35,6 +36,7 @@ QueuesService, ) from .resource_catalog import ResourceCatalogService +from .semantic_proxy import SemanticProxyService def _has_valid_client_credentials( @@ -178,6 +180,14 @@ def remote_a2a(self) -> RemoteA2aService: def orchestrator_setup(self) -> OrchestratorSetupService: return OrchestratorSetupService(self._config, self._execution_context) + @property + def automation_ops(self) -> AutomationOpsService: + return AutomationOpsService(self._config, self._execution_context) + + @property + def semantic_proxy(self) -> SemanticProxyService: + return SemanticProxyService(self._config, self._execution_context) + @property def automation_tracker(self) -> AutomationTrackerService: return AutomationTrackerService(self._config, self._execution_context) diff --git a/packages/uipath-platform/src/uipath/platform/automation_ops/__init__.py b/packages/uipath-platform/src/uipath/platform/automation_ops/__init__.py new file mode 100644 index 000000000..87ce420ec --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/automation_ops/__init__.py @@ -0,0 +1,9 @@ +"""AutomationOps service package. + +Provides the ``AutomationOpsService`` client for retrieving deployed AI Trust +Layer policies from AgentHub. +""" + +from ._automation_ops_service import AutomationOpsService + +__all__ = ["AutomationOpsService"] diff --git a/packages/uipath-platform/src/uipath/platform/automation_ops/_automation_ops_service.py b/packages/uipath-platform/src/uipath/platform/automation_ops/_automation_ops_service.py new file mode 100644 index 000000000..05c37bbb5 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/automation_ops/_automation_ops_service.py @@ -0,0 +1,64 @@ +"""AutomationOps service for UiPath Platform. + +Provides methods for retrieving deployed policies from the AgentHub service. +""" + +from typing import Any + +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._models import Endpoint, RequestSpec + +_DEPLOYED_POLICY_ENDPOINT = Endpoint("agenthub_/api/policies/deployed-policy") + + +class AutomationOpsService(BaseService): + """Service for interacting with UiPath AutomationOps policies via AgentHub.""" + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + super().__init__(config=config, execution_context=execution_context) + + @traced(name="automation_ops_get_deployed_policy", run_type="uipath") + def get_deployed_policy(self) -> dict[str, Any]: + """Retrieve the deployed policy. + + Returns: + The deployed policy response as a dictionary. + """ + spec = self._deployed_policy_spec() + response = self.request( + spec.method, + url=spec.endpoint, + headers=spec.headers, + scoped="tenant", + ) + return response.json() + + @traced(name="automation_ops_get_deployed_policy", run_type="uipath") + async def get_deployed_policy_async(self) -> dict[str, Any]: + """Retrieve the deployed policy (async). + + Returns: + The deployed policy response as a dictionary. + """ + spec = self._deployed_policy_spec() + response = await self.request_async( + spec.method, + url=spec.endpoint, + headers=spec.headers, + scoped="tenant", + ) + return response.json() + + def _deployed_policy_spec(self) -> RequestSpec: + return RequestSpec( + method="POST", + endpoint=_DEPLOYED_POLICY_ENDPOINT, + ) diff --git a/packages/uipath-platform/src/uipath/platform/semantic_proxy/__init__.py b/packages/uipath-platform/src/uipath/platform/semantic_proxy/__init__.py new file mode 100644 index 000000000..e17867ac7 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/semantic_proxy/__init__.py @@ -0,0 +1,36 @@ +"""SemanticProxy service package. + +Provides the ``SemanticProxyService`` client, Pydantic request/response models for +the PII detection endpoint, and utilities for rehydrating masked text with +original PII values after LLM processing. +""" + +from ._semantic_proxy_service import SemanticProxyService +from .pii_utilities import ( + rehydrate_from_pii_entities, + rehydrate_from_pii_response, +) +from .semantic_proxy import ( + PiiDetectionRequest, + PiiDetectionResponse, + PiiDocument, + PiiDocumentResult, + PiiEntity, + PiiEntityThreshold, + PiiFile, + PiiFileResult, +) + +__all__ = [ + "PiiDetectionRequest", + "PiiDetectionResponse", + "PiiDocument", + "PiiDocumentResult", + "PiiEntity", + "PiiEntityThreshold", + "PiiFile", + "PiiFileResult", + "SemanticProxyService", + "rehydrate_from_pii_entities", + "rehydrate_from_pii_response", +] diff --git a/packages/uipath-platform/src/uipath/platform/semantic_proxy/_semantic_proxy_service.py b/packages/uipath-platform/src/uipath/platform/semantic_proxy/_semantic_proxy_service.py new file mode 100644 index 000000000..a68d7c25c --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/semantic_proxy/_semantic_proxy_service.py @@ -0,0 +1,74 @@ +"""SemanticProxy service for UiPath Platform. + +Provides methods for interacting with the SemanticProxy service (e.g. PII detection). +""" + +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._models import Endpoint, RequestSpec +from .semantic_proxy import PiiDetectionRequest, PiiDetectionResponse + +_PII_DETECTION_ENDPOINT = Endpoint("semanticproxy_/api/pii-detection") + + +class SemanticProxyService(BaseService): + """Service for interacting with UiPath SemanticProxy.""" + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + super().__init__(config=config, execution_context=execution_context) + + @traced(name="semantic_proxy_detect_pii", run_type="uipath") + def detect_pii(self, request: PiiDetectionRequest) -> PiiDetectionResponse: + """Detect PII in the provided documents and/or files. + + Args: + request: The PII detection request payload. + + Returns: + The PII detection response. + """ + spec = self._pii_detection_spec(request) + response = self.request( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + scoped="tenant", + ) + return PiiDetectionResponse.model_validate(response.json()) + + @traced(name="semantic_proxy_detect_pii", run_type="uipath") + async def detect_pii_async( + self, request: PiiDetectionRequest + ) -> PiiDetectionResponse: + """Detect PII in the provided documents and/or files (async). + + Args: + request: The PII detection request payload. + + Returns: + The PII detection response. + """ + spec = self._pii_detection_spec(request) + response = await self.request_async( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + scoped="tenant", + ) + return PiiDetectionResponse.model_validate(response.json()) + + def _pii_detection_spec(self, request: PiiDetectionRequest) -> RequestSpec: + return RequestSpec( + method="POST", + endpoint=_PII_DETECTION_ENDPOINT, + json=request.model_dump(by_alias=True, exclude_none=True), + ) diff --git a/packages/uipath-platform/src/uipath/platform/semantic_proxy/pii_utilities.py b/packages/uipath-platform/src/uipath/platform/semantic_proxy/pii_utilities.py new file mode 100644 index 000000000..c480a5fb7 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/semantic_proxy/pii_utilities.py @@ -0,0 +1,115 @@ +"""Utility methods for working with PII data. + +Python port of UiPath.SemanticProxy.Client.PiiUtilities (C#). +""" + +import json +import re +from typing import Callable, Iterable + +from .semantic_proxy import PiiDetectionResponse, PiiEntity + + +def rehydrate_from_pii_entities( + masked_text: str, pii_entities: Iterable[PiiEntity] +) -> str: + """Rehydrate masked text by replacing PII placeholders with original values. + + Placeholders (e.g. ``[Person-1]``) are matched case-insensitively and replaced + with the corresponding original PII text. The function also replaces variants + without the surrounding brackets (e.g. ``Person-1``) in case the LLM stripped + them in its output. + + Args: + masked_text: The masked text with PII placeholders. + pii_entities: The PII entities containing the original values. + + Returns: + The rehydrated text with original PII values. + """ + if not masked_text: + return masked_text + + entities = [e for e in pii_entities if e.replacement_text] + if not entities: + return masked_text + + # Sort by replacement text length descending to avoid substring collisions + # (e.g. "[Person-10]" must be replaced before "[Person-1]"). + entities.sort(key=lambda e: len(e.replacement_text), reverse=True) + + rehydrated = masked_text + for entity in entities: + if not entity.replacement_text or not entity.pii_text: + continue + escaped_pii = _add_escape_characters(entity.pii_text) + # Replace the full placeholder (with brackets) case-insensitively. + # ``_literal_replacer`` bypasses regex backreference interpretation in the + # replacement string. + rehydrated = re.sub( + re.escape(entity.replacement_text), + _literal_replacer(escaped_pii), + rehydrated, + flags=re.IGNORECASE, + ) + # Also replace the content without brackets (in case the LLM dropped them). + if entity.replacement_text.startswith("[") and entity.replacement_text.endswith( + "]" + ): + no_brackets = entity.replacement_text[1:-1] + rehydrated = re.sub( + re.escape(no_brackets), + _literal_replacer(escaped_pii), + rehydrated, + flags=re.IGNORECASE, + ) + + return rehydrated + + +def _literal_replacer(replacement: str) -> Callable[[re.Match[str]], str]: + """Return a replacement function that ignores regex backreference syntax.""" + + def replace(_match: re.Match[str]) -> str: + return replacement + + return replace + + +def rehydrate_from_pii_response( + masked_text: str, response: PiiDetectionResponse +) -> str: + """Rehydrate masked text using all PII entities from a detection response. + + Merges entities from both ``response.response`` (detected in documents/prompts) + and ``response.files`` (detected in files), so placeholders originating from + either source are rehydrated. + + Args: + masked_text: The masked text with PII placeholders. + response: The PII detection response containing entities to rehydrate. + + Returns: + The rehydrated text with original PII values. + """ + entities: list[PiiEntity] = [] + for doc in response.response: + entities.extend(doc.pii_entities) + for file in response.files: + entities.extend(file.pii_entities) + return rehydrate_from_pii_entities(masked_text, entities) + + +def _add_escape_characters(text: str) -> str: + """Escape special characters in text using JSON serialization. + + Mirrors C# ``AddEscapeCharacters`` — serializes as JSON then strips the + surrounding quotes to get the escaped content. + """ + if not text: + return "" + try: + serialized = json.dumps(text, ensure_ascii=False) + return serialized[1:-1] + except (TypeError, ValueError): + return text diff --git a/packages/uipath-platform/src/uipath/platform/semantic_proxy/semantic_proxy.py b/packages/uipath-platform/src/uipath/platform/semantic_proxy/semantic_proxy.py new file mode 100644 index 000000000..2be35e975 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/semantic_proxy/semantic_proxy.py @@ -0,0 +1,91 @@ +"""Public Pydantic models for the SemanticProxy service.""" + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class PiiDocument(BaseModel): + """A text document to scan for PII.""" + + id: str + role: str + document: str + + +class PiiFile(BaseModel): + """A file reference to scan for PII.""" + + model_config = ConfigDict(populate_by_name=True) + + file_name: str = Field(alias="fileName") + file_url: str = Field(alias="fileUrl") + file_type: str = Field(alias="fileType") + + +class PiiEntityThreshold(BaseModel): + """Per-entity confidence threshold override.""" + + model_config = ConfigDict(populate_by_name=True) + + category: str = Field(alias="pii-entity-category") + confidence_threshold: float = Field(alias="pii-entity-confidence-threshold") + + +class PiiDetectionRequest(BaseModel): + """Request payload for the PII detection endpoint.""" + + model_config = ConfigDict(populate_by_name=True) + + documents: Optional[list[PiiDocument]] = None + files: Optional[list[PiiFile]] = None + language_code: Optional[str] = Field(default=None, alias="languageCode") + confidence_threshold: Optional[float] = Field( + default=None, alias="confidenceThreshold" + ) + entity_thresholds: Optional[list[PiiEntityThreshold]] = Field( + default=None, alias="entityThresholds" + ) + + +class PiiEntity(BaseModel): + """A single detected PII entity.""" + + model_config = ConfigDict(populate_by_name=True) + + pii_text: str = Field(alias="piiText") + replacement_text: str = Field(alias="replacementText") + pii_type: str = Field(alias="piiType") + offset: int + confidence_score: float = Field(alias="confidenceScore") + + +class PiiDocumentResult(BaseModel): + """PII detection result for a single document.""" + + model_config = ConfigDict(populate_by_name=True) + + id: str + role: str + masked_document: str = Field(alias="maskedDocument") + initial_document: str = Field(alias="initialDocument") + pii_entities: list[PiiEntity] = Field(default_factory=list, alias="piiEntities") + + +class PiiFileResult(BaseModel): + """PII detection result for a single file (fileUrl is the redacted URL).""" + + model_config = ConfigDict(populate_by_name=True) + + file_name: str = Field(alias="fileName") + file_url: str = Field(alias="fileUrl") + pii_entities: list[PiiEntity] = Field(default_factory=list, alias="piiEntities") + + +class PiiDetectionResponse(BaseModel): + """Response payload from the PII detection endpoint.""" + + model_config = ConfigDict(populate_by_name=True) + + response: list[PiiDocumentResult] = Field(default_factory=list) + files: list[PiiFileResult] = Field(default_factory=list) diff --git a/packages/uipath-platform/tests/services/test_automation_ops_service.py b/packages/uipath-platform/tests/services/test_automation_ops_service.py new file mode 100644 index 000000000..c90e1f80a --- /dev/null +++ b/packages/uipath-platform/tests/services/test_automation_ops_service.py @@ -0,0 +1,159 @@ +"""Tests for AutomationOpsService.""" + +import json + +import httpx +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.automation_ops import AutomationOpsService + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, +) -> AutomationOpsService: + return AutomationOpsService(config=config, execution_context=execution_context) + + +class TestAutomationOpsService: + """Test AutomationOpsService functionality.""" + + class TestGetDeployedPolicy: + """Test get_deployed_policy (sync).""" + + def test_returns_policy_dict( + self, + httpx_mock: HTTPXMock, + service: AutomationOpsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + expected_policy = { + "policy-name": "AITL Policy", + "data": { + "container": {"pii-in-flight-agents": True}, + "pii-entity-table": [], + }, + } + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/agenthub_/api/policies/deployed-policy", + status_code=200, + json=expected_policy, + ) + + result = service.get_deployed_policy() + + assert result == expected_policy + + def test_uses_post_method( + self, + httpx_mock: HTTPXMock, + service: AutomationOpsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + captured_request: httpx.Request | None = None + + def capture(request: httpx.Request) -> httpx.Response: + nonlocal captured_request + captured_request = request + return httpx.Response(status_code=200, json={}) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/agenthub_/api/policies/deployed-policy", + callback=capture, + ) + + service.get_deployed_policy() + + assert captured_request is not None + assert captured_request.method == "POST" + + class TestGetDeployedPolicyAsync: + """Test get_deployed_policy_async.""" + + async def test_returns_policy_dict( + self, + httpx_mock: HTTPXMock, + service: AutomationOpsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + expected_policy = { + "policy-name": "AITL Policy", + "data": { + "container": {"pii-in-flight-agents": False}, + }, + } + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/agenthub_/api/policies/deployed-policy", + status_code=200, + json=expected_policy, + ) + + result = await service.get_deployed_policy_async() + + assert result == expected_policy + + async def test_url_is_tenant_scoped( + self, + httpx_mock: HTTPXMock, + service: AutomationOpsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + captured_request: httpx.Request | None = None + + def capture(request: httpx.Request) -> httpx.Response: + nonlocal captured_request + captured_request = request + return httpx.Response(status_code=200, json={}) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/agenthub_/api/policies/deployed-policy", + callback=capture, + ) + + await service.get_deployed_policy_async() + + assert captured_request is not None + # Tenant-scoped: both org and tenant segments appear in the path + assert org.strip("/") in captured_request.url.path + assert tenant.strip("/") in captured_request.url.path + + async def test_request_has_no_body( + self, + httpx_mock: HTTPXMock, + service: AutomationOpsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + captured_request: httpx.Request | None = None + + def capture(request: httpx.Request) -> httpx.Response: + nonlocal captured_request + captured_request = request + return httpx.Response(status_code=200, json={}) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/agenthub_/api/policies/deployed-policy", + callback=capture, + ) + + await service.get_deployed_policy_async() + + assert captured_request is not None + # POST with no body — body should be empty (or an empty JSON object) + body = captured_request.content + assert body in (b"", b"null") or json.loads(body) in ({}, None) diff --git a/packages/uipath-platform/tests/services/test_pii_utilities.py b/packages/uipath-platform/tests/services/test_pii_utilities.py new file mode 100644 index 000000000..844985a90 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_pii_utilities.py @@ -0,0 +1,170 @@ +"""Tests for PII rehydration utilities.""" + +from uipath.platform.semantic_proxy import ( + PiiDetectionResponse, + PiiDocumentResult, + PiiEntity, + PiiFileResult, + rehydrate_from_pii_entities, + rehydrate_from_pii_response, +) + + +def _entity( + pii_text: str, + replacement_text: str, + pii_type: str = "Person", + offset: int = 0, + confidence_score: float = 0.9, +) -> PiiEntity: + return PiiEntity( + pii_text=pii_text, + replacement_text=replacement_text, + pii_type=pii_type, + offset=offset, + confidence_score=confidence_score, + ) + + +class TestRehydrateFromPiiEntities: + """Test rehydrate_from_pii_entities.""" + + def test_empty_text_returns_empty(self) -> None: + assert rehydrate_from_pii_entities("", [_entity("Alice", "[Person-1]")]) == "" + + def test_no_entities_returns_text_unchanged(self) -> None: + text = "Hello [Person-1]" + assert rehydrate_from_pii_entities(text, []) == text + + def test_replaces_single_placeholder(self) -> None: + result = rehydrate_from_pii_entities( + "Hello [Person-1]", [_entity("Alice", "[Person-1]")] + ) + assert result == "Hello Alice" + + def test_replaces_multiple_placeholders(self) -> None: + result = rehydrate_from_pii_entities( + "Contact [Person-1] at [Email-1]", + [ + _entity("Alice", "[Person-1]"), + _entity("alice@example.com", "[Email-1]", pii_type="Email"), + ], + ) + assert result == "Contact Alice at alice@example.com" + + def test_longer_placeholders_replaced_first(self) -> None: + """[Person-10] must be rehydrated before [Person-1] to avoid partial match.""" + result = rehydrate_from_pii_entities( + "[Person-1] and [Person-10]", + [ + _entity("Alice", "[Person-1]"), + _entity("Zara", "[Person-10]"), + ], + ) + assert result == "Alice and Zara" + + def test_case_insensitive_placeholder_match(self) -> None: + result = rehydrate_from_pii_entities( + "Hello [person-1]", [_entity("Alice", "[Person-1]")] + ) + assert result == "Hello Alice" + + def test_replaces_bracketless_variant(self) -> None: + """The LLM may drop brackets; bracketless variant should still be replaced.""" + result = rehydrate_from_pii_entities( + "Hello Person-1", [_entity("Alice", "[Person-1]")] + ) + assert result == "Hello Alice" + + def test_skips_entities_with_empty_replacement_text(self) -> None: + result = rehydrate_from_pii_entities( + "Hello [Person-1]", + [ + _entity("Ignored", ""), + _entity("Alice", "[Person-1]"), + ], + ) + assert result == "Hello Alice" + + def test_skips_entities_with_empty_pii_text(self) -> None: + result = rehydrate_from_pii_entities( + "Hello [Person-1]", + [_entity("", "[Person-1]")], + ) + assert result == "Hello [Person-1]" + + def test_preserves_non_placeholder_content(self) -> None: + result = rehydrate_from_pii_entities( + "The meeting with [Person-1] is at 3pm in the boardroom.", + [_entity("Alice", "[Person-1]")], + ) + assert result == "The meeting with Alice is at 3pm in the boardroom." + + def test_pii_text_with_special_characters(self) -> None: + """Special chars in PII text must not break regex substitution.""" + result = rehydrate_from_pii_entities( + "Visit [URL-1]", + [_entity("https://example.com/path?q=1&x=2", "[URL-1]", pii_type="URL")], + ) + assert result == "Visit https://example.com/path?q=1&x=2" + + def test_regex_special_chars_in_replacement_text(self) -> None: + """Regex special chars in the placeholder must be escaped for the pattern.""" + result = rehydrate_from_pii_entities( + "Hello [Person.1]", + [_entity("Alice", "[Person.1]")], + ) + assert result == "Hello Alice" + + +class TestRehydrateFromPiiResponse: + """Test rehydrate_from_pii_response.""" + + def test_merges_document_and_file_entities(self) -> None: + response = PiiDetectionResponse( + response=[ + PiiDocumentResult( + id="user-prompt", + role="user", + masked_document="Hi [Person-1]", + initial_document="Hi Alice", + pii_entities=[_entity("Alice", "[Person-1]")], + ) + ], + files=[ + PiiFileResult( + file_name="doc.pdf", + file_url="https://example.com/doc.pdf", + pii_entities=[ + _entity("bob@example.com", "[Email-1]", pii_type="Email") + ], + ) + ], + ) + + result = rehydrate_from_pii_response( + "From [Person-1]: contact [Email-1]", response + ) + assert result == "From Alice: contact bob@example.com" + + def test_file_only_entity_is_rehydrated(self) -> None: + """Entities detected in files (not prompts) must also rehydrate.""" + response = PiiDetectionResponse( + response=[], + files=[ + PiiFileResult( + file_name="doc.pdf", + file_url="https://example.com/doc.pdf", + pii_entities=[ + _entity("alice@example.com", "[Email-1]", pii_type="Email") + ], + ) + ], + ) + + result = rehydrate_from_pii_response("Email is [Email-1]", response) + assert result == "Email is alice@example.com" + + def test_empty_response_returns_text_unchanged(self) -> None: + response = PiiDetectionResponse(response=[], files=[]) + assert rehydrate_from_pii_response("No PII here", response) == "No PII here" diff --git a/packages/uipath-platform/tests/services/test_semantic_proxy_service.py b/packages/uipath-platform/tests/services/test_semantic_proxy_service.py new file mode 100644 index 000000000..51b4f4895 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_semantic_proxy_service.py @@ -0,0 +1,264 @@ +"""Tests for SemanticProxyService.""" + +import json +from typing import Any + +import httpx +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.semantic_proxy import ( + PiiDetectionRequest, + PiiDetectionResponse, + PiiDocument, + PiiEntityThreshold, + PiiFile, + SemanticProxyService, +) + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, +) -> SemanticProxyService: + return SemanticProxyService(config=config, execution_context=execution_context) + + +@pytest.fixture +def sample_response_json() -> dict[str, Any]: + return { + "response": [ + { + "id": "user-prompt", + "role": "user", + "maskedDocument": "Contact [Person-1]", + "initialDocument": "Contact Alison", + "piiEntities": [ + { + "piiText": "Alison", + "replacementText": "[Person-1]", + "piiType": "Person", + "offset": 8, + "confidenceScore": 0.99, + } + ], + } + ], + "files": [ + { + "fileName": "doc.pdf", + "fileUrl": "https://blob.example.com/redacted/doc.pdf", + "piiEntities": [ + { + "piiText": "alice@example.com", + "replacementText": "[Email-1]", + "piiType": "Email", + "offset": 100, + "confidenceScore": 0.88, + } + ], + } + ], + } + + +class TestSemanticProxyService: + """Test SemanticProxyService functionality.""" + + class TestDetectPii: + """Test detect_pii (sync).""" + + def test_returns_typed_response( + self, + httpx_mock: HTTPXMock, + service: SemanticProxyService, + base_url: str, + org: str, + tenant: str, + sample_response_json: dict[str, Any], + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/semanticproxy_/api/pii-detection", + status_code=200, + json=sample_response_json, + ) + + request = PiiDetectionRequest( + documents=[ + PiiDocument( + id="user-prompt", role="user", document="Contact Alison" + ) + ] + ) + result = service.detect_pii(request) + + assert isinstance(result, PiiDetectionResponse) + assert len(result.response) == 1 + assert result.response[0].masked_document == "Contact [Person-1]" + assert len(result.files) == 1 + assert result.files[0].file_name == "doc.pdf" + assert result.files[0].pii_entities[0].replacement_text == "[Email-1]" + + class TestDetectPiiAsync: + """Test detect_pii_async.""" + + async def test_returns_typed_response( + self, + httpx_mock: HTTPXMock, + service: SemanticProxyService, + base_url: str, + org: str, + tenant: str, + sample_response_json: dict[str, Any], + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/semanticproxy_/api/pii-detection", + status_code=200, + json=sample_response_json, + ) + + request = PiiDetectionRequest( + files=[ + PiiFile( + file_name="doc.pdf", + file_url="https://input.example.com/doc.pdf", + file_type="pdf", + ) + ] + ) + result = await service.detect_pii_async(request) + + assert isinstance(result, PiiDetectionResponse) + assert ( + result.files[0].file_url == "https://blob.example.com/redacted/doc.pdf" + ) + + async def test_request_payload_uses_aliases( + self, + httpx_mock: HTTPXMock, + service: SemanticProxyService, + base_url: str, + org: str, + tenant: str, + sample_response_json: dict[str, Any], + ) -> None: + captured_request: httpx.Request | None = None + + def capture(request: httpx.Request) -> httpx.Response: + nonlocal captured_request + captured_request = request + return httpx.Response(status_code=200, json=sample_response_json) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/semanticproxy_/api/pii-detection", + callback=capture, + ) + + request = PiiDetectionRequest( + documents=[ + PiiDocument(id="user-prompt", role="user", document="Hello") + ], + files=[ + PiiFile( + file_name="doc.pdf", + file_url="https://input.example.com/doc.pdf", + file_type="pdf", + ) + ], + language_code="en", + confidence_threshold=0.5, + entity_thresholds=[ + PiiEntityThreshold(category="Person", confidence_threshold=0.7), + ], + ) + await service.detect_pii_async(request) + + assert captured_request is not None + payload = json.loads(captured_request.content) + + # Top-level uses camelCase aliases + assert "documents" in payload + assert "files" in payload + assert "languageCode" in payload + assert "confidenceThreshold" in payload + assert "entityThresholds" in payload + + # File uses camelCase aliases + assert payload["files"][0]["fileName"] == "doc.pdf" + assert payload["files"][0]["fileUrl"] == "https://input.example.com/doc.pdf" + assert payload["files"][0]["fileType"] == "pdf" + + # Entity threshold uses kebab-case aliases + threshold = payload["entityThresholds"][0] + assert threshold["pii-entity-category"] == "Person" + assert threshold["pii-entity-confidence-threshold"] == 0.7 + + async def test_request_excludes_none_fields( + self, + httpx_mock: HTTPXMock, + service: SemanticProxyService, + base_url: str, + org: str, + tenant: str, + sample_response_json: dict[str, Any], + ) -> None: + captured_request: httpx.Request | None = None + + def capture(request: httpx.Request) -> httpx.Response: + nonlocal captured_request + captured_request = request + return httpx.Response(status_code=200, json=sample_response_json) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/semanticproxy_/api/pii-detection", + callback=capture, + ) + + # Only documents set; other optional fields should be omitted + request = PiiDetectionRequest( + documents=[PiiDocument(id="user-prompt", role="user", document="Hello")] + ) + await service.detect_pii_async(request) + + assert captured_request is not None + payload = json.loads(captured_request.content) + assert "files" not in payload + assert "languageCode" not in payload + assert "confidenceThreshold" not in payload + assert "entityThresholds" not in payload + + async def test_url_is_tenant_scoped( + self, + httpx_mock: HTTPXMock, + service: SemanticProxyService, + base_url: str, + org: str, + tenant: str, + sample_response_json: dict[str, Any], + ) -> None: + captured_request: httpx.Request | None = None + + def capture(request: httpx.Request) -> httpx.Response: + nonlocal captured_request + captured_request = request + return httpx.Response(status_code=200, json=sample_response_json) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/semanticproxy_/api/pii-detection", + callback=capture, + ) + + request = PiiDetectionRequest( + documents=[PiiDocument(id="user-prompt", role="user", document="Hello")] + ) + await service.detect_pii_async(request) + + assert captured_request is not None + assert org.strip("/") in captured_request.url.path + assert tenant.strip("/") in captured_request.url.path + assert "/semanticproxy_/api/pii-detection" in captured_request.url.path diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 815defe05..8d6d60a56 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.33" +version = "0.1.34" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index bb7432220..0d146ae6c 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.33" +version = "0.1.34" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 9ef2936847557236132367415357911967730fa6 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Wed, 22 Apr 2026 17:29:12 +0200 Subject: [PATCH 027/191] feat: create virtual resources on push when catalog lookup misses (#1584) --- packages/uipath-platform/pyproject.toml | 2 +- .../_resource_catalog_service.py | 8 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 2 +- .../src/uipath/_cli/_push/_resolvers.py | 177 ++++++++ .../uipath/_cli/_push/_resource_actions.py | 27 ++ .../uipath/src/uipath/_cli/_push/_summary.py | 35 ++ .../src/uipath/_cli/_push/_virtual_kinds.py | 34 ++ .../src/uipath/_cli/_utils/_studio_project.py | 134 ++---- packages/uipath/src/uipath/_cli/cli_push.py | 217 ++++----- .../uipath/tests/cli/test_create_resources.py | 414 ++++++++++++++++++ packages/uipath/tests/cli/test_push.py | 71 ++- packages/uipath/uv.lock | 4 +- 13 files changed, 903 insertions(+), 224 deletions(-) create mode 100644 packages/uipath/src/uipath/_cli/_push/_resolvers.py create mode 100644 packages/uipath/src/uipath/_cli/_push/_resource_actions.py create mode 100644 packages/uipath/src/uipath/_cli/_push/_summary.py create mode 100644 packages/uipath/src/uipath/_cli/_push/_virtual_kinds.py create mode 100644 packages/uipath/tests/cli/test_create_resources.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index cfc151484..abbc9afa9 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.34" +version = "0.1.35" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/resource_catalog/_resource_catalog_service.py b/packages/uipath-platform/src/uipath/platform/resource_catalog/_resource_catalog_service.py index 030d85d01..e42880e71 100644 --- a/packages/uipath-platform/src/uipath/platform/resource_catalog/_resource_catalog_service.py +++ b/packages/uipath-platform/src/uipath/platform/resource_catalog/_resource_catalog_service.py @@ -1,4 +1,4 @@ -from typing import Any, AsyncIterator, Dict, Iterator, List, Optional +from typing import Any, AsyncGenerator, Dict, Iterator, List, Optional from uipath.core.tracing import traced @@ -110,7 +110,7 @@ async def search_async( resource_types: Optional[List[ResourceType]] = None, resource_sub_types: Optional[List[str]] = None, page_size: int = _DEFAULT_PAGE_SIZE, - ) -> AsyncIterator[Resource]: + ) -> AsyncGenerator[Resource, None]: """Asynchronously search for tenant scoped resources and folder scoped resources (accessible to the user). This method automatically handles pagination and yields resources one by one. @@ -258,7 +258,7 @@ async def list_async( folder_path: Optional[str] = None, folder_key: Optional[str] = None, page_size: int = _DEFAULT_PAGE_SIZE, - ) -> AsyncIterator[Resource]: + ) -> AsyncGenerator[Resource, None]: """Asynchronously get tenant scoped resources and folder scoped resources (accessible to the user). If no folder identifier is provided (path or key) only tenant resources will be retrieved. @@ -428,7 +428,7 @@ async def list_by_type_async( folder_path: Optional[str] = None, folder_key: Optional[str] = None, page_size: int = _DEFAULT_PAGE_SIZE, - ) -> AsyncIterator[Resource]: + ) -> AsyncGenerator[Resource, None]: """Asynchronously get resources of a specific type (tenant scoped or folder scoped). If no folder identifier is provided (path or key) only tenant resources will be retrieved. diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 8d6d60a56..ff4ccc1f5 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.34" +version = "0.1.35" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index ee647db03..b80902532 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.51" +version = "2.10.52" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/_push/_resolvers.py b/packages/uipath/src/uipath/_cli/_push/_resolvers.py new file mode 100644 index 000000000..e609014d5 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_push/_resolvers.py @@ -0,0 +1,177 @@ +from typing import AsyncIterator + +from uipath.platform.connections import ConnectionsService +from uipath.platform.errors import EnrichedException, FolderNotFoundException +from uipath.platform.resource_catalog import ( + Resource, + ResourceCatalogService, + ResourceType, +) + +from .._utils._studio_project import ( + ReferencedResourceFolder, + ReferencedResourceRequest, + VirtualResourceRequest, +) +from ..models.runtime_schema import BindingResource, Bindings +from ._resource_actions import CreateReference, CreateVirtual, ResourceAction, Skip + +_NOT_FOUND_SUFFIX = "was not found and will not be added to the solution." + + +async def resolve_bindings( + bindings: Bindings, + resource_catalog: ResourceCatalogService, + connections: ConnectionsService, + supported_virtual_kinds: set[str], +) -> AsyncIterator[ResourceAction]: + """Yield one ResourceAction per importable binding. + + Bindings that should be silently ignored (e.g. guardrail bindings without a + folderPath) are filtered out here. + """ + for binding in bindings.resources: + action = await _resolve_binding( + binding, resource_catalog, connections, supported_virtual_kinds + ) + if action is not None: + yield action + + +async def _resolve_binding( + binding: BindingResource, + resource_catalog: ResourceCatalogService, + connections: ConnectionsService, + supported_virtual_kinds: set[str], +) -> ResourceAction | None: + if binding.resource == "connection": + return await _resolve_connection(binding, resource_catalog, connections) + return await _resolve_regular(binding, resource_catalog, supported_virtual_kinds) + + +async def _resolve_connection( + binding: BindingResource, + resource_catalog: ResourceCatalogService, + connections: ConnectionsService, +) -> ResourceAction | None: + connection_id_value = binding.value.get("ConnectionId") + if connection_id_value is None: + raise ValueError( + f"Connection binding {binding.key!r} is missing required field 'ConnectionId'" + ) + connection_key = connection_id_value.default_value + + try: + connection = await connections.retrieve_async(connection_key) + except EnrichedException: + connector_name = (binding.metadata or {}).get("Connector") + return Skip( + message=( + f"Connection with key '{connection_key}' of type " + f"'{connector_name}' {_NOT_FOUND_SUFFIX}" + ) + ) + + resource_name: str = connection.name + folder_path: str = connection.folder.get("path") + + found = await _find_in_resource_catalog( + resource_catalog, "connection", resource_name, folder_path + ) + if found is None: + return Skip( + message=( + f"Resource '{resource_name}' of type 'connection' at folder path " + f"'{folder_path}' {_NOT_FOUND_SUFFIX}" + ) + ) + return _build_create_reference(found, resource_name) + + +async def _resolve_regular( + binding: BindingResource, + resource_catalog: ResourceCatalogService, + supported_virtual_kinds: set[str], +) -> ResourceAction | None: + name_value = binding.value.get("name") + folder_path_value = binding.value.get("folderPath") + if not folder_path_value: + # guardrail resource, nothing to import + return None + if name_value is None: + raise ValueError(f"Binding {binding.key!r} is missing required field 'name'") + resource_name: str = name_value.default_value + folder_path: str = folder_path_value.default_value + resource_type: str = binding.resource + + found = await _find_in_resource_catalog( + resource_catalog, resource_type, resource_name, folder_path + ) + if found is not None: + return _build_create_reference(found, resource_name) + + if resource_type not in supported_virtual_kinds: + return Skip( + message=( + f"Cannot create virtual resource '{resource_name}' — " + f"kind '{resource_type}' is not supported." + ) + ) + + sub_type: str | None = (binding.metadata or {}).get("SubType") + return CreateVirtual( + request=VirtualResourceRequest( + kind=resource_type, + name=resource_name, + type=sub_type, + ) + ) + + +async def _find_in_resource_catalog( + resource_catalog: ResourceCatalogService, + resource_type: str, + name: str, + folder_path: str, +) -> Resource | None: + """Look up a single resource in the Resource Catalog. + + Returns the first match or None if the catalog can't search this kind, the + folder is unknown, or no resource matches. + """ + catalog_type = next( + (m for m in ResourceType if m.value == resource_type.lower()), None + ) + if catalog_type is None: + return None + + resources = resource_catalog.list_by_type_async( + resource_type=catalog_type, name=name, folder_path=folder_path + ) + try: + return await anext(resources, None) + except FolderNotFoundException: + return None + finally: + await resources.aclose() + + +def _build_create_reference( + found_resource: Resource, resource_name: str +) -> CreateReference: + folder = next(iter(found_resource.folders)) + return CreateReference( + request=ReferencedResourceRequest( + key=found_resource.resource_key, + kind=found_resource.resource_type, + type=found_resource.resource_sub_type, + folder=ReferencedResourceFolder( + folder_key=folder.key, + fully_qualified_name=folder.fully_qualified_name, + path=folder.path, + ), + ), + resource_name=resource_name, + kind=found_resource.resource_type, + sub_type=found_resource.resource_sub_type, + ) diff --git a/packages/uipath/src/uipath/_cli/_push/_resource_actions.py b/packages/uipath/src/uipath/_cli/_push/_resource_actions.py new file mode 100644 index 000000000..4eb76bd57 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_push/_resource_actions.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass + +from .._utils._studio_project import ( + ReferencedResourceRequest, + VirtualResourceRequest, +) + + +@dataclass(frozen=True, slots=True) +class CreateReference: + request: ReferencedResourceRequest + resource_name: str + kind: str + sub_type: str | None + + +@dataclass(frozen=True, slots=True) +class CreateVirtual: + request: VirtualResourceRequest + + +@dataclass(frozen=True, slots=True) +class Skip: + message: str + + +ResourceAction = CreateReference | CreateVirtual | Skip diff --git a/packages/uipath/src/uipath/_cli/_push/_summary.py b/packages/uipath/src/uipath/_cli/_push/_summary.py new file mode 100644 index 000000000..2dcbca57e --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_push/_summary.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass + +import click + + +@dataclass +class ResourceImportSummary: + created: int = 0 + updated: int = 0 + unchanged: int = 0 + virtual_created: int = 0 + virtual_existing: int = 0 + not_found: int = 0 + + @property + def total(self) -> int: + return ( + self.created + + self.updated + + self.unchanged + + self.virtual_created + + self.virtual_existing + + self.not_found + ) + + def __str__(self) -> str: + return ( + f"\n \U0001f535 Resource import summary: {self.total} total resources - " + f"{click.style(str(self.created), fg='green')} created, " + f"{click.style(str(self.updated), fg='blue')} updated, " + f"{click.style(str(self.unchanged), fg='yellow')} unchanged, " + f"{click.style(str(self.virtual_created), fg='green')} virtual-created, " + f"{click.style(str(self.virtual_existing), fg='yellow')} virtual-existing, " + f"{click.style(str(self.not_found), fg='red')} not found" + ) diff --git a/packages/uipath/src/uipath/_cli/_push/_virtual_kinds.py b/packages/uipath/src/uipath/_cli/_push/_virtual_kinds.py new file mode 100644 index 000000000..bc1b20f73 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_push/_virtual_kinds.py @@ -0,0 +1,34 @@ +import logging + +from .._utils._studio_project import ResourceBuilderMetadataEntry, StudioClient + +logger = logging.getLogger(__name__) + +_FALLBACK: frozenset[str] = frozenset( + {"app", "asset", "bucket", "process", "queue", "taskCatalog", "trigger"} +) + + +async def fetch_supported_virtual_kinds(studio_client: StudioClient) -> set[str]: + """Return the set of resource kinds that support inline creation. + + Falls back to a static list on any failure — the caller shouldn't have to + care whether the metadata endpoint was reachable. + """ + try: + metadata = await studio_client.get_resource_builder_metadata() + except Exception as e: + logger.debug("Resource Builder metadata fetch failed, using fallback: %s", e) + return set(_FALLBACK) + return _extract_supported_kinds(metadata) + + +def _extract_supported_kinds( + metadata: list[ResourceBuilderMetadataEntry], +) -> set[str]: + # metadata has one entry per (kind, type), so a kind may appear multiple times + return { + entry.kind + for entry in metadata + if any(version.supports_in_line_creation for version in entry.versions) + } diff --git a/packages/uipath/src/uipath/_cli/_utils/_studio_project.py b/packages/uipath/src/uipath/_cli/_utils/_studio_project.py index 63ddceb37..1380d9119 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_studio_project.py +++ b/packages/uipath/src/uipath/_cli/_utils/_studio_project.py @@ -6,7 +6,6 @@ from pathlib import PurePath from typing import Any, Callable, List, Optional, Union -import click from pydantic import BaseModel, ConfigDict, Field, field_validator from uipath._utils.constants import ( @@ -152,12 +151,20 @@ class LockInfo(BaseModel): solution_lock_key: Optional[str] = Field(alias="solutionLockKey") -class Severity(str, Enum): - """Severity level for virtual resource operation results.""" +class ResourceBuilderMetadataVersion(BaseModel): + model_config = ConfigDict(extra="allow") - SUCCESS = "success" - ATTENTION = "attention" - WARN = "warn" + supports_in_line_creation: bool = Field( + default=False, alias="supportsInLineCreation" + ) + + +class ResourceBuilderMetadataEntry(BaseModel): + model_config = ConfigDict(extra="allow") + + kind: str + type: str | None = None + versions: list[ResourceBuilderMetadataVersion] = Field(default_factory=list) class VirtualResourceRequest(BaseModel): @@ -173,16 +180,20 @@ class VirtualResourceRequest(BaseModel): api_version: Optional[str] = Field(default=None, alias="apiVersion") +class Status(str, Enum): + ADDED = "ADDED" + UNCHANGED = "UNCHANGED" + UPDATED = "UPDATED" + + class VirtualResourceResult(BaseModel): - """Result of a virtual resource creation operation. + """Structured outcome of a virtual resource creation attempt. - Attributes: - severity: The severity level (log, warn or attention) - message: The result message with styling + Only `ADDED` and `UNCHANGED` are possible — virtual resources are never + updated in place. """ - severity: Severity - message: str + status: Status class ReferencedResourceFolder(BaseModel): @@ -362,12 +373,6 @@ class ProjectLockUnavailableError(RuntimeError): pass -class Status(str, Enum): - ADDED = "ADDED" - UNCHANGED = "UNCHANGED" - UPDATED = "UPDATED" - - class ReferencedResourceResponse(BaseModel): """Response from creating a referenced resource. @@ -528,6 +533,19 @@ async def get_project_metadata_async(self) -> Optional[StudioProjectMetadata]: response.read().decode("utf-8") ) + async def get_resource_builder_metadata( + self, + ) -> list[ResourceBuilderMetadataEntry]: + response = await self.uipath.api_client.request_async( + "GET", + url="/studio_/backend/api/resourcebuilder/metadata", + scoped="org", + ) + return [ + ResourceBuilderMetadataEntry.model_validate(entry) + for entry in response.json() + ] + async def _get_existing_resources(self) -> List[dict[str, Any]]: if self._resources_cache is not None: return self._resources_cache @@ -599,68 +617,19 @@ async def get_resource_overwrites(self) -> dict[str, ResourceOverwrite]: async def create_virtual_resource( self, virtual_resource_request: VirtualResourceRequest ) -> VirtualResourceResult: - """Create a virtual resource or return appropriate status if it already exists. - - Args: - virtual_resource_request: The virtual resource request details + """Create a virtual resource, or report UNCHANGED if already present. - Returns: - VirtualResourceResult: Result indicating the operation status and a formatted message + Returns UNCHANGED when the same name+kind already exists in the + solution. Name collisions with a different kind are not checked + client-side — they surface as a server error via EnrichedException. """ - # Build base message with resource details - base_message_parts = [ - f"Resource {click.style(virtual_resource_request.name, fg='cyan')}", - f" (kind: {click.style(virtual_resource_request.kind, fg='yellow')}", - ] - - if virtual_resource_request.type: - base_message_parts.append( - f", type: {click.style(virtual_resource_request.type, fg='yellow')}" - ) - - if virtual_resource_request.activity_name: - base_message_parts.append( - f", activity: {click.style(virtual_resource_request.activity_name, fg='yellow')}" - ) - - base_message_parts.append(")") - base_message = "".join(base_message_parts) - + name = virtual_resource_request.name + kind = virtual_resource_request.kind existing_resources = await self._get_existing_resources() - # Check if resource with same kind and name exists - existing_same_kind = next( - ( - r - for r in existing_resources - if r["name"] == virtual_resource_request.name - and r["kind"] == virtual_resource_request.kind - ), - None, - ) - if existing_same_kind: - message = f"{base_message} already exists. Skipping..." - return VirtualResourceResult(severity=Severity.ATTENTION, message=message) + if any(r["name"] == name and r["kind"] == kind for r in existing_resources): + return VirtualResourceResult(status=Status.UNCHANGED) - # Check if resource with same name but different kind exists - existing_diff_kind = next( - ( - r - for r in existing_resources - if r["name"] == virtual_resource_request.name - and r["kind"] != virtual_resource_request.kind - ), - None, - ) - if existing_diff_kind: - message = ( - f"Cannot create {base_message}. " - f"A resource with this name already exists with kind {click.style(existing_diff_kind['kind'], fg='yellow')}. " - f"Consider renaming the resource in code." - ) - return VirtualResourceResult(severity=Severity.WARN, message=message) - - # Create the virtual resource solution_id = await self._get_solution_id() response = await self.uipath.api_client.request_async( "POST", @@ -669,21 +638,12 @@ async def create_virtual_resource( json=virtual_resource_request.model_dump(exclude_none=True), ) resource_key = response.json()["key"] - await self._update_resource_specs( - resource_key, new_specs={"name": virtual_resource_request.name} - ) + await self._update_resource_specs(resource_key, new_specs={"name": name}) - # Update cache with newly created resource if self._resources_cache is not None: - self._resources_cache.append( - { - "name": virtual_resource_request.name, - "kind": virtual_resource_request.kind, - } - ) + self._resources_cache.append({"name": name, "kind": kind}) - message = f"{base_message} created successfully." - return VirtualResourceResult(severity=Severity.SUCCESS, message=message) + return VirtualResourceResult(status=Status.ADDED) async def create_referenced_resource( self, referenced_resource_request: ReferencedResourceRequest diff --git a/packages/uipath/src/uipath/_cli/cli_push.py b/packages/uipath/src/uipath/_cli/cli_push.py index 61e46cbc6..9082d747f 100644 --- a/packages/uipath/src/uipath/_cli/cli_push.py +++ b/packages/uipath/src/uipath/_cli/cli_push.py @@ -5,9 +5,17 @@ import click from uipath.platform.common import UiPathConfig -from uipath.platform.errors import EnrichedException, FolderNotFoundException - -from ..platform.resource_catalog import ResourceType +from uipath.platform.errors import EnrichedException + +from ._push._resolvers import resolve_bindings +from ._push._resource_actions import ( + CreateReference, + CreateVirtual, + ResourceAction, + Skip, +) +from ._push._summary import ResourceImportSummary +from ._push._virtual_kinds import fetch_supported_virtual_kinds from ._push.sw_file_handler import SwFileHandler from ._telemetry import track_command from ._utils._common import ensure_coded_agent_project, may_override_files @@ -22,10 +30,9 @@ ) from ._utils._studio_project import ( ProjectLockUnavailableError, - ReferencedResourceFolder, - ReferencedResourceRequest, Status, StudioClient, + VirtualResourceRequest, ) from ._utils._uv_helpers import handle_uv_operations from .models.runtime_schema import Bindings @@ -42,133 +49,104 @@ def get_org_scoped_url(base_url: str) -> str: return org_scoped_url -async def create_resources(studio_client: StudioClient): +async def create_resources(studio_client: StudioClient) -> None: console.info("\nImporting referenced resources to Studio Web project...") from uipath.platform import UiPath uipath = UiPath() - resource_catalog = uipath.resource_catalog - connections = uipath.connections with open(UiPathConfig.bindings_file_path, "r") as f: - bindings_file_content = f.read() - - bindings = Bindings.model_validate_json(bindings_file_content) - - resources_not_found = 0 - resources_unchanged = 0 - resources_created = 0 - resource_updated = 0 - - for bindings_resource in bindings.resources: - not_found_warning = "was not found and will not be added to the solution." - found_resource = None - resource_type = bindings_resource.resource - if resource_type == "connection": - connection_key_resource_value = bindings_resource.value.get("ConnectionId") - assert connection_key_resource_value - connection_key = connection_key_resource_value.default_value - try: - connection = await connections.retrieve_async(connection_key) - except EnrichedException: - resources_not_found += 1 - assert bindings_resource.metadata is not None - connector_name = bindings_resource.metadata.get("Connector") - console.warning( - f"Connection with key '{connection_key}' of type '{connector_name}' " - f"{not_found_warning}" - ) - continue - resource_name = connection.name - folder_path = connection.folder.get("path") - else: - name_resource_value = bindings_resource.value.get("name") - folder_path_resource_value = bindings_resource.value.get("folderPath") - - if not folder_path_resource_value: - # guardrail resource, nothing to import - continue - - assert name_resource_value - resource_name = name_resource_value.default_value - folder_path = folder_path_resource_value.default_value - - resources = resource_catalog.list_by_type_async( - resource_type=ResourceType.from_string(resource_type), - name=resource_name, - folder_path=folder_path, - ) + bindings = Bindings.model_validate_json(f.read()) - try: - async for resource in resources: - found_resource = resource - break - await resources.aclose() + supported_virtual_kinds = await fetch_supported_virtual_kinds(studio_client) - except FolderNotFoundException: - pass + summary = ResourceImportSummary() + async for action in resolve_bindings( + bindings, + uipath.resource_catalog, + uipath.connections, + supported_virtual_kinds, + ): + await _execute_action(action, studio_client, summary) - if not found_resource: - console.warning( - f"Resource '{resource_name}' of type '{resource_type}' at folder path '{folder_path}' " - f"{not_found_warning}" - ) - resources_not_found += 1 - continue - - referenced_resource_request = ReferencedResourceRequest( - key=found_resource.resource_key, - kind=found_resource.resource_type, - type=found_resource.resource_sub_type, - folder=next( - ReferencedResourceFolder( - folder_key=folder.key, - fully_qualified_name=folder.fully_qualified_name, - path=folder.path, - ) - for folder in found_resource.folders - ), - ) - response = await studio_client.create_referenced_resource( - referenced_resource_request - ) + console.info(str(summary)) - resource_details = ( - f"(kind = {click.style(found_resource.resource_type, fg='cyan')}, " - f"type = {click.style(found_resource.resource_sub_type, fg='cyan')})" - ) - match response.status: - case Status.ADDED: - console.success( - f"Created reference for resource: {click.style(resource_name, fg='cyan')} " - f"{resource_details}" - ) - resources_created += 1 - case Status.UNCHANGED: - console.info( - f"Resource reference already exists ({click.style('unchanged', fg='yellow')}): {click.style(resource_name, fg='cyan')} " - f"{resource_details}" - ) - resources_unchanged += 1 - case Status.UPDATED: - console.info( - f"Resource reference already exists ({click.style('updated', fg='blue')}): {click.style(resource_name, fg='cyan')} " - f"{resource_details}" - ) - resource_updated += 1 +async def _execute_action( + action: ResourceAction, + studio_client: StudioClient, + summary: ResourceImportSummary, +) -> None: + match action: + case Skip(message=message): + console.warning(message) + summary.not_found += 1 - total_resources = ( - resources_created + resources_unchanged + resources_not_found + resource_updated - ) - console.info( - f"\n \U0001f535 Resource import summary: {total_resources} total resources - " - f"{click.style(str(resources_created), fg='green')} created, " - f"{click.style(str(resource_updated), fg='blue')} updated, " - f"{click.style(str(resources_unchanged), fg='yellow')} unchanged, " - f"{click.style(str(resources_not_found), fg='red')} not found" - ) + case CreateVirtual(request=request): + try: + result = await studio_client.create_virtual_resource(request) + except EnrichedException as e: + console.warning( + f"Failed to create virtual resource '{request.name}' of type " + f"'{request.kind}': {e}" + ) + summary.not_found += 1 + return + label = _format_virtual_label(request) + match result.status: + case Status.ADDED: + console.success(f"{label} created successfully.") + summary.virtual_created += 1 + case Status.UNCHANGED: + console.info(f"{label} already exists. Skipping...") + summary.virtual_existing += 1 + + case CreateReference( + request=request, + resource_name=resource_name, + kind=kind, + sub_type=sub_type, + ): + response = await studio_client.create_referenced_resource(request) + details = ( + f"(kind = {click.style(kind, fg='cyan')}, " + f"type = {click.style(sub_type, fg='cyan')})" + ) + match response.status: + case Status.ADDED: + console.success( + f"Created reference for resource: " + f"{click.style(resource_name, fg='cyan')} {details}" + ) + summary.created += 1 + case Status.UNCHANGED: + console.info( + f"Resource reference already exists " + f"({click.style('unchanged', fg='yellow')}): " + f"{click.style(resource_name, fg='cyan')} {details}" + ) + summary.unchanged += 1 + case Status.UPDATED: + console.info( + f"Resource reference already exists " + f"({click.style('updated', fg='blue')}): " + f"{click.style(resource_name, fg='cyan')} {details}" + ) + summary.updated += 1 + + +def _format_virtual_label(request: VirtualResourceRequest) -> str: + parts = [ + f"Resource {click.style(request.name, fg='cyan')}", + f" (kind: {click.style(request.kind, fg='yellow')}", + ] + if request.type: + parts.append(f", type: {click.style(request.type, fg='yellow')}") + if request.activity_name: + parts.append(f", activity: {click.style(request.activity_name, fg='yellow')}") + parts.append(")") + return "".join(parts) async def upload_source_files_to_project( @@ -262,7 +240,6 @@ def push(root: str, ignore_resources: bool, nolock: bool, overwrite: bool) -> No project_id = UiPathConfig.project_id if not project_id: console.error("UIPATH_PROJECT_ID environment variable not found.") - return studio_client = StudioClient(project_id=project_id) diff --git a/packages/uipath/tests/cli/test_create_resources.py b/packages/uipath/tests/cli/test_create_resources.py new file mode 100644 index 000000000..08012dccc --- /dev/null +++ b/packages/uipath/tests/cli/test_create_resources.py @@ -0,0 +1,414 @@ +"""Unit tests for cli_push.create_resources virtual-resource fallback.""" + +import json +import os +from types import SimpleNamespace +from typing import Any, List, Optional +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from uipath._cli._utils._studio_project import ( + Status, + VirtualResourceResult, +) +from uipath.platform.errors import EnrichedException, FolderNotFoundException + + +def _enriched_exc( + status_code: int = 404, body: bytes = b"not found" +) -> EnrichedException: + """Build an EnrichedException backed by a real HTTPStatusError.""" + request = httpx.Request("GET", "https://example.test/x") + response = httpx.Response(status_code, content=body, request=request) + http_err = httpx.HTTPStatusError("x", request=request, response=response) + return EnrichedException(http_err) + + +class _AsyncIterator: + """Minimal async iterator with aclose() to mimic resource_catalog pagination.""" + + def __init__(self, items: List[Any], raise_exc: Optional[Exception] = None): + self._items = iter(items) + self._raise_exc = raise_exc + self.aclose = AsyncMock() + + def __aiter__(self): + return self + + async def __anext__(self): + if self._raise_exc is not None: + raise self._raise_exc + try: + return next(self._items) + except StopIteration: + raise StopAsyncIteration from None + + +def _make_bindings(resources: List[dict[str, Any]]) -> str: + return json.dumps({"version": "2.2", "resources": resources}) + + +def _asset_binding( + name: str = "my_asset", + folder_path: str = "Shared", + metadata: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + return { + "resource": "asset", + "key": f"binding-{name}", + "value": { + "name": { + "defaultValue": name, + "isExpression": False, + "displayName": "name", + }, + "folderPath": { + "defaultValue": folder_path, + "isExpression": False, + "displayName": "folderPath", + }, + }, + "metadata": metadata, + } + + +def _found_resource( + key: str = "resource-key", + resource_type: str = "asset", + resource_sub_type: str = "stringAsset", + folder_path: str = "Shared", +) -> SimpleNamespace: + folder = SimpleNamespace( + key="folder-key", + fully_qualified_name=folder_path, + path=folder_path, + ) + return SimpleNamespace( + resource_key=key, + resource_type=resource_type, + resource_sub_type=resource_sub_type, + folders=[folder], + ) + + +@pytest.fixture +def bindings_file(tmp_path, monkeypatch): + """Write a bindings file to a tmp path and patch UiPathConfig.bindings_file_path.""" + path = tmp_path / "bindings.json" + + def _writer(content: str) -> str: + path.write_text(content, encoding="utf-8") + return str(path) + + from uipath.platform.common._config import ConfigurationManager + + monkeypatch.setattr( + ConfigurationManager, + "bindings_file_path", + property(lambda self: path), + ) + return _writer + + +@pytest.fixture +def mock_uipath(): + """Patch UiPath() in cli_push to return a mock with resource_catalog + connections. + + cli_push imports `UiPath` lazily from `uipath.platform` inside create_resources, + so we patch the source module. + """ + with patch("uipath.platform.UiPath") as mock_cls: + instance = MagicMock() + instance.resource_catalog = MagicMock() + instance.connections = MagicMock() + mock_cls.return_value = instance + yield instance + + +@pytest.fixture +def studio_client(): + from uipath._cli._utils._studio_project import ( + ResourceBuilderMetadataEntry, + ResourceBuilderMetadataVersion, + ) + + client = MagicMock() + client.create_referenced_resource = AsyncMock() + client.create_virtual_resource = AsyncMock() + supported = ResourceBuilderMetadataVersion(supportsInLineCreation=True) + # /metadata response — every kind our tests use supports inline creation. + client.get_resource_builder_metadata = AsyncMock( + return_value=[ + ResourceBuilderMetadataEntry(kind="asset", versions=[supported]), + ResourceBuilderMetadataEntry(kind="bucket", versions=[supported]), + ResourceBuilderMetadataEntry(kind="queue", versions=[supported]), + ResourceBuilderMetadataEntry(kind="taskCatalog", versions=[supported]), + ] + ) + return client + + +async def _run_create_resources(studio_client): + from uipath._cli.cli_push import create_resources + + await create_resources(studio_client) + + +async def test_catalog_hit_calls_referenced_resource_only( + bindings_file, mock_uipath, studio_client +): + bindings_file(_make_bindings([_asset_binding(metadata={"SubType": "stringAsset"})])) + mock_uipath.resource_catalog.list_by_type_async.return_value = _AsyncIterator( + [_found_resource()] + ) + studio_client.create_referenced_resource.return_value = SimpleNamespace( + status=Status.ADDED + ) + + await _run_create_resources(studio_client) + + studio_client.create_referenced_resource.assert_awaited_once() + studio_client.create_virtual_resource.assert_not_awaited() + + +async def test_catalog_miss_with_subtype_creates_virtual_with_type( + bindings_file, mock_uipath, studio_client +): + bindings_file(_make_bindings([_asset_binding(metadata={"SubType": "stringAsset"})])) + mock_uipath.resource_catalog.list_by_type_async.return_value = _AsyncIterator([]) + studio_client.create_virtual_resource.return_value = VirtualResourceResult( + status=Status.ADDED, + ) + + await _run_create_resources(studio_client) + + studio_client.create_virtual_resource.assert_awaited_once() + req = studio_client.create_virtual_resource.call_args.args[0] + assert req.kind == "asset" + assert req.name == "my_asset" + assert req.type == "stringAsset" + studio_client.create_referenced_resource.assert_not_awaited() + + +async def test_catalog_miss_without_subtype_creates_virtual_kind_only( + bindings_file, mock_uipath, studio_client +): + bindings_file(_make_bindings([_asset_binding(metadata=None)])) + mock_uipath.resource_catalog.list_by_type_async.return_value = _AsyncIterator([]) + studio_client.create_virtual_resource.return_value = VirtualResourceResult( + status=Status.ADDED, + ) + + await _run_create_resources(studio_client) + + studio_client.create_virtual_resource.assert_awaited_once() + req = studio_client.create_virtual_resource.call_args.args[0] + assert req.kind == "asset" + assert req.name == "my_asset" + assert req.type is None + # Body that will actually be sent excludes None → no "type" key. + body = req.model_dump(exclude_none=True) + assert "type" not in body + + +async def test_catalog_miss_metadata_without_subtype_key_creates_virtual_kind_only( + bindings_file, mock_uipath, studio_client +): + bindings_file(_make_bindings([_asset_binding(metadata={"Other": "x"})])) + mock_uipath.resource_catalog.list_by_type_async.return_value = _AsyncIterator([]) + studio_client.create_virtual_resource.return_value = VirtualResourceResult( + status=Status.ADDED, + ) + + await _run_create_resources(studio_client) + + req = studio_client.create_virtual_resource.call_args.args[0] + assert req.type is None + + +async def test_unknown_resource_type_skips_catalog_and_creates_virtual( + bindings_file, mock_uipath, studio_client +): + """Bindings with a resource kind unknown to ResourceType enum but supported + by the virtual endpoint (e.g. 'taskCatalog') should skip the resource + catalog lookup and fall through to the virtual fallback instead of raising + ValueError.""" + task_catalog_binding = { + "resource": "taskCatalog", + "key": "live.good.taskcatalog.Shared", + "value": { + "name": { + "defaultValue": "live.good.taskcatalog", + "isExpression": False, + "displayName": "Name", + }, + "folderPath": { + "defaultValue": "Shared", + "isExpression": False, + "displayName": "Folder Path", + }, + }, + "metadata": None, + } + bindings_file(_make_bindings([task_catalog_binding])) + studio_client.create_virtual_resource.return_value = VirtualResourceResult( + status=Status.ADDED, + ) + + await _run_create_resources(studio_client) + + mock_uipath.resource_catalog.list_by_type_async.assert_not_called() + studio_client.create_virtual_resource.assert_awaited_once() + req = studio_client.create_virtual_resource.call_args.args[0] + assert req.kind == "taskCatalog" + assert req.type is None + + +async def test_unsupported_virtual_kind_is_skipped_with_warning( + bindings_file, mock_uipath, studio_client +): + """Bindings whose kind the virtual endpoint cannot materialize (e.g. + 'entity', 'choiceSet', 'webhook') should be skipped with a warning and + never reach create_virtual_resource.""" + entity_binding = { + "resource": "entity", + "key": "live.good.entity.Shared", + "value": { + "name": { + "defaultValue": "live.good.entity", + "isExpression": False, + "displayName": "Name", + }, + "folderPath": { + "defaultValue": "Shared", + "isExpression": False, + "displayName": "Folder Path", + }, + }, + "metadata": None, + } + bindings_file(_make_bindings([entity_binding])) + + await _run_create_resources(studio_client) + + mock_uipath.resource_catalog.list_by_type_async.assert_not_called() + studio_client.create_virtual_resource.assert_not_awaited() + studio_client.create_referenced_resource.assert_not_awaited() + + +async def test_folder_not_found_falls_back_to_virtual( + bindings_file, mock_uipath, studio_client +): + bindings_file(_make_bindings([_asset_binding(metadata={"SubType": "stringAsset"})])) + mock_uipath.resource_catalog.list_by_type_async.return_value = _AsyncIterator( + [], raise_exc=FolderNotFoundException("missing folder") + ) + studio_client.create_virtual_resource.return_value = VirtualResourceResult( + status=Status.ADDED, + ) + + await _run_create_resources(studio_client) + + studio_client.create_virtual_resource.assert_awaited_once() + req = studio_client.create_virtual_resource.call_args.args[0] + assert req.kind == "asset" + assert req.type == "stringAsset" + + +async def test_virtual_enriched_exception_caught_and_logged_as_warning( + bindings_file, mock_uipath, studio_client +): + bindings_file( + _make_bindings( + [ + _asset_binding(name="first"), + _asset_binding(name="second"), + ] + ) + ) + mock_uipath.resource_catalog.list_by_type_async.return_value = _AsyncIterator([]) + # First binding raises, second succeeds → loop must continue past the raise. + studio_client.create_virtual_resource.side_effect = [ + _enriched_exc(status_code=500, body=b"boom"), + VirtualResourceResult(status=Status.ADDED), + ] + + await _run_create_resources(studio_client) + + assert studio_client.create_virtual_resource.await_count == 2 + + +async def test_connection_branch_unchanged_no_virtual_fallback( + bindings_file, mock_uipath, studio_client +): + """Connection bindings retain old behavior: retrieve_async + warn on miss, no virtual.""" + connection_binding = { + "resource": "connection", + "key": "binding-conn", + "value": { + "ConnectionId": { + "defaultValue": "missing-conn-id", + "isExpression": False, + "displayName": "ConnectionId", + } + }, + "metadata": {"Connector": "salesforce"}, + } + bindings_file(_make_bindings([connection_binding])) + mock_uipath.connections.retrieve_async = AsyncMock(side_effect=_enriched_exc()) + + await _run_create_resources(studio_client) + + mock_uipath.connections.retrieve_async.assert_awaited_once() + studio_client.create_virtual_resource.assert_not_awaited() + studio_client.create_referenced_resource.assert_not_awaited() + + +async def test_guardrail_binding_without_folder_path_is_skipped( + bindings_file, mock_uipath, studio_client +): + # No folderPath in value → guardrail; should be skipped entirely. + guardrail_binding = { + "resource": "asset", + "key": "binding-guard", + "value": { + "name": { + "defaultValue": "g", + "isExpression": False, + "displayName": "name", + } + }, + "metadata": None, + } + bindings_file(_make_bindings([guardrail_binding])) + + await _run_create_resources(studio_client) + + mock_uipath.resource_catalog.list_by_type_async.assert_not_called() + studio_client.create_virtual_resource.assert_not_awaited() + studio_client.create_referenced_resource.assert_not_awaited() + + +# Ensure env doesn't leak solution ids between tests. +@pytest.fixture(autouse=True) +def _reset_solution_id(): + from uipath.platform.common._config import ConfigurationManager + + ConfigurationManager.studio_solution_id = None + yield + ConfigurationManager.studio_solution_id = None + + +# Set minimal env so UiPath() construction inside create_resources (if not mocked +# away cleanly) doesn't trip on missing creds. The mock_uipath fixture patches +# the class, so this is defense-in-depth. +@pytest.fixture(autouse=True) +def _env(monkeypatch): + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "mock_token") + yield + for k in ("UIPATH_URL", "UIPATH_ACCESS_TOKEN"): + if k in os.environ: + monkeypatch.delenv(k, raising=False) diff --git a/packages/uipath/tests/cli/test_push.py b/packages/uipath/tests/cli/test_push.py index d2189098d..d4d30a166 100644 --- a/packages/uipath/tests/cli/test_push.py +++ b/packages/uipath/tests/cli/test_push.py @@ -1928,6 +1928,14 @@ def test_push_with_resources_imports_referenced_resources( json=mock_structure, ) + httpx_mock.add_response( + method="GET", + url=f"{base_url}/studio_/backend/api/resourcebuilder/metadata", + json=[ + {"kind": "asset", "versions": [{"supportsInLineCreation": True}]}, + ], + ) + # Mock getting the solution ID httpx_mock.add_response( method="GET", @@ -2171,7 +2179,7 @@ def test_push_with_ignore_resources_flag_skips_resource_import( assert "Created reference for resource" not in result.output assert "Resource import summary" not in result.output - def test_push_with_resource_not_found_shows_warning( + def test_push_with_resource_not_found_creates_virtual( self, runner: CliRunner, temp_dir: str, @@ -2179,9 +2187,11 @@ def test_push_with_resource_not_found_shows_warning( mock_env_vars: dict[str, str], httpx_mock: HTTPXMock, ) -> None: - """Test that push shows warning when referenced resource is not found in catalog.""" + """When catalog lookup misses, push creates a virtual resource placeholder.""" base_url = "https://cloud.uipath.com/organization" project_id = "test-project-id" + solution_id = "test-solution-id" + tenant_id = "test-tenant-id" mock_structure = { "id": "root", @@ -2226,6 +2236,43 @@ def test_push_with_resource_not_found_shows_warning( json=mock_structure, ) + # Resource Builder metadata — declares which kinds support inline creation. + httpx_mock.add_response( + method="GET", + url=f"{base_url}/studio_/backend/api/resourcebuilder/metadata", + json=[ + {"kind": "asset", "versions": [{"supportsInLineCreation": True}]}, + ], + ) + + # Solution ID lookup for the virtual-resource fallback + httpx_mock.add_response( + method="GET", + url=f"{base_url}/studio_/backend/api/Project/{project_id}", + json={"solutionId": solution_id}, + ) + + # Existing resources in the solution (empty → no conflict with our new virtual) + httpx_mock.add_response( + method="GET", + url=f"{base_url}/studio_/backend/api/resourcebuilder/solutions/{solution_id}/entities", + json={"resources": []}, + ) + + # Virtual resource POST + httpx_mock.add_response( + method="POST", + url=f"{base_url}/studio_/backend/api/resourcebuilder/solutions/{solution_id}/resources/virtual", + json={"key": "virtual-resource-key-123"}, + ) + + # Configuration PATCH after virtual creation + httpx_mock.add_response( + method="PATCH", + url=f"{base_url}/studio_/backend/api/resourcebuilder/solutions/{solution_id}/resources/virtual-resource-key-123/configuration", + json={}, + ) + with runner.isolated_filesystem(temp_dir=temp_dir): # Create required files with open("uipath.json", "w") as f: @@ -2255,6 +2302,7 @@ def test_push_with_resource_not_found_shows_warning( "ActivityName": "retrieve_async", "BindingsVersion": "2.2", "DisplayLabel": "FullName", + "SubType": "stringAsset", }, } ], @@ -2273,6 +2321,7 @@ def test_push_with_resource_not_found_shows_warning( configure_env_vars(mock_env_vars) os.environ["UIPATH_PROJECT_ID"] = project_id + os.environ["UIPATH_TENANT_ID"] = tenant_id # Mock resource catalog list_by_type_async to return no resources async def mock_list_by_type_async_empty(*args, **kwargs): @@ -2291,16 +2340,14 @@ async def mock_list_by_type_async_empty(*args, **kwargs): result = runner.invoke(cli, ["push", "./"]) assert result.exit_code == 0 - # Check that warning was shown for missing resource + # Check that the virtual-resource fallback ran and succeeded assert ( "Importing referenced resources to Studio Web project" in result.output ) - assert ( - "Resource 'missing.asset' of type 'asset' at folder path 'Default' was not found" - in result.output - ) + assert "missing.asset" in result.output + assert "created successfully" in result.output assert "Resource import summary:" in result.output - assert "1 not found" in result.output + assert "1 virtual-created" in result.output def test_push_with_resource_already_exists_shows_unchanged( self, @@ -2359,6 +2406,14 @@ def test_push_with_resource_already_exists_shows_unchanged( json=mock_structure, ) + httpx_mock.add_response( + method="GET", + url=f"{base_url}/studio_/backend/api/resourcebuilder/metadata", + json=[ + {"kind": "asset", "versions": [{"supportsInLineCreation": True}]}, + ], + ) + # Mock getting the solution ID httpx_mock.add_response( method="GET", diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 0d146ae6c..e167bb65c 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.51" +version = "2.10.52" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.34" +version = "0.1.35" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 27646a42a59679a06267f98c40077ea371dce3b5 Mon Sep 17 00:00:00 2001 From: Akshaya Shanbhogue Date: Wed, 22 Apr 2026 08:49:24 -0700 Subject: [PATCH 028/191] fix(mocks): pass invocation as a tuple to avoid arg name collisions (#1585) Co-authored-by: Claude Opus 4.7 (1M context) --- packages/uipath/pyproject.toml | 2 +- .../src/uipath/eval/mocks/_llm_mocker.py | 7 +- .../src/uipath/eval/mocks/_mock_context.py | 6 +- .../uipath/src/uipath/eval/mocks/_mocker.py | 3 +- .../src/uipath/eval/mocks/_mockito_mocker.py | 7 +- .../uipath/src/uipath/eval/mocks/mockable.py | 2 +- .../eval/mocks/test_mockable_arg_collision.py | 107 ++++++++++++++++++ packages/uipath/uv.lock | 2 +- 8 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 packages/uipath/tests/cli/eval/mocks/test_mockable_arg_collision.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index b80902532..ae68beed7 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.52" +version = "2.10.53" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py b/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py index 3715ac226..d1fd2a1c9 100644 --- a/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py +++ b/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py @@ -96,11 +96,16 @@ def __init__(self, context: MockingContext): @traced(name="__mocker__", recording=False) async def response( - self, func: Callable[[T], R], params: dict[str, Any], *args: T, **kwargs + self, + func: Callable[[T], R], + params: dict[str, Any], + invocation: tuple[tuple[Any, ...], dict[str, Any]], ) -> R: """Respond with mocked response generated by an LLM.""" assert isinstance(self.context.strategy, LLMMockingStrategy) + args, kwargs = invocation + function_name = params.get("name") or func.__name__ if function_name in [x.name for x in self.context.strategy.tools_to_simulate]: uipath = UiPath() diff --git a/packages/uipath/src/uipath/eval/mocks/_mock_context.py b/packages/uipath/src/uipath/eval/mocks/_mock_context.py index c2335544f..a50df9cba 100644 --- a/packages/uipath/src/uipath/eval/mocks/_mock_context.py +++ b/packages/uipath/src/uipath/eval/mocks/_mock_context.py @@ -64,11 +64,13 @@ def is_tool_simulated(tool_name: str) -> bool: async def get_mocked_response( - func: Callable[[Any], Any], params: dict[str, Any], *args, **kwargs + func: Callable[[Any], Any], + params: dict[str, Any], + invocation: tuple[tuple[Any, ...], dict[str, Any]], ) -> Any: """Get a mocked response.""" mocker = mocker_context.get() if mocker is None: raise UiPathNoMockFoundError() else: - return await mocker.response(func, params, *args, **kwargs) + return await mocker.response(func, params, invocation) diff --git a/packages/uipath/src/uipath/eval/mocks/_mocker.py b/packages/uipath/src/uipath/eval/mocks/_mocker.py index 57cb8bcc3..99e5da1b2 100644 --- a/packages/uipath/src/uipath/eval/mocks/_mocker.py +++ b/packages/uipath/src/uipath/eval/mocks/_mocker.py @@ -16,8 +16,7 @@ async def response( self, func: Callable[[T], R], params: dict[str, Any], - *args: T, - **kwargs, + invocation: tuple[tuple[Any, ...], dict[str, Any]], ) -> R: """Respond with mocked response.""" raise NotImplementedError() diff --git a/packages/uipath/src/uipath/eval/mocks/_mockito_mocker.py b/packages/uipath/src/uipath/eval/mocks/_mockito_mocker.py index 041478baf..a9b30230f 100644 --- a/packages/uipath/src/uipath/eval/mocks/_mockito_mocker.py +++ b/packages/uipath/src/uipath/eval/mocks/_mockito_mocker.py @@ -99,12 +99,17 @@ def __init__(self, context: MockingContext): stubbed = stubbed.thenRaise(_resolve_value(answer_dict["value"])) async def response( - self, func: Callable[[T], R], params: dict[str, Any], *args: T, **kwargs + self, + func: Callable[[T], R], + params: dict[str, Any], + invocation: tuple[tuple[Any, ...], dict[str, Any]], ) -> R: """Return mocked response or raise appropriate errors.""" if not isinstance(self.context.strategy, MockitoMockingStrategy): raise UiPathMockResponseGenerationError("Mocking strategy misconfigured.") + args, kwargs = invocation + # No behavior configured → call real function is_mocked = any( behavior.function == params["name"] diff --git a/packages/uipath/src/uipath/eval/mocks/mockable.py b/packages/uipath/src/uipath/eval/mocks/mockable.py index 254f88b89..3e9a324b9 100644 --- a/packages/uipath/src/uipath/eval/mocks/mockable.py +++ b/packages/uipath/src/uipath/eval/mocks/mockable.py @@ -39,7 +39,7 @@ def mocked_response_decorator(func, params: dict[str, Any]): """Mocked response decorator.""" async def mock_response_generator(*args, **kwargs): - mocked_response = await get_mocked_response(func, params, *args, **kwargs) + mocked_response = await get_mocked_response(func, params, (args, kwargs)) # Mocking successful. context = UiPathSpanUtils.get_parent_context() diff --git a/packages/uipath/tests/cli/eval/mocks/test_mockable_arg_collision.py b/packages/uipath/tests/cli/eval/mocks/test_mockable_arg_collision.py new file mode 100644 index 000000000..838e9d835 --- /dev/null +++ b/packages/uipath/tests/cli/eval/mocks/test_mockable_arg_collision.py @@ -0,0 +1,107 @@ +"""Regression tests: @mockable must not collide with user args named `func`/`params`.""" + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from uipath.eval.mocks import mockable +from uipath.eval.mocks._mock_runtime import ( + clear_execution_context, + set_execution_context, +) +from uipath.eval.mocks._types import MockingContext +from uipath.eval.models.evaluation_set import EvaluationItem + +_mock_span_collector = MagicMock() + + +def _build_evaluation( + function_name: str, kwargs: dict[str, Any], value: Any +) -> EvaluationItem: + evaluation_item: dict[str, Any] = { + "id": "evaluation-id", + "name": "Test evaluation", + "inputs": {}, + "evaluationCriterias": {"ExactMatchEvaluator": None}, + "mockingStrategy": { + "type": "mockito", + "behaviors": [ + { + "function": function_name, + "arguments": {"args": [], "kwargs": kwargs}, + "then": [{"type": "return", "value": value}], + } + ], + }, + } + return EvaluationItem(**evaluation_item) + + +class TestMockableArgCollision: + """Ensure `@mockable` works when the wrapped function has args named `func` or `params`.""" + + def test_sync_function_with_func_and_params_args(self): + """A sync mockable function that takes `func` and `params` kwargs should not raise.""" + + @mockable() + def test_function(func: str, params: dict[str, Any]) -> str: + raise NotImplementedError() + + evaluation = _build_evaluation( + "test_function", + kwargs={"func": "some_func", "params": {"k": "v"}}, + value="mocked_result", + ) + + set_execution_context( + MockingContext( + strategy=evaluation.mocking_strategy, + name=evaluation.name, + inputs=evaluation.inputs, + ), + _mock_span_collector, + "test-execution-id", + ) + + try: + with patch("uipath.eval.mocks.mockable.UiPathSpanUtils"): + with patch("uipath.eval.mocks.mockable.trace"): + result = test_function(func="some_func", params={"k": "v"}) + + assert result == "mocked_result" + finally: + clear_execution_context() + + @pytest.mark.asyncio + async def test_async_function_with_func_and_params_args(self): + """An async mockable function that takes `func` and `params` kwargs should not raise.""" + + @mockable() + async def test_function(func: str, params: dict[str, Any]) -> str: + raise NotImplementedError() + + evaluation = _build_evaluation( + "test_function", + kwargs={"func": "some_func", "params": {"k": "v"}}, + value="mocked_result", + ) + + set_execution_context( + MockingContext( + strategy=evaluation.mocking_strategy, + name=evaluation.name, + inputs=evaluation.inputs, + ), + _mock_span_collector, + "test-execution-id", + ) + + try: + with patch("uipath.eval.mocks.mockable.UiPathSpanUtils"): + with patch("uipath.eval.mocks.mockable.trace"): + result = await test_function(func="some_func", params={"k": "v"}) + + assert result == "mocked_result" + finally: + clear_execution_context() diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index e167bb65c..d922431c1 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.52" +version = "2.10.53" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From d1122b9d4a4d3770afd655b581b56b75217b44e5 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Thu, 23 Apr 2026 14:27:35 +0200 Subject: [PATCH 029/191] feat(debug): add --attach flag for non-interactive debug runs (#1587) --- packages/uipath/pyproject.toml | 4 +- .../uipath/src/uipath/_cli/_debug/_bridge.py | 23 +++++-- packages/uipath/src/uipath/_cli/cli_debug.py | 22 ++++++- .../tests/cli/test_debug_bridge_selection.py | 64 +++++++++++++++++++ packages/uipath/uv.lock | 10 +-- 5 files changed, 110 insertions(+), 13 deletions(-) create mode 100644 packages/uipath/tests/cli/test_debug_bridge_selection.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index ae68beed7..a8d04271e 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "uipath" -version = "2.10.53" +version = "2.10.54" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.8, <0.6.0", - "uipath-runtime>=0.10.0, <0.11.0", + "uipath-runtime>=0.10.1, <0.11.0", "uipath-platform>=0.1.13, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", diff --git a/packages/uipath/src/uipath/_cli/_debug/_bridge.py b/packages/uipath/src/uipath/_cli/_debug/_bridge.py index 9607398a0..2ad4e0418 100644 --- a/packages/uipath/src/uipath/_cli/_debug/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_debug/_bridge.py @@ -19,9 +19,15 @@ UiPathRuntimeResult, UiPathRuntimeStatus, ) -from uipath.runtime.debug import UiPathDebugProtocol, UiPathDebugQuitError +from uipath.runtime.debug import ( + DetachedDebugBridge, + UiPathDebugProtocol, + UiPathDebugQuitError, +) from uipath.runtime.events import UiPathRuntimeStateEvent, UiPathRuntimeStatePhase +DebugAttachMode = Literal["signalr", "console", "none"] + logger = logging.getLogger(__name__) @@ -871,18 +877,27 @@ def get_remote_debug_bridge(context: UiPathRuntimeContext) -> UiPathDebugProtoco def get_debug_bridge( - context: UiPathRuntimeContext, verbose: bool = True + context: UiPathRuntimeContext, + verbose: bool = True, + attach: DebugAttachMode | None = None, ) -> UiPathDebugProtocol: """Factory to get appropriate debug bridge based on context. Args: context: The runtime context containing debug configuration. verbose: If True, console bridge shows all state updates. If False, only breakpoints. + attach: Explicit attach mode. When None, falls back to + ``context.job_id``-based selection. Returns: An instance of UiPathDebugBridge suitable for the context. """ - if context.job_id: + if attach == "none": + return DetachedDebugBridge() + if attach == "signalr": return get_remote_debug_bridge(context) - else: + if attach == "console": return ConsoleDebugBridge(verbose=verbose) + if context.job_id: + return get_remote_debug_bridge(context) + return ConsoleDebugBridge(verbose=verbose) diff --git a/packages/uipath/src/uipath/_cli/cli_debug.py b/packages/uipath/src/uipath/_cli/cli_debug.py index d2e08353b..cd9042b78 100644 --- a/packages/uipath/src/uipath/_cli/cli_debug.py +++ b/packages/uipath/src/uipath/_cli/cli_debug.py @@ -1,10 +1,11 @@ import asyncio import logging +from typing import cast, get_args import click from uipath._cli._chat._bridge import get_chat_bridge -from uipath._cli._debug._bridge import get_debug_bridge +from uipath._cli._debug._bridge import DebugAttachMode, get_debug_bridge from uipath._cli._utils._debug import setup_debugging from uipath._cli._utils._studio_project import StudioClient from uipath.core.tracing import UiPathTraceManager @@ -64,6 +65,15 @@ default=5678, help="Port for the debug server (default: 5678)", ) +@click.option( + "--attach", + type=click.Choice(list(get_args(DebugAttachMode)), case_sensitive=False), + default=None, + help=( + "Debugger attach mode. Defaults to 'signalr' for cloud runs, " + "'console' for local runs." + ), +) @track_command("debug") def debug( entrypoint: str | None, @@ -74,6 +84,7 @@ def debug( output_file: str | None, debug: bool, debug_port: int, + attach: str | None, ) -> None: """Debug the project.""" input_file = file or input_file @@ -81,6 +92,10 @@ def debug( if not setup_debugging(debug, debug_port): console.error(f"Failed to start debug server on port {debug_port}") + attach_mode: DebugAttachMode | None = ( + cast(DebugAttachMode, attach.lower()) if attach else None + ) + result = Middlewares.next( "debug", entrypoint, @@ -90,6 +105,7 @@ def debug( output_file=output_file, debug=debug, debug_port=debug_port, + attach=attach_mode, ) if result.error_message: @@ -141,7 +157,9 @@ async def execute_debug_runtime(): async def execute_debug_runtime(): chat_runtime: UiPathRuntimeProtocol | None = None - debug_bridge: UiPathDebugProtocol = get_debug_bridge(ctx) + debug_bridge: UiPathDebugProtocol = get_debug_bridge( + ctx, attach=attach_mode + ) runtime = await factory.new_runtime( entrypoint, diff --git a/packages/uipath/tests/cli/test_debug_bridge_selection.py b/packages/uipath/tests/cli/test_debug_bridge_selection.py new file mode 100644 index 000000000..732461c77 --- /dev/null +++ b/packages/uipath/tests/cli/test_debug_bridge_selection.py @@ -0,0 +1,64 @@ +"""Tests for `get_debug_bridge()` selection matrix. + +Locks in the non-breaking-change contract: absence of `attach` preserves the +legacy `job_id`-based selection. Explicit `attach` overrides that selection. +""" + +from __future__ import annotations + +import pytest + +from uipath._cli._debug._bridge import ( + ConsoleDebugBridge, + SignalRDebugBridge, + get_debug_bridge, +) +from uipath.runtime import UiPathRuntimeContext +from uipath.runtime.debug import DetachedDebugBridge + + +def _ctx(**overrides) -> UiPathRuntimeContext: + return UiPathRuntimeContext(**overrides) + + +def test_attach_none_returns_detached_bridge_without_job_id(): + bridge = get_debug_bridge(_ctx(), attach="none") + assert isinstance(bridge, DetachedDebugBridge) + + +def test_attach_none_returns_detached_bridge_even_when_job_id_set(monkeypatch): + """'none' wins over job_id — this is the whole point of the flag.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + bridge = get_debug_bridge(_ctx(job_id="job-123"), attach="none") + assert isinstance(bridge, DetachedDebugBridge) + + +def test_attach_signalr_forces_signalr_bridge(monkeypatch): + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + bridge = get_debug_bridge(_ctx(job_id="job-123"), attach="signalr") + assert isinstance(bridge, SignalRDebugBridge) + + +def test_attach_console_forces_console_bridge_even_when_job_id_set(monkeypatch): + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + bridge = get_debug_bridge(_ctx(job_id="job-123"), attach="console") + assert isinstance(bridge, ConsoleDebugBridge) + + +def test_legacy_selection_signalr_when_job_id_set_and_no_attach(monkeypatch): + """Non-breaking change assertion: absence of `attach` preserves today's behavior.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + bridge = get_debug_bridge(_ctx(job_id="job-123")) + assert isinstance(bridge, SignalRDebugBridge) + + +def test_legacy_selection_console_when_no_job_id_and_no_attach(): + """Non-breaking change assertion: absence of `attach` preserves today's behavior.""" + bridge = get_debug_bridge(_ctx()) + assert isinstance(bridge, ConsoleDebugBridge) + + +def test_attach_signalr_without_job_id_raises(): + """Explicit signalr without job_id is a user error — surface it loudly.""" + with pytest.raises(ValueError, match="UIPATH_URL and UIPATH_JOB_KEY"): + get_debug_bridge(_ctx(), attach="signalr") diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index d922431c1..22bd59925 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.53" +version = "2.10.54" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2616,7 +2616,7 @@ requires-dist = [ { name = "truststore", specifier = ">=0.10.1" }, { name = "uipath-core", editable = "../uipath-core" }, { name = "uipath-platform", editable = "../uipath-platform" }, - { name = "uipath-runtime", specifier = ">=0.10.0,<0.11.0" }, + { name = "uipath-runtime", specifier = ">=0.10.1,<0.11.0" }, ] [package.metadata.requires-dev] @@ -2720,14 +2720,14 @@ dev = [ [[package]] name = "uipath-runtime" -version = "0.10.0" +version = "0.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/75/64/69462ee01a5607ce36b1fa152c52ac72fb28abe0aa049394406fc0b31525/uipath_runtime-0.10.0.tar.gz", hash = "sha256:d27d58e2252f506c8c0e00f814b37c3863150e8ffcde8e4c6ab14bd98febd3df", size = 139626, upload-time = "2026-03-24T19:42:43.738Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/87/2e625219b3364a7153549e6056bce41d2050725ed0844f2711c414a872c0/uipath_runtime-0.10.1.tar.gz", hash = "sha256:9ed1bdb6737ad64cc5bb7ef0c8466dbae8ca010858ecd856818396ea264eb3d5", size = 141189, upload-time = "2026-04-23T11:34:53.102Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/ed/9c0e97a078b96e4d3742ea3515cb30886b08579cd08077cd42a159adf70d/uipath_runtime-0.10.0-py3-none-any.whl", hash = "sha256:4f52df0b56f54e70fcf34fbf74e223d02b97b5a6fd6d8f64bc06782bb5484b07", size = 42097, upload-time = "2026-03-24T19:42:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/ad/41/bc3465ee89dd01f8a9045d7d22d0f0927c0d437242eeded8d3d5b33f50ed/uipath_runtime-0.10.1-py3-none-any.whl", hash = "sha256:f04483db92ee7683513762a79bf48c229c7133d5adc7fef10ea5eaa4c7ce9b29", size = 43057, upload-time = "2026-04-23T11:34:51.781Z" }, ] [[package]] From 28ad47c572e73a201627de12d95ea7037f879ebc Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Thu, 23 Apr 2026 16:33:34 +0200 Subject: [PATCH 030/191] docs(cli): document debug and eval commands (#1588) --- packages/uipath/docs/cli/index.md | 89 +++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/packages/uipath/docs/cli/index.md b/packages/uipath/docs/cli/index.md index 336040742..9e8401e8c 100644 --- a/packages/uipath/docs/cli/index.md +++ b/packages/uipath/docs/cli/index.md @@ -308,3 +308,92 @@ Processing: uipath.json File 'uipath.json' is up to date ✓ Project pulled successfully ``` +--- + +::: mkdocs-click + :module: uipath._cli + :command: debug + :depth: 1 + :style: table + +Runs your agent under the debug runtime, with a debug bridge attached. Locally, the bridge is the interactive **console** (read commands from stdin, stop at breakpoints). In the cloud, the bridge is **SignalR** (driven by Studio Web / Orchestrator). The `--attach` flag lets you override that default, including `none` for executors that need the debug command's surrounding behavior (bindings fetch, state streaming) but cannot speak the interactive debug protocol. + +### Attach modes + +| Mode | When to use | +|------|-------------| +| `signalr` | Remote runs driven by Studio Web / Orchestrator. Default when `job_id` is set. | +| `console` | Local interactive debugging from the terminal. Default when no `job_id`. | +| `none` | Run under the debug command without attaching a debugger. No wait-for-start gate, no breakpoints, no step mode. | + +/// info +`--attach` selects the **debug bridge**. It's unrelated to `--debug`, which starts a `debugpy` server for Python-level breakpoints in your IDE. The two can be combined. +/// + + + +```shell +> uipath debug main '{"message": "test"}' +Debug Mode Commands + c, continue Continue until next breakpoint + s, step Step to next node + b Set breakpoint at + l, list List all breakpoints + r Remove breakpoint at + h, help Show help + q, quit Exit debugger +▶ START +> b analyze_sentiment +✓ Breakpoint set at: analyze_sentiment +> c +──────────────────────────────────────── +■ BREAKPOINT analyze_sentiment (before) +Next: analyze_sentiment +──────────────────────────────────────── +> s +● analyze_sentiment +> c +✓ Execution completed +``` +--- + +::: mkdocs-click + :module: uipath._cli + :command: eval + :depth: 1 + :style: table + +Runs an evaluation set against your agent. Entry point and eval set are auto-discovered from the project if not passed explicitly. Evaluations run in parallel (see `--workers`) and, unless `--no-report` is passed, results are reported back to Studio Web when `UIPATH_PROJECT_ID` is set. + +### Common flags + +| Flag | Purpose | +|------|---------| +| `--eval-ids` | Run only a subset of evaluations by id. | +| `--workers` | Parallel workers for running evaluations (default 1). | +| `--no-report` | Skip reporting results back to UiPath. | +| `--enable-mocker-cache` | Cache LLM mocker responses across runs. | +| `--input-overrides` | Per-eval input overrides, merged into the eval's input. | +| `--trace-file` | Write OpenTelemetry traces to a JSONL file for offline inspection. | +| `--resume` | Resume evaluation from a previous suspended state. | + + + +```shell +> uipath eval +⠋ Running evaluations ... + Weather in Paris + LLM Judge Output 0.7 + Tool Call Arguments 1.0 + Tool Call Count 1.0 + Tool Call Order 1.0 + +Evaluation Results +┏━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┓ +┃ Evaluation ┃ LLM Judge Output ┃ Tool Call Args ┃ Tool Call Count ┃ Tool Call Order ┃ +┡━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━┩ +│ Weather in Paris │ 0.7 │ 1.0 │ 1.0 │ 1.0 │ +├────────────────────┼────────────────────┼────────────────────┼────────────────────┼────────────────────┤ +│ Average │ 0.7 │ 1.0 │ 1.0 │ 1.0 │ +└────────────────────┴────────────────────┴────────────────────┴────────────────────┴────────────────────┘ +``` From 59e427ea65256752079ee707d79bda6eecca792a Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Thu, 23 Apr 2026 15:12:27 -0700 Subject: [PATCH 031/191] feat: add memorySpace to resource overwrite types (#1586) Co-authored-by: Claude Opus 4.6 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../uipath-platform/src/uipath/platform/common/_bindings.py | 1 + packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index abbc9afa9..914a16e13 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.35" +version = "0.1.36" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/common/_bindings.py b/packages/uipath-platform/src/uipath/platform/common/_bindings.py index 224cae425..a93880896 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_bindings.py +++ b/packages/uipath-platform/src/uipath/platform/common/_bindings.py @@ -60,6 +60,7 @@ class GenericResourceOverwrite(ResourceOverwrite): "mcpServer", "queue", "remoteA2aAgent", + "memorySpace", ] name: str = Field(alias="name") folder_path: str = Field(alias="folderPath") diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index ff4ccc1f5..079a3dd27 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.35" +version = "0.1.36" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 22bd59925..0839674b1 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.35" +version = "0.1.36" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 64399bfb7c8c78da1971ba4f73747ba5da1315f2 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Fri, 24 Apr 2026 11:07:59 +0200 Subject: [PATCH 032/191] fix: handle debug signalR bridge race condition (#1590) --- packages/uipath/pyproject.toml | 2 +- packages/uipath/src/uipath/_cli/_debug/_bridge.py | 3 +++ packages/uipath/uv.lock | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index a8d04271e..1bc9f87c0 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.54" +version = "2.10.55" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/_debug/_bridge.py b/packages/uipath/src/uipath/_cli/_debug/_bridge.py index 2ad4e0418..cfd286b5c 100644 --- a/packages/uipath/src/uipath/_cli/_debug/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_debug/_bridge.py @@ -747,6 +747,9 @@ async def _handle_start(self, args: list[Any]) -> None: f"Debug started: breakpoints={self.state.breakpoints}, step_mode={step_mode}" ) + # handle race conditions, runtime connected to debug bridge before the receiver + await self.emit_execution_started() + async def _handle_resume(self, args: list[Any]) -> None: """Handle Resume command from SignalR server. diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 0839674b1..880aa92f7 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.54" +version = "2.10.55" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From f82df052506aeca37167dca80bdddfe0a8ef560b Mon Sep 17 00:00:00 2001 From: CalebMartinUiPath <106194045+CalebMartinUiPath@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:11:32 -0700 Subject: [PATCH 033/191] fix: batch transform failure handling (#1573) --- packages/uipath-platform/pyproject.toml | 2 +- .../_context_grounding_service.py | 9 +++++ .../src/uipath/platform/errors/__init__.py | 3 ++ .../_batch_transform_failed_exception.py | 10 ++++++ .../platform/resume_triggers/_protocol.py | 6 ++++ .../tests/services/test_hitl.py | 35 +++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 8 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/errors/_batch_transform_failed_exception.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 914a16e13..f25b5cf88 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.36" +version = "0.1.37" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py b/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py index e577c5082..706394aca 100644 --- a/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py +++ b/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py @@ -17,6 +17,7 @@ ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE, ) from ..errors import ( + BatchTransformFailedException, BatchTransformNotCompleteException, IngestionInProgressException, UnsupportedDataSourceException, @@ -1050,6 +1051,10 @@ def download_batch_transform_result( batch_transform = self.retrieve_batch_transform( id=id, index_name=index_name ) + if batch_transform.last_batch_rag_status == BatchTransformStatus.FAILED: + raise BatchTransformFailedException( + batch_transform_id=id, + ) if batch_transform.last_batch_rag_status != BatchTransformStatus.SUCCESSFUL: raise BatchTransformNotCompleteException( batch_transform_id=id, @@ -1109,6 +1114,10 @@ async def download_batch_transform_result_async( batch_transform = await self.retrieve_batch_transform_async( id=id, index_name=index_name ) + if batch_transform.last_batch_rag_status == BatchTransformStatus.FAILED: + raise BatchTransformFailedException( + batch_transform_id=id, + ) if batch_transform.last_batch_rag_status != BatchTransformStatus.SUCCESSFUL: raise BatchTransformNotCompleteException( batch_transform_id=id, diff --git a/packages/uipath-platform/src/uipath/platform/errors/__init__.py b/packages/uipath-platform/src/uipath/platform/errors/__init__.py index 3c446fe14..58afd93f7 100644 --- a/packages/uipath-platform/src/uipath/platform/errors/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/errors/__init__.py @@ -8,6 +8,7 @@ - FolderNotFoundException: Raised when a folder cannot be found - UnsupportedDataSourceException: Raised when an operation is attempted on an unsupported data source type - IngestionInProgressException: Raised when a search is attempted on an index during ingestion +- BatchTransformFailedException: Raised when a batch transform has failed - BatchTransformNotCompleteException: Raised when attempting to get results from an incomplete batch transform - OperationNotCompleteException: Raised when attempting to get results from an incomplete operation - OperationFailedException: Raised when an operation has failed @@ -15,6 +16,7 @@ """ from ._base_url_missing_error import BaseUrlMissingError +from ._batch_transform_failed_exception import BatchTransformFailedException from ._batch_transform_not_complete_exception import BatchTransformNotCompleteException from ._enriched_exception import EnrichedException, ExtractedErrorInfo from ._folder_not_found_exception import FolderNotFoundException @@ -26,6 +28,7 @@ __all__ = [ "BaseUrlMissingError", + "BatchTransformFailedException", "BatchTransformNotCompleteException", "EnrichedException", "ExtractedErrorInfo", diff --git a/packages/uipath-platform/src/uipath/platform/errors/_batch_transform_failed_exception.py b/packages/uipath-platform/src/uipath/platform/errors/_batch_transform_failed_exception.py new file mode 100644 index 000000000..67088b0f1 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/_batch_transform_failed_exception.py @@ -0,0 +1,10 @@ +class BatchTransformFailedException(Exception): + """Raised when a batch transform has failed. + + This exception is raised when a batch transform task has completed + with a failed status, as opposed to still being in progress. + """ + + def __init__(self, batch_transform_id: str): + self.message = f"Batch transform '{batch_transform_id}' failed." + super().__init__(self.message) diff --git a/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py b/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py index 60a169da9..98e4f81a2 100644 --- a/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py +++ b/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py @@ -53,6 +53,7 @@ ContextGroundingIndex, ) from uipath.platform.errors import ( + BatchTransformFailedException, BatchTransformNotCompleteException, OperationNotCompleteException, ) @@ -323,6 +324,11 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: "index_name", trigger.payload ), ) + except BatchTransformFailedException as e: + raise UiPathFaultedTriggerError( + ErrorCategory.SYSTEM, + f"{e.message}", + ) from e except BatchTransformNotCompleteException as e: raise UiPathPendingTriggerError( ErrorCategory.SYSTEM, diff --git a/packages/uipath-platform/tests/services/test_hitl.py b/packages/uipath-platform/tests/services/test_hitl.py index aa288137d..2e8069a3e 100644 --- a/packages/uipath-platform/tests/services/test_hitl.py +++ b/packages/uipath-platform/tests/services/test_hitl.py @@ -809,6 +809,41 @@ async def test_read_batch_rag_trigger_pending( reader = UiPathResumeTriggerReader() await reader.read_trigger(resume_trigger) + @pytest.mark.anyio + async def test_read_batch_rag_trigger_failed( + self, + setup_test_env: None, + ) -> None: + """Test reading a failed batch rag trigger raises faulted error.""" + from uipath.core.errors import UiPathFaultedTriggerError + + from uipath.platform.errors import BatchTransformFailedException + + task_id = "test-batch-rag-id" + destination_path = "test/output.xlsx" + mock_download_async = AsyncMock( + side_effect=BatchTransformFailedException(task_id) + ) + + with patch( + "uipath.platform.context_grounding._context_grounding_service.ContextGroundingService.download_batch_transform_result_async", + new=mock_download_async, + ): + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.BATCH_RAG, + item_key=task_id, + folder_key="test-folder", + folder_path="test-path", + payload={ + "index_name": "test-index", + "destination_path": destination_path, + }, + ) + + with pytest.raises(UiPathFaultedTriggerError): + reader = UiPathResumeTriggerReader() + await reader.read_trigger(resume_trigger) + @pytest.mark.anyio async def test_read_ephemeral_index_trigger_successful( self, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 079a3dd27..11138cfbd 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.36" +version = "0.1.37" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 880aa92f7..2bc995e74 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.36" +version = "0.1.37" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From cc24c8eb783b5a28f99d21491c63a46a9c74d358 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Fri, 24 Apr 2026 15:55:36 -0700 Subject: [PATCH 034/191] feat: add @resource_override and folder_path support to MemoryService (#1591) Co-authored-by: Claude Opus 4.6 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../uipath/platform/memory/_memory_service.py | 62 ++++++++++++++----- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 4 files changed, 50 insertions(+), 18 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index f25b5cf88..e32ca5495 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.37" +version = "0.1.38" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py index 89ec86a25..73d788f80 100644 --- a/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py +++ b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py @@ -10,6 +10,7 @@ from uipath.core.tracing import traced from ..common._base_service import BaseService +from ..common._bindings import resource_override from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import FolderContext, header_folder @@ -50,6 +51,7 @@ def __init__( # ── Memory space operations (ECS) ────────────────────────────────── + @resource_override(resource_type="memorySpace") @traced(name="memory_create", run_type="uipath") def create( self, @@ -57,6 +59,7 @@ def create( description: Optional[str] = None, is_encrypted: Optional[bool] = None, folder_key: Optional[str] = None, + folder_path: Optional[str] = None, ) -> MemorySpace: """Create a new memory space. @@ -65,11 +68,14 @@ def create( description: Optional description (max 1024 chars). is_encrypted: Whether the memory space should be encrypted. folder_key: The folder key for the operation. + folder_path: The folder path for the operation. Returns: MemorySpace: The created memory space. """ - spec = self._create_spec(name, description, is_encrypted, folder_key) + spec = self._create_spec( + name, description, is_encrypted, folder_key, folder_path + ) response = self.request( spec.method, spec.endpoint, @@ -78,6 +84,7 @@ def create( ).json() return MemorySpace.model_validate(response) + @resource_override(resource_type="memorySpace") @traced(name="memory_create", run_type="uipath") async def create_async( self, @@ -85,6 +92,7 @@ async def create_async( description: Optional[str] = None, is_encrypted: Optional[bool] = None, folder_key: Optional[str] = None, + folder_path: Optional[str] = None, ) -> MemorySpace: """Asynchronously create a new memory space. @@ -93,11 +101,14 @@ async def create_async( description: Optional description (max 1024 chars). is_encrypted: Whether the memory space should be encrypted. folder_key: The folder key for the operation. + folder_path: The folder path for the operation. Returns: MemorySpace: The created memory space. """ - spec = self._create_spec(name, description, is_encrypted, folder_key) + spec = self._create_spec( + name, description, is_encrypted, folder_key, folder_path + ) response = ( await self.request_async( spec.method, @@ -116,6 +127,7 @@ def list( top: Optional[int] = None, skip: Optional[int] = None, folder_key: Optional[str] = None, + folder_path: Optional[str] = None, ) -> MemorySpaceListResponse: """List memory spaces with optional OData query parameters. @@ -125,11 +137,12 @@ def list( top: Maximum number of results. skip: Number of results to skip. folder_key: The folder key for the operation. + folder_path: The folder path for the operation. Returns: MemorySpaceListResponse: The list of memory spaces. """ - spec = self._list_spec(filter, orderby, top, skip, folder_key) + spec = self._list_spec(filter, orderby, top, skip, folder_key, folder_path) response = self.request( spec.method, spec.endpoint, @@ -146,6 +159,7 @@ async def list_async( top: Optional[int] = None, skip: Optional[int] = None, folder_key: Optional[str] = None, + folder_path: Optional[str] = None, ) -> MemorySpaceListResponse: """Asynchronously list memory spaces. @@ -155,11 +169,12 @@ async def list_async( top: Maximum number of results. skip: Number of results to skip. folder_key: The folder key for the operation. + folder_path: The folder path for the operation. Returns: MemorySpaceListResponse: The list of memory spaces. """ - spec = self._list_spec(filter, orderby, top, skip, folder_key) + spec = self._list_spec(filter, orderby, top, skip, folder_key, folder_path) response = ( await self.request_async( spec.method, @@ -178,6 +193,7 @@ def search( memory_space_id: str, request: MemorySearchRequest, folder_key: Optional[str] = None, + folder_path: Optional[str] = None, ) -> MemorySearchResponse: """Search a memory space via LLMOps. @@ -188,11 +204,12 @@ def search( memory_space_id: The GUID of the memory space. request: The search request payload. folder_key: The folder key for the operation. + folder_path: The folder path for the operation. Returns: MemorySearchResponse: Results, metadata, and system prompt injection. """ - spec = self._search_spec(memory_space_id, folder_key) + spec = self._search_spec(memory_space_id, folder_key, folder_path) response = self.request( spec.method, spec.endpoint, @@ -207,6 +224,7 @@ async def search_async( memory_space_id: str, request: MemorySearchRequest, folder_key: Optional[str] = None, + folder_path: Optional[str] = None, ) -> MemorySearchResponse: """Asynchronously search a memory space via LLMOps. @@ -217,11 +235,12 @@ async def search_async( memory_space_id: The GUID of the memory space. request: The search request payload. folder_key: The folder key for the operation. + folder_path: The folder path for the operation. Returns: MemorySearchResponse: Results, metadata, and system prompt injection. """ - spec = self._search_spec(memory_space_id, folder_key) + spec = self._search_spec(memory_space_id, folder_key, folder_path) response = ( await self.request_async( spec.method, @@ -240,6 +259,7 @@ def escalation_search( memory_space_id: str, request: MemorySearchRequest, folder_key: Optional[str] = None, + folder_path: Optional[str] = None, ) -> EscalationMemorySearchResponse: """Search escalation memory for previously resolved outcomes. @@ -250,11 +270,12 @@ def escalation_search( memory_space_id: The GUID of the memory space. request: The search request payload (same as regular search). folder_key: The folder key for the operation. + folder_path: The folder path for the operation. Returns: EscalationMemorySearchResponse: Matched escalation outcomes. """ - spec = self._escalation_search_spec(memory_space_id, folder_key) + spec = self._escalation_search_spec(memory_space_id, folder_key, folder_path) response = self.request( spec.method, spec.endpoint, @@ -269,6 +290,7 @@ async def escalation_search_async( memory_space_id: str, request: MemorySearchRequest, folder_key: Optional[str] = None, + folder_path: Optional[str] = None, ) -> EscalationMemorySearchResponse: """Asynchronously search escalation memory for previously resolved outcomes. @@ -279,11 +301,12 @@ async def escalation_search_async( memory_space_id: The GUID of the memory space. request: The search request payload (same as regular search). folder_key: The folder key for the operation. + folder_path: The folder path for the operation. Returns: EscalationMemorySearchResponse: Matched escalation outcomes. """ - spec = self._escalation_search_spec(memory_space_id, folder_key) + spec = self._escalation_search_spec(memory_space_id, folder_key, folder_path) response = ( await self.request_async( spec.method, @@ -300,6 +323,7 @@ def escalation_ingest( memory_space_id: str, request: EscalationMemoryIngestRequest, folder_key: Optional[str] = None, + folder_path: Optional[str] = None, ) -> None: """Ingest a resolved escalation outcome into memory. @@ -310,8 +334,9 @@ def escalation_ingest( memory_space_id: The GUID of the memory space. request: The escalation ingest payload. folder_key: The folder key for the operation. + folder_path: The folder path for the operation. """ - spec = self._escalation_ingest_spec(memory_space_id, folder_key) + spec = self._escalation_ingest_spec(memory_space_id, folder_key, folder_path) self.request( spec.method, spec.endpoint, @@ -325,6 +350,7 @@ async def escalation_ingest_async( memory_space_id: str, request: EscalationMemoryIngestRequest, folder_key: Optional[str] = None, + folder_path: Optional[str] = None, ) -> None: """Asynchronously ingest a resolved escalation outcome into memory. @@ -335,8 +361,9 @@ async def escalation_ingest_async( memory_space_id: The GUID of the memory space. request: The escalation ingest payload. folder_key: The folder key for the operation. + folder_path: The folder path for the operation. """ - spec = self._escalation_ingest_spec(memory_space_id, folder_key) + spec = self._escalation_ingest_spec(memory_space_id, folder_key, folder_path) await self.request_async( spec.method, spec.endpoint, @@ -379,8 +406,9 @@ def _create_spec( description: Optional[str], is_encrypted: Optional[bool], folder_key: Optional[str] = None, + folder_path: Optional[str] = None, ) -> RequestSpec: - folder_key = self._resolve_folder(folder_key) + folder_key = self._resolve_folder(folder_key, folder_path) body = MemorySpaceCreateRequest( name=name, description=description, @@ -400,8 +428,9 @@ def _list_spec( top: Optional[int], skip: Optional[int], folder_key: Optional[str] = None, + folder_path: Optional[str] = None, ) -> RequestSpec: - folder_key = self._resolve_folder(folder_key) + folder_key = self._resolve_folder(folder_key, folder_path) params: dict[str, Any] = {} if filter is not None: params["$filter"] = filter @@ -424,8 +453,9 @@ def _search_spec( self, memory_space_id: str, folder_key: Optional[str] = None, + folder_path: Optional[str] = None, ) -> RequestSpec: - folder_key = self._resolve_folder(folder_key) + folder_key = self._resolve_folder(folder_key, folder_path) return RequestSpec( method="POST", endpoint=Endpoint(f"{_LLMOPS_AGENT_BASE}/{memory_space_id}/search"), @@ -436,8 +466,9 @@ def _escalation_search_spec( self, memory_space_id: str, folder_key: Optional[str] = None, + folder_path: Optional[str] = None, ) -> RequestSpec: - folder_key = self._resolve_folder(folder_key) + folder_key = self._resolve_folder(folder_key, folder_path) return RequestSpec( method="POST", endpoint=Endpoint( @@ -450,8 +481,9 @@ def _escalation_ingest_spec( self, memory_space_id: str, folder_key: Optional[str] = None, + folder_path: Optional[str] = None, ) -> RequestSpec: - folder_key = self._resolve_folder(folder_key) + folder_key = self._resolve_folder(folder_key, folder_path) return RequestSpec( method="POST", endpoint=Endpoint( diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 11138cfbd..6821bbc1c 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.37" +version = "0.1.38" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 2bc995e74..bf0fb3ed3 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.37" +version = "0.1.38" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 0c3dc51bf666f1f90f2523309ab545f72799d587 Mon Sep 17 00:00:00 2001 From: GabrielVasilescu04 Date: Mon, 27 Apr 2026 15:57:13 +0300 Subject: [PATCH 035/191] feat: add models-list command (#1474) --- packages/uipath/pyproject.toml | 2 +- packages/uipath/src/uipath/_cli/__init__.py | 1 + .../uipath/src/uipath/_cli/cli_list_models.py | 64 +++++ .../integration/test_list_models_commands.py | 233 ++++++++++++++++++ packages/uipath/uv.lock | 2 +- 5 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 packages/uipath/src/uipath/_cli/cli_list_models.py create mode 100644 packages/uipath/tests/cli/integration/test_list_models_commands.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 1bc9f87c0..53d1a891f 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.55" +version = "2.10.56" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/__init__.py b/packages/uipath/src/uipath/_cli/__init__.py index aa6e177e8..d8d3a8a46 100644 --- a/packages/uipath/src/uipath/_cli/__init__.py +++ b/packages/uipath/src/uipath/_cli/__init__.py @@ -45,6 +45,7 @@ "server": "cli_server", "register": "cli_register", "debug": "cli_debug", + "list-models": "cli_list_models", "assets": "services.cli_assets", "buckets": "services.cli_buckets", "context-grounding": "services.cli_context_grounding", diff --git a/packages/uipath/src/uipath/_cli/cli_list_models.py b/packages/uipath/src/uipath/_cli/cli_list_models.py new file mode 100644 index 000000000..7c14686a4 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/cli_list_models.py @@ -0,0 +1,64 @@ +from collections.abc import Iterable + +import click +from rich.console import Console +from rich.table import Table + +from ..platform.agenthub import LlmModel +from ._utils._context import get_cli_context +from ._utils._service_base import ServiceCommandBase, service_command + + +@click.command(name="list-models") +@click.option( + "--format", + type=click.Choice(["json", "table", "csv"]), + help="Output format (overrides global)", +) +@click.option( + "--output", + "--output-file", + "-o", + type=click.Path(), + help="File path where the output will be written", +) +@service_command +async def list_models(ctx, format, output): + """List available LLM models.""" + client = ServiceCommandBase.get_client(ctx) + models = await client.agenthub.get_available_llm_models_async() + + fmt = format or get_cli_context(ctx).output_format + if fmt == "table" and not output: + _render_rich_table(models) + return None + return models + + +def _render_rich_table(models: Iterable[LlmModel]) -> None: + """Render models as a rich table with one column per vendor.""" + by_vendor: dict[str, list[str]] = {} + for model in models: + vendor = model.vendor or "Unknown" + by_vendor.setdefault(vendor, []).append(model.model_name) + + console = Console() + if not by_vendor: + console.print("Available LLM Models: none") + return + + for names in by_vendor.values(): + names.sort() + + vendors = sorted(by_vendor.keys()) + + table = Table(title="Available LLM Models", show_lines=False) + for vendor in vendors: + table.add_column(vendor, style="cyan", no_wrap=True) + + max_rows = max(len(by_vendor[v]) for v in vendors) + for i in range(max_rows): + row = [by_vendor[v][i] if i < len(by_vendor[v]) else "" for v in vendors] + table.add_row(*row) + + console.print(table) diff --git a/packages/uipath/tests/cli/integration/test_list_models_commands.py b/packages/uipath/tests/cli/integration/test_list_models_commands.py new file mode 100644 index 000000000..c7cb9f042 --- /dev/null +++ b/packages/uipath/tests/cli/integration/test_list_models_commands.py @@ -0,0 +1,233 @@ +"""Integration tests for the `uipath list-models` CLI command. + +The command renders a rich table grouped by vendor (one column per vendor) +for human terminal use, and falls through to the shared `format_output` +pipeline for `--format json|csv` and `--output `. +""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from click.testing import CliRunner + +from uipath._cli import cli +from uipath.platform.agenthub import LlmModel + + +@pytest.fixture +def runner(): + """Provide a Click CLI test runner.""" + return CliRunner() + + +@pytest.fixture +def mock_client(): + """Provide a mocked UiPath client with an async agenthub service.""" + with patch("uipath.platform._uipath.UiPath") as mock: + client_instance = MagicMock() + mock.return_value = client_instance + + client_instance.agenthub = MagicMock() + client_instance.agenthub.get_available_llm_models_async = AsyncMock() + + yield client_instance + + +def _make_models() -> list[LlmModel]: + """Build a small list of LlmModel instances spanning multiple vendors.""" + return [ + LlmModel(model_name="gpt-4o-mini", vendor="OpenAi"), + LlmModel(model_name="gpt-4.1", vendor="OpenAi"), + LlmModel(model_name="claude-sonnet-4-5", vendor="Anthropic"), + LlmModel(model_name="gemini-2.5-flash", vendor="VertexAi"), + ] + + +class TestRichTable: + def test_renders_each_model_and_vendor(self, runner, mock_client, mock_env_vars): + """All models and vendor headers appear in the rendered table.""" + mock_client.agenthub.get_available_llm_models_async.return_value = ( + _make_models() + ) + + result = runner.invoke(cli, ["list-models"]) + + assert result.exit_code == 0 + for model in _make_models(): + assert model.model_name in result.output + assert (model.vendor or "") in result.output + mock_client.agenthub.get_available_llm_models_async.assert_awaited_once() + + def test_table_title(self, runner, mock_client, mock_env_vars): + """The rich table renders its title for orientation.""" + mock_client.agenthub.get_available_llm_models_async.return_value = ( + _make_models() + ) + + result = runner.invoke(cli, ["list-models"]) + + assert result.exit_code == 0 + assert "Available LLM Models" in result.output + + def test_missing_vendor_grouped_under_unknown( + self, runner, mock_client, mock_env_vars + ): + """A model with no vendor lands in an 'Unknown' column.""" + mock_client.agenthub.get_available_llm_models_async.return_value = [ + LlmModel(model_name="custom-model", vendor=None), + ] + + result = runner.invoke(cli, ["list-models"]) + + assert result.exit_code == 0 + assert "custom-model" in result.output + assert "Unknown" in result.output + + def test_empty(self, runner, mock_client, mock_env_vars): + """An empty model list renders the title without rows or errors.""" + mock_client.agenthub.get_available_llm_models_async.return_value = [] + + result = runner.invoke(cli, ["list-models"]) + + assert result.exit_code == 0 + assert "Available LLM Models" in result.output + + +class TestMachineReadableFormats: + def test_json_format(self, runner, mock_client, mock_env_vars): + """--format json bypasses the rich table and emits parseable JSON.""" + mock_client.agenthub.get_available_llm_models_async.return_value = ( + _make_models() + ) + + result = runner.invoke(cli, ["list-models", "--format", "json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert isinstance(payload, list) + assert {m["model_name"] for m in payload} == { + "gpt-4o-mini", + "gpt-4.1", + "claude-sonnet-4-5", + "gemini-2.5-flash", + } + + def test_csv_format(self, runner, mock_client, mock_env_vars): + """--format csv emits a header row and one row per model.""" + mock_client.agenthub.get_available_llm_models_async.return_value = ( + _make_models() + ) + + result = runner.invoke(cli, ["list-models", "--format", "csv"]) + + assert result.exit_code == 0 + lines = [line for line in result.output.splitlines() if line.strip()] + assert "model_name" in lines[0] + assert "vendor" in lines[0] + assert any("gpt-4o-mini" in line for line in lines[1:]) + + def test_global_json_flag(self, runner, mock_client, mock_env_vars): + """The cli-group --format json is honored too.""" + mock_client.agenthub.get_available_llm_models_async.return_value = ( + _make_models() + ) + + result = runner.invoke(cli, ["--format", "json", "list-models"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert len(payload) == 4 + + def test_output_writes_through_plain_formatter( + self, runner, mock_client, mock_env_vars, tmp_path + ): + """--output writes through format_output (not the rich path).""" + mock_client.agenthub.get_available_llm_models_async.return_value = ( + _make_models() + ) + out_file = tmp_path / "models.json" + + result = runner.invoke( + cli, + ["list-models", "--format", "json", "--output", str(out_file)], + ) + + assert result.exit_code == 0 + assert out_file.exists() + payload = json.loads(out_file.read_text(encoding="utf-8")) + assert {m["model_name"] for m in payload} == { + "gpt-4o-mini", + "gpt-4.1", + "claude-sonnet-4-5", + "gemini-2.5-flash", + } + + def test_output_file_alias(self, runner, mock_client, mock_env_vars, tmp_path): + """`--output-file` works as an alias for `--output` (matches `run`).""" + mock_client.agenthub.get_available_llm_models_async.return_value = ( + _make_models() + ) + out_file = tmp_path / "models.json" + + result = runner.invoke( + cli, + ["list-models", "--format", "json", "--output-file", str(out_file)], + ) + + assert result.exit_code == 0 + assert out_file.exists() + payload = json.loads(out_file.read_text(encoding="utf-8")) + assert len(payload) == 4 + + +class TestErrorPaths: + def test_service_error(self, runner, mock_client, mock_env_vars): + """Exceptions from the service are surfaced as click errors.""" + mock_client.agenthub.get_available_llm_models_async.side_effect = RuntimeError( + "boom" + ) + + result = runner.invoke(cli, ["list-models"]) + + assert result.exit_code != 0 + assert "boom" in result.output + + def test_missing_url(self, runner, monkeypatch): + """Missing UIPATH_URL surfaces an auth-configuration error.""" + monkeypatch.delenv("UIPATH_URL", raising=False) + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "mock_token") + + result = runner.invoke(cli, ["list-models"]) + + assert result.exit_code != 0 + assert "UIPATH_URL not configured" in result.output + + def test_missing_token(self, runner, monkeypatch): + """Missing UIPATH_ACCESS_TOKEN surfaces an auth-configuration error.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.delenv("UIPATH_ACCESS_TOKEN", raising=False) + + result = runner.invoke(cli, ["list-models"]) + + assert result.exit_code != 0 + assert "Authentication required" in result.output + + +class TestRegistration: + def test_help_text(self, runner): + """--help surfaces the command description and options.""" + result = runner.invoke(cli, ["list-models", "--help"]) + + assert result.exit_code == 0 + assert "List available LLM models" in result.output + assert "--format" in result.output + assert "--output" in result.output + assert "--output-file" in result.output + + def test_registered_in_cli(self, runner): + """The command is wired into the top-level CLI group.""" + result = runner.invoke(cli, ["--help"]) + + assert result.exit_code == 0 + assert "list-models" in result.output diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index bf0fb3ed3..bb59734af 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.55" +version = "2.10.56" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 94567e4e20af61c56f609a556b078ee368ab4675 Mon Sep 17 00:00:00 2001 From: Josh Park <50765702+JoshParkSJ@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:32:22 -0400 Subject: [PATCH 036/191] feat: add confirmToolCall support to CAS chat bridge [JAR-8666] (#1558) --- packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/chat/__init__.py | 33 +----- .../src/uipath/core/chat/interrupt.py | 112 ------------------ .../src/uipath/core/chat/message.py | 8 -- .../uipath-core/src/uipath/core/chat/tool.py | 37 ++++++ packages/uipath-core/uv.lock | 2 +- .../services/test_conversations_service.py | 3 - packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/_cli/_chat/_bridge.py | 112 ++++++------------ .../eval/models/_conversational_utils.py | 4 - packages/uipath/uv.lock | 4 +- 12 files changed, 86 insertions(+), 235 deletions(-) delete mode 100644 packages/uipath-core/src/uipath/core/chat/interrupt.py diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 387818039..2f38df428 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.12" +version = "0.5.13" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/chat/__init__.py b/packages/uipath-core/src/uipath/core/chat/__init__.py index c2e9f025b..d81fa8153 100644 --- a/packages/uipath-core/src/uipath/core/chat/__init__.py +++ b/packages/uipath-core/src/uipath/core/chat/__init__.py @@ -77,20 +77,6 @@ UiPathConversationExchangeEvent, UiPathConversationExchangeStartEvent, ) -from .interrupt import ( - InterruptTypeEnum, - UiPathConversationGenericInterruptEndEvent, - UiPathConversationGenericInterruptStartEvent, - UiPathConversationInterrupt, - UiPathConversationInterruptData, - UiPathConversationInterruptEndEvent, - UiPathConversationInterruptEvent, - UiPathConversationInterruptStartEvent, - UiPathConversationToolCallConfirmationEndValue, - UiPathConversationToolCallConfirmationInterruptEndEvent, - UiPathConversationToolCallConfirmationInterruptStartEvent, - UiPathConversationToolCallConfirmationValue, -) from .message import ( UiPathConversationMessage, UiPathConversationMessageData, @@ -108,6 +94,9 @@ ) from .tool import ( UiPathConversationToolCall, + UiPathConversationToolCallConfirmation, + UiPathConversationToolCallConfirmationData, + UiPathConversationToolCallConfirmationEvent, UiPathConversationToolCallData, UiPathConversationToolCallEndEvent, UiPathConversationToolCallEvent, @@ -146,19 +135,6 @@ "UiPathConversationMessageEvent", "UiPathConversationMessageData", "UiPathConversationMessage", - # Interrupt - "InterruptTypeEnum", - "UiPathConversationInterruptStartEvent", - "UiPathConversationInterruptEndEvent", - "UiPathConversationInterruptEvent", - "UiPathConversationToolCallConfirmationValue", - "UiPathConversationToolCallConfirmationEndValue", - "UiPathConversationToolCallConfirmationInterruptStartEvent", - "UiPathConversationToolCallConfirmationInterruptEndEvent", - "UiPathConversationGenericInterruptStartEvent", - "UiPathConversationGenericInterruptEndEvent", - "UiPathConversationInterruptData", - "UiPathConversationInterrupt", # Content "UiPathConversationContentPartChunkEvent", "UiPathConversationContentPartStartEvent", @@ -183,6 +159,9 @@ # Tool "UiPathConversationToolCallStartEvent", "UiPathConversationToolCallEndEvent", + "UiPathConversationToolCallConfirmation", + "UiPathConversationToolCallConfirmationData", + "UiPathConversationToolCallConfirmationEvent", "UiPathConversationToolCallEvent", "UiPathConversationToolCallResult", "UiPathConversationToolCallData", diff --git a/packages/uipath-core/src/uipath/core/chat/interrupt.py b/packages/uipath-core/src/uipath/core/chat/interrupt.py deleted file mode 100644 index a2ce3e13f..000000000 --- a/packages/uipath-core/src/uipath/core/chat/interrupt.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Interrupt events for human-in-the-loop patterns.""" - -from enum import Enum -from typing import Any, Literal, Union - -from pydantic import BaseModel, ConfigDict, Field - - -class InterruptTypeEnum(str, Enum): - """Enum of known interrupt types.""" - - TOOL_CALL_CONFIRMATION = "uipath_cas_tool_call_confirmation" - - -class UiPathConversationToolCallConfirmationValue(BaseModel): - """Schema for tool call confirmation interrupt value.""" - - tool_call_id: str = Field(..., alias="toolCallId") - tool_name: str = Field(..., alias="toolName") - input_schema: Any = Field(..., alias="inputSchema") - input_value: Any | None = Field(None, alias="inputValue") - - model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) - - -class UiPathConversationToolCallConfirmationInterruptStartEvent(BaseModel): - """Tool call confirmation interrupt start event with strong typing.""" - - type: Literal["uipath_cas_tool_call_confirmation"] - value: UiPathConversationToolCallConfirmationValue - - model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) - - -class UiPathConversationGenericInterruptStartEvent(BaseModel): - """Generic interrupt start event for custom interrupt types.""" - - type: str - value: Any - - model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) - - -UiPathConversationInterruptStartEvent = Union[ - UiPathConversationToolCallConfirmationInterruptStartEvent, - UiPathConversationGenericInterruptStartEvent, -] - - -class UiPathConversationToolCallConfirmationEndValue(BaseModel): - """Schema for tool call confirmation end value.""" - - approved: bool - input: Any | None = None - - model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) - - -class UiPathConversationToolCallConfirmationInterruptEndEvent(BaseModel): - """Tool call confirmation interrupt end event with strong typing.""" - - type: Literal["uipath_cas_tool_call_confirmation"] - value: UiPathConversationToolCallConfirmationEndValue - - model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) - - -class UiPathConversationGenericInterruptEndEvent(BaseModel): - """Generic interrupt end event for custom interrupt types.""" - - type: str - value: Any - - model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) - - -UiPathConversationInterruptEndEvent = Union[ - UiPathConversationToolCallConfirmationInterruptEndEvent, - UiPathConversationGenericInterruptEndEvent, -] - - -class UiPathConversationInterruptEvent(BaseModel): - """Encapsulates interrupt-related events within a message.""" - - interrupt_id: str = Field(..., alias="interruptId") - start: UiPathConversationInterruptStartEvent | None = Field( - None, alias="startInterrupt" - ) - end: UiPathConversationInterruptEndEvent | None = Field(None, alias="endInterrupt") - - model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) - - -class UiPathConversationInterruptData(BaseModel): - """Represents the core data of an interrupt within a message - a pause point where the agent needs external input.""" - - type: str - interrupt_value: Any = Field(..., alias="interruptValue") - end_value: Any | None = Field(None, alias="endValue") - - model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) - - -class UiPathConversationInterrupt(UiPathConversationInterruptData): - """Represents an interrupt within a message - a pause point where the agent needs external input.""" - - interrupt_id: str = Field(..., alias="interruptId") - created_at: str = Field(..., alias="createdAt") - updated_at: str = Field(..., alias="updatedAt") - - model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) diff --git a/packages/uipath-core/src/uipath/core/chat/message.py b/packages/uipath-core/src/uipath/core/chat/message.py index 48e79171f..9d6aa248d 100644 --- a/packages/uipath-core/src/uipath/core/chat/message.py +++ b/packages/uipath-core/src/uipath/core/chat/message.py @@ -10,11 +10,6 @@ UiPathConversationContentPartEvent, ) from .error import UiPathConversationErrorEvent -from .interrupt import ( - UiPathConversationInterrupt, - UiPathConversationInterruptData, - UiPathConversationInterruptEvent, -) from .tool import ( UiPathConversationToolCall, UiPathConversationToolCallData, @@ -53,7 +48,6 @@ class UiPathConversationMessageEvent(BaseModel): None, alias="contentPart" ) tool_call: UiPathConversationToolCallEvent | None = Field(None, alias="toolCall") - interrupt: UiPathConversationInterruptEvent | None = None meta_event: dict[str, Any] | None = Field(None, alias="metaEvent") error: UiPathConversationErrorEvent | None = Field(None, alias="messageError") @@ -68,7 +62,6 @@ class UiPathConversationMessageData(BaseModel): ..., alias="contentParts" ) tool_calls: Sequence[UiPathConversationToolCallData] = Field(..., alias="toolCalls") - interrupts: Sequence[UiPathConversationInterruptData] model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) @@ -86,6 +79,5 @@ class UiPathConversationMessage(UiPathConversationMessageData): ..., alias="contentParts" ) tool_calls: Sequence[UiPathConversationToolCall] = Field(..., alias="toolCalls") - interrupts: Sequence[UiPathConversationInterrupt] model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) diff --git a/packages/uipath-core/src/uipath/core/chat/tool.py b/packages/uipath-core/src/uipath/core/chat/tool.py index 9c9e911bd..8af5fb604 100644 --- a/packages/uipath-core/src/uipath/core/chat/tool.py +++ b/packages/uipath-core/src/uipath/core/chat/tool.py @@ -25,6 +25,8 @@ class UiPathConversationToolCallStartEvent(BaseModel): timestamp: str | None = None input: dict[str, Any] | None = None metadata: dict[str, Any] | None = Field(None, alias="metaData") + require_confirmation: bool | None = Field(None, alias="requireConfirmation") + input_schema: Any | None = Field(None, alias="inputSchema") model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) @@ -41,6 +43,34 @@ class UiPathConversationToolCallEndEvent(BaseModel): model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) +class UiPathConversationToolCallConfirmationEvent(BaseModel): + """Signals a tool call confirmation (approve/reject) from the client.""" + + approved: bool + input: Any | None = None + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationToolCallConfirmationData(BaseModel): + """Represents the core data of a tool call confirmation.""" + + approved: bool + input: Any | None = None + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationToolCallConfirmation( + UiPathConversationToolCallConfirmationData +): + """Represents the stored confirmation state on a tool call.""" + + confirmed_at: str | None = Field(None, alias="confirmedAt") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + class UiPathConversationToolCallEvent(BaseModel): """Encapsulates the data related to a tool call event.""" @@ -49,6 +79,9 @@ class UiPathConversationToolCallEvent(BaseModel): None, alias="startToolCall" ) end: UiPathConversationToolCallEndEvent | None = Field(None, alias="endToolCall") + confirm: UiPathConversationToolCallConfirmationEvent | None = Field( + None, alias="confirmToolCall" + ) meta_event: dict[str, Any] | None = Field(None, alias="metaEvent") error: UiPathConversationErrorEvent | None = Field(None, alias="toolCallError") @@ -61,6 +94,9 @@ class UiPathConversationToolCallData(BaseModel): name: str input: dict[str, Any] | None = None result: UiPathConversationToolCallResult | None = None + require_confirmation: bool | None = Field(None, alias="requireConfirmation") + input_schema: Any | None = Field(None, alias="inputSchema") + confirmation: UiPathConversationToolCallConfirmationData | None = None model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) @@ -72,5 +108,6 @@ class UiPathConversationToolCall(UiPathConversationToolCallData): timestamp: str | None = None created_at: str = Field(..., alias="createdAt") updated_at: str = Field(..., alias="updatedAt") + confirmation: UiPathConversationToolCallConfirmation | None = None model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index 807653ed8..a096d011c 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -1007,7 +1007,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.12" +version = "0.5.13" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/tests/services/test_conversations_service.py b/packages/uipath-platform/tests/services/test_conversations_service.py index 31aa4a653..37e08bdfa 100644 --- a/packages/uipath-platform/tests/services/test_conversations_service.py +++ b/packages/uipath-platform/tests/services/test_conversations_service.py @@ -38,7 +38,6 @@ async def test_retrieve_message( "role": "assistant", "contentParts": [], "toolCalls": [], - "interrupts": [], "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z", }, @@ -95,7 +94,6 @@ async def test_retrieve_message_with_content_parts( } ], "toolCalls": [], - "interrupts": [], "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z", }, @@ -145,7 +143,6 @@ async def test_retrieve_message_with_tool_calls( "updatedAt": "2024-01-01T00:00:00Z", } ], - "interrupts": [], "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z", }, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 6821bbc1c..6e1fc96fd 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1056,7 +1056,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.12" +version = "0.5.13" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 53d1a891f..d69b420a5 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.56" +version = "2.10.57" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/_chat/_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_bridge.py index 24c1be024..2a382a59e 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_bridge.py @@ -4,7 +4,6 @@ import json import logging import os -import uuid from typing import Any from urllib.parse import urlparse @@ -14,11 +13,8 @@ UiPathConversationEvent, UiPathConversationExchangeEndEvent, UiPathConversationExchangeEvent, - UiPathConversationInterruptEndEvent, - UiPathConversationInterruptEvent, UiPathConversationMessageEvent, - UiPathConversationToolCallConfirmationInterruptStartEvent, - UiPathConversationToolCallConfirmationValue, + UiPathConversationToolCallConfirmationEvent, ) from uipath.core.triggers import UiPathResumeTrigger from uipath.runtime.chat import UiPathChatProtocol @@ -126,9 +122,10 @@ def __init__( self._client: Any | None = None self._connected_event = asyncio.Event() - # Interrupt state for HITL round-trip - self._interrupt_end_event = asyncio.Event() - self._interrupt_end_value: UiPathConversationInterruptEndEvent | None = None + self._tool_confirmation_event = asyncio.Event() + self._tool_confirmation_value: ( + UiPathConversationToolCallConfirmationEvent | None + ) = None self._current_message_id: str | None = None # Set CAS_WEBSOCKET_DISABLED when using the debugger to prevent websocket errors from @@ -363,67 +360,35 @@ async def emit_exchange_error_event(self, error: Exception) -> None: raise RuntimeError(f"Failed to send exchange error event: {e}") from e async def emit_interrupt_event(self, resume_trigger: UiPathResumeTrigger): - if self._client and self._connected_event.is_set(): - try: - # Clear previous interrupt state and generate new interrupt_id - self._interrupt_id = str(uuid.uuid4()) - - # Ensure we have a valid message_id - if self._current_message_id is None: - raise RuntimeError( - "Cannot emit interrupt event: no current message_id set" - ) - - # Ensure api_resume is not None - if resume_trigger.api_resume is None: - raise RuntimeError( - "Cannot emit interrupt event: api_resume is None" - ) - - interrupt_event = UiPathConversationEvent( - conversation_id=self.conversation_id, - exchange=UiPathConversationExchangeEvent( - exchange_id=self.exchange_id, - message=UiPathConversationMessageEvent( - message_id=self._current_message_id, - interrupt=UiPathConversationInterruptEvent( - interrupt_id=self._interrupt_id, - start=UiPathConversationToolCallConfirmationInterruptStartEvent( - type="uipath_cas_tool_call_confirmation", - value=UiPathConversationToolCallConfirmationValue( - **resume_trigger.api_resume.request - ), - ), - ), - ), - ), - ) - - event_data = interrupt_event.model_dump( - mode="json", exclude_none=True, by_alias=True - ) - if self._websocket_disabled: - logger.info( - f"SocketIOChatBridge is in debug mode. Not sending event: {json.dumps(event_data)}" - ) - else: - await self._client.emit("ConversationEvent", event_data) - except Exception as e: - logger.warning(f"Error sending interrupt event: {e}") + """No-op. + + Tool confirmation — the only interrupt pattern CAS uses today — is + handled end-to-end via ``startToolCall`` with ``requireConfirmation: + true`` paired with ``wait_for_resume()``. This is deliberately + simpler than the old interrupt-based flow: CAS needs + ``requireConfirmation`` on the tool call event itself to render the + confirmation UI, so a parallel ``startInterrupt`` event would be + redundant. + + The only hypothetical reason to put work here is a generic, + non-tool-call agent interrupt (e.g. a coded agent calling + ``interrupt("do you want to continue?")``). Nothing uses that today + and it's not a near-term requirement — the method is kept for + generic flexibility. + """ + return None async def wait_for_resume(self) -> dict[str, Any]: - """Wait for the interrupt_end event to be received. - - Returns: - Resume data from the interrupt end event - """ - self._interrupt_end_event.clear() - self._interrupt_end_value = None + """Wait for a confirmToolCall event to be received.""" + self._tool_confirmation_event.clear() + self._tool_confirmation_value = None - await self._interrupt_end_event.wait() + await self._tool_confirmation_event.wait() - if self._interrupt_end_value: - return self._interrupt_end_value.model_dump(mode="python", by_alias=False) + if self._tool_confirmation_value: + return self._tool_confirmation_value.model_dump( + mode="python", by_alias=False + ) return {} @property @@ -458,17 +423,14 @@ async def _handle_conversation_event( if ( parsed_event.exchange and parsed_event.exchange.message - and parsed_event.exchange.message.interrupt - and parsed_event.exchange.message.interrupt.end + and (tool_call := parsed_event.exchange.message.tool_call) + and (confirm := tool_call.confirm) ): - interrupt = parsed_event.exchange.message.interrupt - - if interrupt.interrupt_id == self._interrupt_id: - logger.info( - f"Received endInterrupt for interrupt_id: {self._interrupt_id}" - ) - self._interrupt_end_value = interrupt.end - self._interrupt_end_event.set() + logger.info( + f"Received confirmToolCall for tool_call_id: {tool_call.tool_call_id}, approved: {confirm.approved}" + ) + self._tool_confirmation_value = confirm + self._tool_confirmation_event.set() except Exception as e: logger.warning(f"Error parsing conversation event: {e}") diff --git a/packages/uipath/src/uipath/eval/models/_conversational_utils.py b/packages/uipath/src/uipath/eval/models/_conversational_utils.py index d4dbf0cdd..9e3523acc 100644 --- a/packages/uipath/src/uipath/eval/models/_conversational_utils.py +++ b/packages/uipath/src/uipath/eval/models/_conversational_utils.py @@ -168,7 +168,6 @@ def legacy_conversational_eval_input_to_uipath_message_list( role="user", content_parts=content_parts, tool_calls=[], - interrupts=[], created_at=timestamp, updated_at=timestamp, ) @@ -215,7 +214,6 @@ def legacy_conversational_eval_input_to_uipath_message_list( role="assistant", content_parts=content_parts, tool_calls=tool_calls, - interrupts=[], created_at=timestamp, updated_at=timestamp, ) @@ -259,7 +257,6 @@ def legacy_conversational_eval_input_to_uipath_message_list( role="user", content_parts=content_parts, tool_calls=[], - interrupts=[], created_at=timestamp, updated_at=timestamp, ) @@ -301,7 +298,6 @@ def legacy_conversational_eval_output_to_uipath_message_data_list( role="assistant", content_parts=content_parts, tool_calls=tool_calls, - interrupts=[], ) ) diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index bb59734af..a0201a489 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.56" +version = "2.10.57" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2650,7 +2650,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.12" +version = "0.5.13" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, From 4b6fcbccdf264c18d74f81bcfaa656c54f341433 Mon Sep 17 00:00:00 2001 From: Radu Mihai Gheorghe Date: Tue, 28 Apr 2026 11:10:19 +0300 Subject: [PATCH 037/191] chore: update pyproject template version to current minor interval (#1592) --- packages/uipath/pyproject.toml | 2 +- packages/uipath/src/uipath/_cli/cli_new.py | 2 +- packages/uipath/uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index d69b420a5..b81b3aa57 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.57" +version = "2.10.58" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/cli_new.py b/packages/uipath/src/uipath/_cli/cli_new.py index 390c581c5..d273055d5 100644 --- a/packages/uipath/src/uipath/_cli/cli_new.py +++ b/packages/uipath/src/uipath/_cli/cli_new.py @@ -28,7 +28,7 @@ def generate_pyproject(target_directory, project_name): description = "{project_name}" authors = [{{ name = "John Doe", email = "john.doe@myemail.com" }}] dependencies = [ - "uipath>=2.2.0, <2.3.0" + "uipath>=2.10.0, <2.11.0" ] requires-python = ">=3.11" """ diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index a0201a489..32ef182ac 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.57" +version = "2.10.58" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 97fb1a4f93b63e623bdc663dcbf6039abe7e3961 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Tue, 28 Apr 2026 12:45:46 +0200 Subject: [PATCH 038/191] chore: add agentservice source on job invocation (#1593) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/orchestrator/_processes_service.py | 6 +++++- .../tests/services/test_processes_service.py | 4 ++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 2 +- packages/uipath/uv.lock | 2 +- 6 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index e32ca5495..59585688f 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.38" +version = "0.1.39" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_processes_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_processes_service.py index 73b4122e1..e6ecc988a 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_processes_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_processes_service.py @@ -321,7 +321,11 @@ def _invoke_spec( parent_operation_id: Optional[str] = None, run_as_me: Optional[bool] = None, ) -> RequestSpec: - payload: Dict[str, Any] = {"ReleaseName": name, **(input_data or {})} + payload: Dict[str, Any] = { + "ReleaseName": name, + **(input_data or {}), + "Source": "AgentService", + } self._add_tracing(payload, UiPathConfig.trace_id, parent_span_id) if parent_operation_id: diff --git a/packages/uipath-platform/tests/services/test_processes_service.py b/packages/uipath-platform/tests/services/test_processes_service.py index 15b2c9c7e..015888c54 100644 --- a/packages/uipath-platform/tests/services/test_processes_service.py +++ b/packages/uipath-platform/tests/services/test_processes_service.py @@ -79,6 +79,7 @@ def test_invoke( "startInfo": { "ReleaseName": process_name, "InputArguments": json.dumps(input_arguments), + "Source": "AgentService", } }, separators=(",", ":"), @@ -139,6 +140,7 @@ def test_invoke_without_input_arguments( "startInfo": { "ReleaseName": process_name, "InputArguments": "{}", + "Source": "AgentService", } }, separators=(",", ":"), @@ -300,6 +302,7 @@ async def test_invoke_async( "startInfo": { "ReleaseName": process_name, "InputArguments": json.dumps(input_arguments), + "Source": "AgentService", } }, separators=(",", ":"), @@ -361,6 +364,7 @@ async def test_invoke_async_without_input_arguments( "startInfo": { "ReleaseName": process_name, "InputArguments": "{}", + "Source": "AgentService", } }, separators=(",", ":"), diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 6e1fc96fd..4431c9b1c 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.38" +version = "0.1.39" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index b81b3aa57..c6a998262 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.8, <0.6.0", "uipath-runtime>=0.10.1, <0.11.0", - "uipath-platform>=0.1.13, <0.2.0", + "uipath-platform>=0.1.39, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 32ef182ac..87c9a3aa2 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.38" +version = "0.1.39" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 82f8e54edb118c4eddefb05c348fad3eab8528f9 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Wed, 29 Apr 2026 10:02:04 +0200 Subject: [PATCH 039/191] fix: restore interrupt event compat shims in uipath-core (#1599) (#1600) --- packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/chat/__init__.py | 27 ++++ .../src/uipath/core/chat/interrupt.py | 122 ++++++++++++++++++ packages/uipath-core/uv.lock | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 6 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 packages/uipath-core/src/uipath/core/chat/interrupt.py diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 2f38df428..7e559c9a4 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.13" +version = "0.5.14" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/chat/__init__.py b/packages/uipath-core/src/uipath/core/chat/__init__.py index d81fa8153..77fa6cbee 100644 --- a/packages/uipath-core/src/uipath/core/chat/__init__.py +++ b/packages/uipath-core/src/uipath/core/chat/__init__.py @@ -77,6 +77,20 @@ UiPathConversationExchangeEvent, UiPathConversationExchangeStartEvent, ) +from .interrupt import ( + InterruptTypeEnum, + UiPathConversationGenericInterruptEndEvent, + UiPathConversationGenericInterruptStartEvent, + UiPathConversationInterrupt, + UiPathConversationInterruptData, + UiPathConversationInterruptEndEvent, + UiPathConversationInterruptEvent, + UiPathConversationInterruptStartEvent, + UiPathConversationToolCallConfirmationEndValue, + UiPathConversationToolCallConfirmationInterruptEndEvent, + UiPathConversationToolCallConfirmationInterruptStartEvent, + UiPathConversationToolCallConfirmationValue, +) from .message import ( UiPathConversationMessage, UiPathConversationMessageData, @@ -177,4 +191,17 @@ "UiPathVoiceToolCallRequest", "UiPathVoiceToolCallMessage", "UiPathVoiceToolCallResult", + # Interrupt (compat shims — deprecated, see interrupt.py) + "InterruptTypeEnum", + "UiPathConversationInterruptStartEvent", + "UiPathConversationInterruptEndEvent", + "UiPathConversationInterruptEvent", + "UiPathConversationInterruptData", + "UiPathConversationInterrupt", + "UiPathConversationGenericInterruptStartEvent", + "UiPathConversationGenericInterruptEndEvent", + "UiPathConversationToolCallConfirmationValue", + "UiPathConversationToolCallConfirmationEndValue", + "UiPathConversationToolCallConfirmationInterruptStartEvent", + "UiPathConversationToolCallConfirmationInterruptEndEvent", ] diff --git a/packages/uipath-core/src/uipath/core/chat/interrupt.py b/packages/uipath-core/src/uipath/core/chat/interrupt.py new file mode 100644 index 000000000..9094d1597 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/chat/interrupt.py @@ -0,0 +1,122 @@ +"""Compatibility shims for legacy interrupt event types. + +The interrupt-based tool-call confirmation flow was replaced by `confirmToolCall` +on the tool call event itself (see PR #1558). The original `interrupt.py` was +removed in `uipath-core` 0.5.13, but published `uipath-runtime` versions still +import these names at module load time, breaking installs that pull the new +`uipath-core` alongside an older runtime. + +These shims keep those imports working. They are not used by current code paths +and should be removed in the next minor bump of `uipath-core`. +""" + +from enum import Enum +from typing import Any, Literal, Union + +from pydantic import BaseModel, ConfigDict, Field + + +class InterruptTypeEnum(str, Enum): + """Enum of known interrupt types.""" + + TOOL_CALL_CONFIRMATION = "uipath_cas_tool_call_confirmation" + + +class UiPathConversationToolCallConfirmationValue(BaseModel): + """Schema for tool call confirmation interrupt value.""" + + tool_call_id: str = Field(..., alias="toolCallId") + tool_name: str = Field(..., alias="toolName") + input_schema: Any = Field(..., alias="inputSchema") + input_value: Any | None = Field(None, alias="inputValue") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationToolCallConfirmationInterruptStartEvent(BaseModel): + """Tool call confirmation interrupt start event with strong typing.""" + + type: Literal["uipath_cas_tool_call_confirmation"] + value: UiPathConversationToolCallConfirmationValue + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationGenericInterruptStartEvent(BaseModel): + """Generic interrupt start event for custom interrupt types.""" + + type: str + value: Any + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +UiPathConversationInterruptStartEvent = Union[ + UiPathConversationToolCallConfirmationInterruptStartEvent, + UiPathConversationGenericInterruptStartEvent, +] + + +class UiPathConversationToolCallConfirmationEndValue(BaseModel): + """Schema for tool call confirmation end value.""" + + approved: bool + input: Any | None = None + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationToolCallConfirmationInterruptEndEvent(BaseModel): + """Tool call confirmation interrupt end event with strong typing.""" + + type: Literal["uipath_cas_tool_call_confirmation"] + value: UiPathConversationToolCallConfirmationEndValue + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationGenericInterruptEndEvent(BaseModel): + """Generic interrupt end event for custom interrupt types.""" + + type: str + value: Any + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +UiPathConversationInterruptEndEvent = Union[ + UiPathConversationToolCallConfirmationInterruptEndEvent, + UiPathConversationGenericInterruptEndEvent, +] + + +class UiPathConversationInterruptEvent(BaseModel): + """Encapsulates interrupt-related events within a message.""" + + interrupt_id: str = Field(..., alias="interruptId") + start: UiPathConversationInterruptStartEvent | None = Field( + None, alias="startInterrupt" + ) + end: UiPathConversationInterruptEndEvent | None = Field(None, alias="endInterrupt") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationInterruptData(BaseModel): + """Core data of an interrupt within a message.""" + + type: str + interrupt_value: Any = Field(..., alias="interruptValue") + end_value: Any | None = Field(None, alias="endValue") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationInterrupt(UiPathConversationInterruptData): + """An interrupt within a message — a pause point where the agent needs external input.""" + + interrupt_id: str = Field(..., alias="interruptId") + created_at: str = Field(..., alias="createdAt") + updated_at: str = Field(..., alias="updatedAt") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index a096d011c..61159a1d5 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -1007,7 +1007,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.13" +version = "0.5.14" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 4431c9b1c..415634498 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1056,7 +1056,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.13" +version = "0.5.14" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 87c9a3aa2..b85d0fb8d 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2650,7 +2650,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.13" +version = "0.5.14" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, From ae5dac0748ce870f73387b3041ad087ea529545d Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Wed, 29 Apr 2026 12:29:10 +0200 Subject: [PATCH 040/191] ci: cross-test all integrations against locally-built uipath wheels (#1602) --- .github/labeler.yml | 6 +- .../workflows/test-uipath-integrations.yml | 284 ++++++++++++++++++ .github/workflows/test-uipath-langchain.yml | 48 +++ .github/workflows/test-uipath-llamaindex.yml | 173 ----------- 4 files changed, 337 insertions(+), 174 deletions(-) create mode 100644 .github/workflows/test-uipath-integrations.yml delete mode 100644 .github/workflows/test-uipath-llamaindex.yml diff --git a/.github/labeler.yml b/.github/labeler.yml index ee2f4de14..d83420502 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -6,9 +6,13 @@ test:uipath-langchain: - changed-files: - any-glob-to-any-file: ['packages/uipath-core/src/**/*.py'] -test:uipath-llamaindex: +test:uipath-integrations: - changed-files: - any-glob-to-any-file: ['packages/uipath/src/**/*.py'] + - changed-files: + - any-glob-to-any-file: ['packages/uipath-platform/src/**/*.py'] + - changed-files: + - any-glob-to-any-file: ['packages/uipath-core/src/**/*.py'] test:uipath-runtime: - changed-files: diff --git a/.github/workflows/test-uipath-integrations.yml b/.github/workflows/test-uipath-integrations.yml new file mode 100644 index 000000000..ed50a4b3d --- /dev/null +++ b/.github/workflows/test-uipath-integrations.yml @@ -0,0 +1,284 @@ +name: uipath - Test Integrations + +on: + pull_request: + types: [ opened, synchronize, reopened, labeled ] + +jobs: + build-wheels: + runs-on: ubuntu-latest + permissions: + contents: read + if: contains(github.event.pull_request.labels.*.name, 'test:uipath-integrations') + steps: + - name: Checkout uipath-python + uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build uipath-core package + working-directory: packages/uipath-core + run: uv build + + - name: Build uipath-platform package + working-directory: packages/uipath-platform + run: uv build + + - name: Build uipath package + working-directory: packages/uipath + run: uv build + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: uipath-wheels + path: packages/*/dist/*.whl + + discover-packages: + needs: [build-wheels] + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + packages: ${{ steps.discover.outputs.packages }} + steps: + - name: Checkout uipath-integrations-python + uses: actions/checkout@v4 + with: + repository: 'UiPath/uipath-integrations-python' + path: 'uipath-integrations-python' + + - name: Discover packages + id: discover + working-directory: uipath-integrations-python + run: | + # Find every package directory under packages/ that has a pyproject.toml + package_dirs=$(find packages -maxdepth 2 -name pyproject.toml -printf '%h\n' | sed 's|^packages/||' | sort) + + echo "Found integration packages:" + echo "$package_dirs" + + packages_json=$(echo "$package_dirs" | jq -R -s -c 'split("\n")[:-1]') + echo "packages=$packages_json" >> $GITHUB_OUTPUT + + test-package: + needs: [build-wheels, discover-packages] + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + package: ${{ fromJson(needs.discover-packages.outputs.packages) }} + python-version: [ "3.11", "3.12", "3.13" ] + os: [ ubuntu-latest, windows-latest ] + + name: "${{ matrix.package }} / py${{ matrix.python-version }} / ${{ matrix.os }}" + permissions: + contents: read + + steps: + - name: Setup uv + uses: astral-sh/setup-uv@v5 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Download wheels + uses: actions/download-artifact@v4 + with: + name: uipath-wheels + path: wheels + + - name: Checkout uipath-integrations-python + uses: actions/checkout@v4 + with: + repository: 'UiPath/uipath-integrations-python' + path: 'uipath-integrations-python' + + - name: Update uipath packages + shell: bash + working-directory: uipath-integrations-python/packages/${{ matrix.package }} + run: | + uv add ../../../wheels/uipath-core/dist/*.whl --dev + uv add ../../../wheels/uipath-platform/dist/*.whl --dev + uv add ../../../wheels/uipath/dist/*.whl --dev + + - name: Install dependencies and run tests + shell: bash + working-directory: uipath-integrations-python/packages/${{ matrix.package }} + run: | + uv sync + if [ -d tests ]; then + uv run pytest + else + echo "No tests directory found in ${{ matrix.package }}, skipping pytest" + fi + + discover-testcases: + needs: [test-package, discover-packages] + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + matrix: ${{ steps.discover.outputs.matrix }} + has_testcases: ${{ steps.discover.outputs.has_testcases }} + steps: + - name: Checkout uipath-integrations-python + uses: actions/checkout@v4 + with: + repository: 'UiPath/uipath-integrations-python' + path: 'uipath-integrations-python' + + - name: Discover testcases across packages + id: discover + working-directory: uipath-integrations-python + run: | + # For each package with a testcases/ directory, list its testcase folders + # and emit one matrix entry per (package, testcase) pair. + entries="[]" + for pkg_dir in packages/*/; do + pkg=$(basename "$pkg_dir") + tc_dir="$pkg_dir/testcases" + if [ ! -d "$tc_dir" ]; then + continue + fi + testcases=$(find "$tc_dir" -maxdepth 1 -type d -name "*-*" -printf '%f\n' | sort) + if [ -z "$testcases" ]; then + continue + fi + for tc in $testcases; do + entries=$(echo "$entries" | jq --arg p "$pkg" --arg t "$tc" '. + [{package: $p, testcase: $t}]') + done + done + + echo "Discovered testcase matrix:" + echo "$entries" | jq . + + count=$(echo "$entries" | jq 'length') + if [ "$count" -eq 0 ]; then + echo "has_testcases=false" >> $GITHUB_OUTPUT + echo "matrix=[]" >> $GITHUB_OUTPUT + else + echo "has_testcases=true" >> $GITHUB_OUTPUT + echo "matrix=$(echo "$entries" | jq -c .)" >> $GITHUB_OUTPUT + fi + + run-integration-tests: + needs: [build-wheels, discover-testcases] + if: needs.discover-testcases.outputs.has_testcases == 'true' + runs-on: ubuntu-latest + container: + image: ghcr.io/astral-sh/uv:python3.12-bookworm + env: + UIPATH_JOB_KEY: "3a03d5cb-fa21-4021-894d-a8e2eda0afe0" + UIPATH_TRACING_ENABLED: false + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.discover-testcases.outputs.matrix) }} + environment: [alpha, staging] # temporary disable [cloud] + + name: "${{ matrix.package }} / ${{ matrix.testcase }} / ${{ matrix.environment }}" + + steps: + - name: Download wheels + uses: actions/download-artifact@v4 + with: + name: uipath-wheels + path: wheels + + - name: Checkout uipath-integrations-python + uses: actions/checkout@v4 + with: + repository: 'UiPath/uipath-integrations-python' + path: 'uipath-integrations-python' + + - name: Update uipath packages + shell: bash + working-directory: uipath-integrations-python/packages/${{ matrix.package }} + run: | + uv add ../../../wheels/uipath-core/dist/*.whl + uv add ../../../wheels/uipath-platform/dist/*.whl + uv add ../../../wheels/uipath/dist/*.whl + + - name: Install dependencies + working-directory: uipath-integrations-python/packages/${{ matrix.package }} + run: uv sync + + - name: Run testcase + env: + CLIENT_ID: ${{ matrix.environment == 'alpha' && secrets.ALPHA_TEST_CLIENT_ID || matrix.environment == 'staging' && secrets.STAGING_TEST_CLIENT_ID || matrix.environment == 'cloud' && secrets.CLOUD_TEST_CLIENT_ID }} + CLIENT_SECRET: ${{ matrix.environment == 'alpha' && secrets.ALPHA_TEST_CLIENT_SECRET || matrix.environment == 'staging' && secrets.STAGING_TEST_CLIENT_SECRET || matrix.environment == 'cloud' && secrets.CLOUD_TEST_CLIENT_SECRET }} + BASE_URL: ${{ matrix.environment == 'alpha' && secrets.ALPHA_BASE_URL || matrix.environment == 'staging' && secrets.STAGING_BASE_URL || matrix.environment == 'cloud' && secrets.CLOUD_BASE_URL }} + UV_PYTHON: "3.12" + working-directory: uipath-integrations-python/packages/${{ matrix.package }}/testcases/${{ matrix.testcase }} + run: | + echo "Package: ${{ matrix.package }}" + echo "Testcase: ${{ matrix.testcase }}" + echo "Environment: ${{ matrix.environment }}" + + bash run.sh + bash ../common/validate_output.sh + + notify-on-failure: + needs: [test-package, run-integration-tests] + if: always() && contains(github.event.pull_request.labels.*.name, 'test:uipath-integrations') && (needs.test-package.result == 'failure' || needs.run-integration-tests.result == 'failure') + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Comment on PR + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const body = [ + marker, + '## :rotating_light: **Heads up: `uipath-integrations` cross-tests are FAILING** :rotating_light:', + '', + 'Your changes may break one or more integrations in **[`uipath-integrations-python`](https://github.com/UiPath/uipath-integrations-python)**:', + '', + '- `uipath-openai-agents`', + '- `uipath-google-adk`', + '- `uipath-agent-framework`', + '- `uipath-llamaindex`', + '- `uipath-pydantic-ai`', + '', + '> :warning: **These checks are NOT enforced by branch protection rules.** Please review the failures before merging.', + '', + `**:mag: [Inspect the failed run →](${runUrl})**`, + ].join('\n'); + + // Delete any prior failure comments for this workflow so the new + // one always lands at the bottom of the PR conversation. + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + for (const c of comments) { + if (c.body && c.body.includes(marker)) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: c.id, + }); + } + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); diff --git a/.github/workflows/test-uipath-langchain.yml b/.github/workflows/test-uipath-langchain.yml index f74135975..f7ccb2cf9 100644 --- a/.github/workflows/test-uipath-langchain.yml +++ b/.github/workflows/test-uipath-langchain.yml @@ -175,3 +175,51 @@ jobs: # Execute the testcase run script directly bash run.sh bash ../common/validate_output.sh + + notify-on-failure: + needs: [test-uipath-langchain, run-uipath-langchain-integration-tests] + if: always() && contains(github.event.pull_request.labels.*.name, 'test:uipath-langchain') && (needs.test-uipath-langchain.result == 'failure' || needs.run-uipath-langchain-integration-tests.result == 'failure') + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Comment on PR + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const body = [ + marker, + '## :rotating_light: **Heads up: `uipath-langchain` cross-tests are FAILING** :rotating_light:', + '', + 'Your changes may break the **[`uipath-langchain-python`](https://github.com/UiPath/uipath-langchain-python)** integration.', + '', + '> :warning: **These checks are NOT enforced by branch protection rules.** Please review the failures before merging.', + '', + `**:mag: [Inspect the failed run →](${runUrl})**`, + ].join('\n'); + + // Delete any prior failure comments for this workflow so the new + // one always lands at the bottom of the PR conversation. + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + for (const c of comments) { + if (c.body && c.body.includes(marker)) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: c.id, + }); + } + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); diff --git a/.github/workflows/test-uipath-llamaindex.yml b/.github/workflows/test-uipath-llamaindex.yml deleted file mode 100644 index fcf8d0fb4..000000000 --- a/.github/workflows/test-uipath-llamaindex.yml +++ /dev/null @@ -1,173 +0,0 @@ -name: uipath - Test LlamaIndex - -on: - pull_request: - types: [ opened, synchronize, reopened, labeled ] - -jobs: - build-wheels: - runs-on: ubuntu-latest - permissions: - contents: read - if: contains(github.event.pull_request.labels.*.name, 'test:uipath-llamaindex') - steps: - - name: Checkout uipath-python - uses: actions/checkout@v4 - - - name: Setup uv - uses: astral-sh/setup-uv@v5 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Build uipath-core package - working-directory: packages/uipath-core - run: uv build - - - name: Build uipath-platform package - working-directory: packages/uipath-platform - run: uv build - - - name: Build uipath package - working-directory: packages/uipath - run: uv build - - - name: Upload wheels - uses: actions/upload-artifact@v4 - with: - name: uipath-wheels - path: packages/*/dist/*.whl - - test-uipath-llamaindex: - needs: [build-wheels] - runs-on: ${{ matrix.os }} - strategy: - matrix: - python-version: [ "3.11", "3.12", "3.13" ] - os: [ ubuntu-latest, windows-latest ] - - permissions: - contents: read - - steps: - - name: Setup uv - uses: astral-sh/setup-uv@v5 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Download wheels - uses: actions/download-artifact@v4 - with: - name: uipath-wheels - path: wheels - - - name: Checkout uipath-integrations-python - uses: actions/checkout@v4 - with: - repository: 'UiPath/uipath-integrations-python' - path: 'uipath-integrations-python' - - - name: Update uipath packages - shell: bash - working-directory: uipath-integrations-python/packages/uipath-llamaindex - run: | - uv add ../../../wheels/uipath-core/dist/*.whl --dev - uv add ../../../wheels/uipath-platform/dist/*.whl --dev - uv add ../../../wheels/uipath/dist/*.whl --dev - - - name: Run uipath-llamaindex tests - working-directory: uipath-integrations-python/packages/uipath-llamaindex - run: | - uv sync - uv run pytest - - discover-testcases: - runs-on: ubuntu-latest - permissions: - contents: read - needs: [test-uipath-llamaindex] - outputs: - testcases: ${{ steps.discover.outputs.testcases }} - steps: - - name: Checkout uipath-integrations-python - uses: actions/checkout@v4 - with: - repository: 'UiPath/uipath-integrations-python' - path: 'uipath-integrations-python' - - - name: Discover testcases - id: discover - working-directory: uipath-integrations-python/packages/uipath-llamaindex - run: | - # Find all testcase folders (excluding common folders like README, etc.) - testcase_dirs=$(find testcases -maxdepth 1 -type d -name "*-*" | sed 's|testcases/||' | sort) - - echo "Found testcase directories:" - echo "$testcase_dirs" - - # Convert to JSON array for matrix - testcases_json=$(echo "$testcase_dirs" | jq -R -s -c 'split("\n")[:-1]') - echo "testcases=$testcases_json" >> $GITHUB_OUTPUT - - run-uipath-llamaindex-integration-tests: - runs-on: ubuntu-latest - needs: [build-wheels, discover-testcases] - container: - image: ghcr.io/astral-sh/uv:python3.12-bookworm - env: - UIPATH_JOB_KEY: "3a03d5cb-fa21-4021-894d-a8e2eda0afe0" - UIPATH_TRACING_ENABLED: false - permissions: - contents: read - strategy: - fail-fast: false - matrix: - testcase: ${{ fromJson(needs.discover-testcases.outputs.testcases) }} - environment: [alpha, staging] # temporary disable [cloud] - - name: "${{ matrix.testcase }} / ${{ matrix.environment }}" - - steps: - - name: Download wheels - uses: actions/download-artifact@v4 - with: - name: uipath-wheels - path: wheels - - - name: Checkout uipath-integrations-python - uses: actions/checkout@v4 - with: - repository: 'UiPath/uipath-integrations-python' - path: 'uipath-integrations-python' - - - name: Update uipath packages - shell: bash - working-directory: uipath-integrations-python/packages/uipath-llamaindex - run: | - uv add ../../../wheels/uipath-core/dist/*.whl - uv add ../../../wheels/uipath-platform/dist/*.whl - uv add ../../../wheels/uipath/dist/*.whl - - - name: Install dependencies - working-directory: uipath-integrations-python/packages/uipath-llamaindex - run: uv sync - - - name: Run testcase - env: - CLIENT_ID: ${{ matrix.environment == 'alpha' && secrets.ALPHA_TEST_CLIENT_ID || matrix.environment == 'staging' && secrets.STAGING_TEST_CLIENT_ID || matrix.environment == 'cloud' && secrets.CLOUD_TEST_CLIENT_ID }} - CLIENT_SECRET: ${{ matrix.environment == 'alpha' && secrets.ALPHA_TEST_CLIENT_SECRET || matrix.environment == 'staging' && secrets.STAGING_TEST_CLIENT_SECRET || matrix.environment == 'cloud' && secrets.CLOUD_TEST_CLIENT_SECRET }} - BASE_URL: ${{ matrix.environment == 'alpha' && secrets.ALPHA_BASE_URL || matrix.environment == 'staging' && secrets.STAGING_BASE_URL || matrix.environment == 'cloud' && secrets.CLOUD_BASE_URL }} - UV_PYTHON: "3.12" - working-directory: uipath-integrations-python/packages/uipath-llamaindex/testcases/${{ matrix.testcase }} - run: | - echo "Running testcase: ${{ matrix.testcase }}" - echo "Environment: ${{ matrix.environment }}" - - # Execute the testcase run script directly - bash run.sh - bash ../common/validate_output.sh From 79756aa5aacd4aa45e35505ec73631ebc60368b5 Mon Sep 17 00:00:00 2001 From: dianagrecu-uipath Date: Wed, 29 Apr 2026 15:21:37 +0300 Subject: [PATCH 041/191] feat: add is_case_manager field in agent declaration (#1594) Add is_case_manager field in agent declaration --- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/agent/models/agent.py | 6 + .../uipath/tests/agent/models/test_agent.py | 113 ++++++++++++++++++ packages/uipath/uv.lock | 2 +- 4 files changed, 121 insertions(+), 2 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index c6a998262..3883b3d84 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.58" +version = "2.10.59" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 7694a861e..8f8b8a207 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -1156,6 +1156,7 @@ class AgentMetadata(BaseCfg): """Agent metadata model.""" is_conversational: bool = Field(alias="isConversational") + is_case_manager: bool = Field(default=False, alias="isCaseManager") storage_version: str = Field(alias="storageVersion") @@ -1216,6 +1217,11 @@ def is_conversational(self) -> bool: return metadata.is_conversational return False + @property + def is_case_manager(self) -> bool: + """Checks if the agent is a case manager agent.""" + return self.metadata.is_case_manager if self.metadata else False + @staticmethod def _normalize_guardrails(v: Dict[str, Any]) -> None: guards = v.get("guardrails") diff --git a/packages/uipath/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py index d00e5a42b..6c4c0519a 100644 --- a/packages/uipath/tests/agent/models/test_agent.py +++ b/packages/uipath/tests/agent/models/test_agent.py @@ -3283,6 +3283,119 @@ def test_is_conversational_false_by_default(self): assert config.is_conversational is False +class TestAgentDefinitionIsCaseManager: + """Tests for AgentDefinition.is_case_manager property.""" + + def test_is_case_manager_true_when_metadata_set(self): + """Returns True when metadata.is_case_manager is True.""" + json_data = { + "id": "test-case-manager", + "name": "Case Manager Agent", + "version": "1.0.0", + "metadata": { + "isConversational": False, + "isCaseManager": True, + "storageVersion": "1.0.0", + }, + "settings": { + "model": "gpt-4o", + "maxTokens": 4096, + "temperature": 0, + "engine": "basic-v1", + }, + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": {"type": "object", "properties": {}}, + "resources": [], + "messages": [ + {"role": "system", "content": "You are a case manager agent."} + ], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + assert config.is_case_manager is True + + def test_is_case_manager_false_when_metadata_set_false(self): + """Returns False when metadata.is_case_manager is False.""" + json_data = { + "id": "test-non-case-manager", + "name": "Regular Agent", + "version": "1.0.0", + "metadata": { + "isConversational": False, + "isCaseManager": False, + "storageVersion": "1.0.0", + }, + "settings": { + "model": "gpt-4o", + "maxTokens": 4096, + "temperature": 0, + "engine": "basic-v1", + }, + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": {"type": "object", "properties": {}}, + "resources": [], + "messages": [{"role": "system", "content": "You are an agent."}], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + assert config.is_case_manager is False + + def test_is_case_manager_false_when_not_in_metadata(self): + """Returns False when isCaseManager is not present in metadata.""" + json_data = { + "id": "test-no-case-manager-field", + "name": "Agent Without CM Field", + "version": "1.0.0", + "metadata": {"isConversational": False, "storageVersion": "1.0.0"}, + "settings": { + "model": "gpt-4o", + "maxTokens": 4096, + "temperature": 0, + "engine": "basic-v1", + }, + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": {"type": "object", "properties": {}}, + "resources": [], + "messages": [{"role": "system", "content": "You are an agent."}], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + assert config.is_case_manager is False + + def test_is_case_manager_false_when_no_metadata(self): + """Returns False when agent has no metadata.""" + json_data = { + "id": "test-no-metadata", + "name": "Agent Without Metadata", + "version": "1.0.0", + "settings": { + "model": "gpt-4o", + "maxTokens": 4096, + "temperature": 0, + "engine": "basic-v1", + }, + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": {"type": "object", "properties": {}}, + "resources": [], + "messages": [{"role": "system", "content": "You are an agent."}], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + assert config.is_case_manager is False + + class TestAgentBuilderConfigResources: """Tests for AgentDefinition resource configuration parsing.""" diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index b85d0fb8d..10b7e683b 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.58" +version = "2.10.59" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From a927b7ea452a0f5d7fd0d298ee9504a0d779af92 Mon Sep 17 00:00:00 2001 From: GabrielVasilescu04 Date: Thu, 30 Apr 2026 10:38:09 +0300 Subject: [PATCH 042/191] feat: add IS resume triggers (#1580) --- packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/triggers/__init__.py | 2 + .../src/uipath/core/triggers/trigger.py | 22 ++ packages/uipath-core/uv.lock | 2 +- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/common/__init__.py | 2 + .../platform/common/interrupt_models.py | 20 ++ .../platform/orchestrator/_jobs_service.py | 46 +++ .../platform/resume_triggers/_protocol.py | 78 +++++ .../tests/services/test_hitl.py | 269 ++++++++++++++++++ packages/uipath-platform/uv.lock | 4 +- packages/uipath/uv.lock | 4 +- 12 files changed, 446 insertions(+), 7 deletions(-) diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 7e559c9a4..cb188084e 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.14" +version = "0.5.15" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/triggers/__init__.py b/packages/uipath-core/src/uipath/core/triggers/__init__.py index 400462277..fb4ee6ae5 100644 --- a/packages/uipath-core/src/uipath/core/triggers/__init__.py +++ b/packages/uipath-core/src/uipath/core/triggers/__init__.py @@ -4,11 +4,13 @@ "UiPathResumeTrigger", "UiPathResumeTriggerType", "UiPathApiTrigger", + "UiPathIntegrationTrigger", "UiPathResumeTriggerName", ] from uipath.core.triggers.trigger import ( UiPathApiTrigger, + UiPathIntegrationTrigger, UiPathResumeTrigger, UiPathResumeTriggerName, UiPathResumeTriggerType, diff --git a/packages/uipath-core/src/uipath/core/triggers/trigger.py b/packages/uipath-core/src/uipath/core/triggers/trigger.py index 424245079..c897acd28 100644 --- a/packages/uipath-core/src/uipath/core/triggers/trigger.py +++ b/packages/uipath-core/src/uipath/core/triggers/trigger.py @@ -53,6 +53,25 @@ class UiPathApiTrigger(BaseModel): model_config = ConfigDict(validate_by_name=True) +class UiPathIntegrationTrigger(BaseModel): + """Integration Services (Inbox) resume trigger request. + + Mirrors Orchestrator's `IntegrationResumeDto`: the configuration needed to + register a remote event trigger through the Connections service and + correlate the eventual payload back to the suspended job via `inbox_id`. + """ + + connector: str = Field(alias="connector") + connection_id: str = Field(alias="connectionId") + operation: str = Field(alias="operation") + object_name: str = Field(alias="objectName") + filter_expression: str | None = Field(default=None, alias="filterExpression") + parameters: dict[str, str] | None = Field(default=None, alias="parameters") + inbox_id: str = Field(alias="inboxId") + + model_config = ConfigDict(validate_by_name=True) + + class UiPathResumeTrigger(BaseModel): """Information needed to resume execution.""" @@ -65,6 +84,9 @@ class UiPathResumeTrigger(BaseModel): ) item_key: str | None = Field(default=None, alias="itemKey") api_resume: UiPathApiTrigger | None = Field(default=None, alias="apiResume") + integration_resume: UiPathIntegrationTrigger | None = Field( + default=None, alias="integrationResume" + ) folder_path: str | None = Field(default=None, alias="folderPath") folder_key: str | None = Field(default=None, alias="folderKey") payload: Any | None = Field(default=None, alias="interruptObject", exclude=True) diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index 61159a1d5..bf73c603d 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -1007,7 +1007,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.14" +version = "0.5.15" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 59585688f..234dd78b8 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.39" +version = "0.1.40" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/common/__init__.py b/packages/uipath-platform/src/uipath/platform/common/__init__.py index 9070d0d70..cefd92075 100644 --- a/packages/uipath-platform/src/uipath/platform/common/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/common/__init__.py @@ -48,6 +48,7 @@ WaitEphemeralIndex, WaitEphemeralIndexRaw, WaitEscalation, + WaitIntegrationEvent, WaitJob, WaitJobRaw, WaitSystemAgent, @@ -89,6 +90,7 @@ "WaitEphemeralIndexRaw", "DocumentExtractionValidation", "WaitDocumentExtractionValidation", + "WaitIntegrationEvent", "RequestSpec", "Endpoint", "UiPathUrl", diff --git a/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py b/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py index 100b601bd..3b2468551 100644 --- a/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py +++ b/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py @@ -259,3 +259,23 @@ class WaitDocumentExtractionValidation(BaseModel): extraction_validation: StartExtractionValidationResponse task_url: str | None = None + + +class WaitIntegrationEvent(BaseModel): + """Model representing a wait on an Integration Services event. + + Used to suspend a job until a remote event (e.g. Slack message, Teams reply) + is delivered by Integration Services. The SDK resolves `connection_name` + (scoped to `connection_folder_path` when provided) to the underlying + connection id and generates a fresh `inbox_id` when the trigger is created; + the rest of the fields describe which remote event to subscribe to via + the Connections service. + """ + + connector: str + connection_name: str + connection_folder_path: str | None = None + operation: str + object_name: str + filter_expression: str | None = None + parameters: dict[str, str] | None = None diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py index f9433d221..a9a132e02 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py @@ -674,6 +674,52 @@ def _retrieve_api_payload_spec( }, ) + def retrieve_inbox_payload(self, inbox_id: str) -> Any: + """Fetch payload data for Integration Services (Inbox) triggers. + + Unlike `retrieve_api_payload`, this returns the response body as-is. + Orchestrator's `GET /JobTriggers/GetPayload/{inboxId}` returns the + stored payload directly without an envelope. + + Args: + inbox_id: The Id of the inbox to fetch the payload for. + + Returns: + The stored payload. + """ + spec = self._retrieve_api_payload_spec(inbox_id=inbox_id) + + response = self.request( + spec.method, + url=spec.endpoint, + headers=spec.headers, + ) + + return response.json() + + async def retrieve_inbox_payload_async(self, inbox_id: str) -> Any: + """Asynchronously fetch payload data for Integration Services (Inbox) triggers. + + Unlike `retrieve_api_payload_async`, this returns the response body + as-is. Orchestrator's `GET /JobTriggers/GetPayload/{inboxId}` returns + the stored payload directly without an envelope. + + Args: + inbox_id: The Id of the inbox to fetch the payload for. + + Returns: + The stored payload. + """ + spec = self._retrieve_api_payload_spec(inbox_id=inbox_id) + + response = await self.request_async( + spec.method, + url=spec.endpoint, + headers=spec.headers, + ) + + return response.json() + def _extract_first_inbox_id(self, response: Any) -> str: if len(response["value"]) > 0: return response["value"][0]["ItemKey"] diff --git a/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py b/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py index 98e4f81a2..b2dbae787 100644 --- a/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py +++ b/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py @@ -13,6 +13,7 @@ from uipath.core.serialization import serialize_object from uipath.core.triggers import ( UiPathApiTrigger, + UiPathIntegrationTrigger, UiPathResumeTrigger, UiPathResumeTriggerName, UiPathResumeTriggerType, @@ -43,11 +44,13 @@ WaitEphemeralIndex, WaitEphemeralIndexRaw, WaitEscalation, + WaitIntegrationEvent, WaitJob, WaitJobRaw, WaitSystemAgent, WaitTask, ) +from uipath.platform.connections import EventArguments from uipath.platform.context_grounding import DeepRagStatus, IndexStatus from uipath.platform.context_grounding.context_grounding_index import ( ContextGroundingIndex, @@ -401,6 +404,23 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: f"Error fetching API trigger payload for inbox {trigger.api_resume.inbox_id}: {str(e)}", ) from e + case UiPathResumeTriggerType.INBOX: + if trigger.integration_resume and trigger.integration_resume.inbox_id: + try: + inbox_payload = await uipath.jobs.retrieve_inbox_payload_async( + trigger.integration_resume.inbox_id + ) + event_args = EventArguments.model_validate(inbox_payload) + return await uipath.connections.retrieve_event_payload_async( + event_args + ) + except Exception as e: + raise UiPathFaultedTriggerError( + ErrorCategory.SYSTEM, + f"Failed to get trigger payload" + f"Error fetching Inbox trigger payload for inbox {trigger.integration_resume.inbox_id}: {str(e)}", + ) from e + case _: raise UiPathFaultedTriggerError( ErrorCategory.SYSTEM, @@ -461,6 +481,9 @@ async def create_trigger(self, suspend_value: Any) -> UiPathResumeTrigger: case UiPathResumeTriggerType.API: self._handle_api_trigger(suspend_value, resume_trigger) + case UiPathResumeTriggerType.INBOX: + await self._handle_inbox_trigger(suspend_value, resume_trigger) + case UiPathResumeTriggerType.DEEP_RAG: await self._handle_deep_rag_job_trigger( suspend_value, resume_trigger @@ -545,6 +568,8 @@ def _determine_trigger_type(self, value: Any) -> UiPathResumeTriggerType: value, (DocumentExtractionValidation, WaitDocumentExtractionValidation) ): return UiPathResumeTriggerType.IXP_VS_ESCALATION + if isinstance(value, WaitIntegrationEvent): + return UiPathResumeTriggerType.INBOX # default to API trigger return UiPathResumeTriggerType.API @@ -579,6 +604,8 @@ def _determine_trigger_name(self, value: Any) -> UiPathResumeTriggerName: return UiPathResumeTriggerName.BATCH_RAG if isinstance(value, (DocumentExtraction, WaitDocumentExtraction)): return UiPathResumeTriggerName.EXTRACTION + if isinstance(value, WaitIntegrationEvent): + return UiPathResumeTriggerName.INBOX # default to API trigger return UiPathResumeTriggerName.API @@ -901,6 +928,57 @@ def _handle_api_trigger( inbox_id=str(uuid.uuid4()), request=serialize_object(value) ) + async def _handle_inbox_trigger( + self, value: WaitIntegrationEvent, resume_trigger: UiPathResumeTrigger + ) -> None: + """Handle Inbox-type resume triggers. + + Resolves `connection_name` (scoped to `connection_folder_path` when + provided) to a connection id via the Connections service, populates + `integration_resume` with the Integration Services configuration plus a + freshly generated `inbox_id`. The Connections-service registration is + performed server-side by Orchestrator's `CreateResumeTriggerTaskHandler` + once the job suspends. + + Args: + value: The suspend value (WaitIntegrationEvent) + resume_trigger: The resume trigger to populate + + Raises: + Exception: If no connection matches `connection_name`, or if more + than one exact match is found. + """ + uipath = UiPath() + connections = await uipath.connections.list_async( + name=value.connection_name, + folder_path=value.connection_folder_path, + connector_key=value.connector, + ) + connection = next( + (c for c in connections if c.name == value.connection_name), None + ) + if connection is None: + raise Exception( + f"No connection named '{value.connection_name}' " + f"for connector '{value.connector}' found" + + ( + f" in folder '{value.connection_folder_path}'" + if value.connection_folder_path + else "" + ) + ) + assert connection.id is not None + + resume_trigger.integration_resume = UiPathIntegrationTrigger( + connector=value.connector, + connection_id=connection.id, + operation=value.operation, + object_name=value.object_name, + filter_expression=value.filter_expression, + parameters=value.parameters, + inbox_id=str(uuid.uuid4()), + ) + class UiPathResumeTriggerHandler: """Combined handler for creating and reading resume triggers. diff --git a/packages/uipath-platform/tests/services/test_hitl.py b/packages/uipath-platform/tests/services/test_hitl.py index 2e8069a3e..7395908a9 100644 --- a/packages/uipath-platform/tests/services/test_hitl.py +++ b/packages/uipath-platform/tests/services/test_hitl.py @@ -1,3 +1,4 @@ +import json import uuid from typing import Any from unittest.mock import AsyncMock, patch @@ -7,6 +8,7 @@ from uipath.core.errors import ErrorCategory, UiPathFaultedTriggerError from uipath.core.triggers import ( UiPathApiTrigger, + UiPathIntegrationTrigger, UiPathResumeTrigger, UiPathResumeTriggerName, UiPathResumeTriggerType, @@ -32,11 +34,13 @@ WaitDocumentExtractionValidation, WaitEphemeralIndex, WaitEphemeralIndexRaw, + WaitIntegrationEvent, WaitJob, WaitJobRaw, WaitSystemAgent, WaitTask, ) +from uipath.platform.connections import Connection from uipath.platform.context_grounding import ( BatchTransformCreationResponse, BatchTransformOutputColumn, @@ -508,6 +512,91 @@ async def test_read_api_trigger_failure( await reader.read_trigger(resume_trigger) assert exc_info.value.category == ErrorCategory.SYSTEM + @pytest.mark.anyio + async def test_read_inbox_trigger( + self, + httpx_mock: HTTPXMock, + base_url: str, + setup_test_env: None, + ) -> None: + """Test reading an Inbox trigger fetches the IS metadata via GetPayload + and then enriches it via /elements_/v1/events/{processedEventId}. + """ + inbox_id = str(uuid.uuid4()) + processed_event_id = "v2::pp::1777041494382::334071::e374ecd5d0f73c21" + inbox_metadata = { + "UiPathEventConnector": "uipath-slack", + "UiPathEvent": "NEW_MESSAGE", + "UiPathEventObjectType": "Message", + "UiPathEventObjectId": "C123:1777041494.382", + "UiPathAdditionalEventData": json.dumps( + {"processedEventId": processed_event_id} + ), + } + enriched_event = { + "channel": "alerts", + "user": "U456", + "text": "hello from slack", + "ts": "1777041494.382", + } + + httpx_mock.add_response( + url=f"{base_url}/orchestrator_/api/JobTriggers/GetPayload/{inbox_id}", + status_code=200, + json=inbox_metadata, + ) + httpx_mock.add_response( + url=f"{base_url}/elements_/v1/events/{processed_event_id}", + status_code=200, + json=enriched_event, + ) + + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.INBOX, + integration_resume=UiPathIntegrationTrigger( + connector="slack", + connection_id=str(uuid.uuid4()), + operation="OnMessage", + object_name="Message", + inbox_id=inbox_id, + ), + ) + + reader = UiPathResumeTriggerReader() + result = await reader.read_trigger(resume_trigger) + assert result == enriched_event + + @pytest.mark.anyio + async def test_read_inbox_trigger_failure( + self, + httpx_mock: HTTPXMock, + base_url: str, + setup_test_env: None, + ) -> None: + """Test reading an Inbox trigger with a failed payload response.""" + inbox_id = str(uuid.uuid4()) + + httpx_mock.add_response( + url=f"{base_url}/orchestrator_/api/JobTriggers/GetPayload/{inbox_id}", + status_code=500, + ) + + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.INBOX, + integration_resume=UiPathIntegrationTrigger( + connector="slack", + connection_id=str(uuid.uuid4()), + operation="OnMessage", + object_name="Message", + inbox_id=inbox_id, + ), + ) + + with pytest.raises(UiPathFaultedTriggerError) as exc_info: + reader = UiPathResumeTriggerReader() + await reader.read_trigger(resume_trigger) + assert exc_info.value.category == ErrorCategory.SYSTEM + @pytest.mark.anyio async def test_read_deep_rag_trigger_successful( self, @@ -1374,6 +1463,186 @@ async def test_create_resume_trigger_api( assert isinstance(resume_trigger.api_resume.inbox_id, str) assert resume_trigger.api_resume.request == api_input + @pytest.mark.anyio + async def test_create_resume_trigger_wait_integration_event( + self, + setup_test_env: None, + ) -> None: + """Test creating a resume trigger for WaitIntegrationEvent.""" + connection_id = str(uuid.uuid4()) + mock_connection = Connection( + id=connection_id, name="Slack-Alerts", element_instance_id=1 + ) + mock_list_async = AsyncMock(return_value=[mock_connection]) + + wait_event = WaitIntegrationEvent( + connector="slack", + connection_name="Slack-Alerts", + connection_folder_path="Shared", + operation="OnMessage", + object_name="Message", + filter_expression="channel == 'alerts'", + parameters={"channel_id": "C123"}, + ) + + with patch( + "uipath.platform.connections._connections_service.ConnectionsService.list_async", + new=mock_list_async, + ): + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(wait_event) + + assert resume_trigger is not None + assert resume_trigger.trigger_type == UiPathResumeTriggerType.INBOX + assert resume_trigger.trigger_name == UiPathResumeTriggerName.INBOX + assert resume_trigger.api_resume is None + assert resume_trigger.integration_resume is not None + assert resume_trigger.integration_resume.connector == "slack" + assert resume_trigger.integration_resume.connection_id == connection_id + assert resume_trigger.integration_resume.operation == "OnMessage" + assert resume_trigger.integration_resume.object_name == "Message" + assert ( + resume_trigger.integration_resume.filter_expression == "channel == 'alerts'" + ) + assert resume_trigger.integration_resume.parameters == {"channel_id": "C123"} + assert isinstance(resume_trigger.integration_resume.inbox_id, str) + uuid.UUID(resume_trigger.integration_resume.inbox_id) + mock_list_async.assert_called_once_with( + name="Slack-Alerts", folder_path="Shared", connector_key="slack" + ) + + @pytest.mark.anyio + async def test_create_resume_trigger_wait_integration_event_optional_fields_omitted( + self, + setup_test_env: None, + ) -> None: + """Test that filter_expression, parameters, and folder_path are optional.""" + mock_connection = Connection( + id=str(uuid.uuid4()), name="Teams-Default", element_instance_id=2 + ) + mock_list_async = AsyncMock(return_value=[mock_connection]) + + wait_event = WaitIntegrationEvent( + connector="teams", + connection_name="Teams-Default", + operation="OnReply", + object_name="Reply", + ) + + with patch( + "uipath.platform.connections._connections_service.ConnectionsService.list_async", + new=mock_list_async, + ): + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(wait_event) + + assert resume_trigger.integration_resume is not None + assert resume_trigger.integration_resume.filter_expression is None + assert resume_trigger.integration_resume.parameters is None + mock_list_async.assert_called_once_with( + name="Teams-Default", folder_path=None, connector_key="teams" + ) + + @pytest.mark.anyio + async def test_wait_integration_event_serializes_with_camelcase_aliases( + self, + setup_test_env: None, + ) -> None: + """Wire shape: UiPathResumeTrigger.integration_resume must serialize + with the field names Orchestrator's ResumeTriggerDto/IntegrationResumeDto + expect (PascalCase-ish camelCase). + """ + connection_id = str(uuid.uuid4()) + mock_connection = Connection( + id=connection_id, name="Slack-Alerts", element_instance_id=3 + ) + mock_list_async = AsyncMock(return_value=[mock_connection]) + + wait_event = WaitIntegrationEvent( + connector="slack", + connection_name="Slack-Alerts", + operation="OnMessage", + object_name="Message", + ) + + with patch( + "uipath.platform.connections._connections_service.ConnectionsService.list_async", + new=mock_list_async, + ): + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(wait_event) + + dumped = resume_trigger.model_dump(by_alias=True, exclude_none=True) + + assert dumped["triggerType"] == UiPathResumeTriggerType.INBOX + assert "integrationResume" in dumped + integration = dumped["integrationResume"] + assert integration["connector"] == "slack" + assert integration["connectionId"] == connection_id + assert integration["operation"] == "OnMessage" + assert integration["objectName"] == "Message" + assert "inboxId" in integration + uuid.UUID(integration["inboxId"]) + + @pytest.mark.anyio + async def test_create_resume_trigger_wait_integration_event_no_match( + self, + setup_test_env: None, + ) -> None: + """Listing returns no exact-name match -> creator raises.""" + mock_list_async = AsyncMock(return_value=[]) + + wait_event = WaitIntegrationEvent( + connector="slack", + connection_name="Missing-Connection", + operation="OnMessage", + object_name="Message", + ) + + with patch( + "uipath.platform.connections._connections_service.ConnectionsService.list_async", + new=mock_list_async, + ): + processor = UiPathResumeTriggerCreator() + with pytest.raises(UiPathFaultedTriggerError): + await processor.create_trigger(wait_event) + + @pytest.mark.anyio + async def test_create_resume_trigger_wait_integration_event_filters_to_exact_match( + self, + setup_test_env: None, + ) -> None: + """list_async partial-matches; creator must pick the exact-name entry.""" + target_id = str(uuid.uuid4()) + # list_async partial-matches; simulate prefix-matching returning extras + mock_list_async = AsyncMock( + return_value=[ + Connection( + id=str(uuid.uuid4()), + name="Slack-Alerts-Old", + element_instance_id=4, + ), + Connection(id=target_id, name="Slack-Alerts", element_instance_id=5), + ] + ) + + wait_event = WaitIntegrationEvent( + connector="slack", + connection_name="Slack-Alerts", + operation="OnMessage", + object_name="Message", + ) + + with patch( + "uipath.platform.connections._connections_service.ConnectionsService.list_async", + new=mock_list_async, + ): + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(wait_event) + + assert resume_trigger.integration_resume is not None + assert resume_trigger.integration_resume.connection_id == target_id + @pytest.mark.anyio async def test_create_resume_trigger_create_deep_rag( self, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 415634498..773e3ea39 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1056,7 +1056,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.14" +version = "0.5.15" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.39" +version = "0.1.40" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 10b7e683b..5bef60a68 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2650,7 +2650,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.14" +version = "0.5.15" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.39" +version = "0.1.40" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 867a31e7957cbc2ae81c7ddb9e71a3d7187eed51 Mon Sep 17 00:00:00 2001 From: dianagrecu-uipath Date: Thu, 30 Apr 2026 15:17:00 +0300 Subject: [PATCH 043/191] refactor(agent): replace metadata.is_case_manager with metadata.variant (#1606) --- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/agent/models/agent.py | 12 +++++++++-- .../uipath/tests/agent/models/test_agent.py | 20 +++++++++---------- packages/uipath/uv.lock | 2 +- 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 3883b3d84..c2ffb843f 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.59" +version = "2.10.60" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 8f8b8a207..38bc8a815 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -165,6 +165,12 @@ class AgentMessageRole(str, CaseInsensitiveEnum): USER = "user" +class AgentVariant(str, CaseInsensitiveEnum): + """Agent variant enumeration.""" + + CASE_MANAGER = "caseManager" + + class AgentGuardrailActionType(str, CaseInsensitiveEnum): """Agent guardrail action type enumeration.""" @@ -1156,7 +1162,7 @@ class AgentMetadata(BaseCfg): """Agent metadata model.""" is_conversational: bool = Field(alias="isConversational") - is_case_manager: bool = Field(default=False, alias="isCaseManager") + variant: Optional[AgentVariant] = Field(default=None, alias="variant") storage_version: str = Field(alias="storageVersion") @@ -1220,7 +1226,9 @@ def is_conversational(self) -> bool: @property def is_case_manager(self) -> bool: """Checks if the agent is a case manager agent.""" - return self.metadata.is_case_manager if self.metadata else False + if not self.metadata: + return False + return self.metadata.variant == AgentVariant.CASE_MANAGER @staticmethod def _normalize_guardrails(v: Dict[str, Any]) -> None: diff --git a/packages/uipath/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py index 6c4c0519a..4daae105a 100644 --- a/packages/uipath/tests/agent/models/test_agent.py +++ b/packages/uipath/tests/agent/models/test_agent.py @@ -3286,15 +3286,15 @@ def test_is_conversational_false_by_default(self): class TestAgentDefinitionIsCaseManager: """Tests for AgentDefinition.is_case_manager property.""" - def test_is_case_manager_true_when_metadata_set(self): - """Returns True when metadata.is_case_manager is True.""" + def test_is_case_manager_true_when_variant_is_case_manager(self): + """Returns True when metadata.variant is "caseManager".""" json_data = { "id": "test-case-manager", "name": "Case Manager Agent", "version": "1.0.0", "metadata": { "isConversational": False, - "isCaseManager": True, + "variant": "caseManager", "storageVersion": "1.0.0", }, "settings": { @@ -3317,15 +3317,15 @@ def test_is_case_manager_true_when_metadata_set(self): assert config.is_case_manager is True - def test_is_case_manager_false_when_metadata_set_false(self): - """Returns False when metadata.is_case_manager is False.""" + def test_is_case_manager_false_when_variant_is_none(self): + """Returns False when metadata.variant is None.""" json_data = { "id": "test-non-case-manager", "name": "Regular Agent", "version": "1.0.0", "metadata": { "isConversational": False, - "isCaseManager": False, + "variant": None, "storageVersion": "1.0.0", }, "settings": { @@ -3346,11 +3346,11 @@ def test_is_case_manager_false_when_metadata_set_false(self): assert config.is_case_manager is False - def test_is_case_manager_false_when_not_in_metadata(self): - """Returns False when isCaseManager is not present in metadata.""" + def test_is_case_manager_false_when_variant_not_in_metadata(self): + """Returns False when variant is not present in metadata.""" json_data = { - "id": "test-no-case-manager-field", - "name": "Agent Without CM Field", + "id": "test-no-variant-field", + "name": "Agent Without Variant Field", "version": "1.0.0", "metadata": {"isConversational": False, "storageVersion": "1.0.0"}, "settings": { diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 5bef60a68..ea05f4f25 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.59" +version = "2.10.60" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 61439910d6448bccaf81dfab60698dbe47f35213 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Thu, 30 Apr 2026 14:28:51 +0200 Subject: [PATCH 044/191] docs: document uv.lock packing and --nolock flag (#423) (#1597) Co-authored-by: Claude Opus 4.7 (1M context) --- packages/uipath/docs/cli/index.md | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/uipath/docs/cli/index.md b/packages/uipath/docs/cli/index.md index 9e8401e8c..46f96c460 100644 --- a/packages/uipath/docs/cli/index.md +++ b/packages/uipath/docs/cli/index.md @@ -203,6 +203,25 @@ authors = [{name = "Your Name", email = "your.email@example.com"}] ``` /// +/// info +### Dependency Locking + +By default, `uipath pack` includes `uv.lock` in the `.nupkg` (creating it if it does not exist). The executor then installs the pinned versions from the lock file, so every run uses the exact same dependency versions. + +Use `--nolock` to opt out — `uv.lock` is not added to the package. With no lock file present, the executor resolves dependencies on each run and picks the latest versions compatible with the constraints in your `pyproject.toml`. + + +```shell +> uipath pack --nolock +⠋ Packaging project ... +✓ Project successfully packaged. +``` + +**When to lock (default):** you want reproducible runs and protection against breaking changes or malicious upgrades in your dependencies. The versions you tested with are the versions that run. + +**When to use `--nolock`:** you want each run to pick up the latest patches automatically within your declared constraints, or your project does not use uv. +/// + ```shell > uipath pack @@ -289,6 +308,19 @@ Importing referenced resources to Studio Web project... 🔵 Resource import summary: 0 total resources - 0 created, 0 updated, 0 unchanged, 0 not found ``` + +/// info +### Dependency Locking + +By default, `uipath push` includes `uv.lock` in the upload (creating it if it does not exist). The executor then installs the pinned versions from the lock file, so every run uses the exact same dependency versions. + +Use `--nolock` to opt out — `uv.lock` is not uploaded. With no lock file present, the executor resolves dependencies on each run and picks the latest versions compatible with the constraints in your `pyproject.toml`. + +**When to lock (default):** you want reproducible runs and protection against breaking changes or malicious upgrades in your dependencies. The versions you tested with are the versions that run. + +**When to use `--nolock`:** you want each run to pick up the latest patches automatically within your declared constraints, or your project does not use uv. +/// + --- ::: mkdocs-click From fa662117311e1635fa02ef5a63bcb6e072159ba2 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Thu, 30 Apr 2026 14:29:40 +0200 Subject: [PATCH 045/191] docs: document client credentials authentication flow (#422) (#1598) --- packages/uipath/docs/cli/index.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/uipath/docs/cli/index.md b/packages/uipath/docs/cli/index.md index 46f96c460..165c5431a 100644 --- a/packages/uipath/docs/cli/index.md +++ b/packages/uipath/docs/cli/index.md @@ -32,6 +32,37 @@ Select tenant number: 0 Selected tenant: Tenant1 ✓ Authentication successful. ``` + +/// info | Unattended Authentication (Client Credentials) + +For CI/CD pipelines and other non-interactive contexts, authenticate with the OAuth client credentials flow by passing all three of `--client-id`, `--client-secret`, and `--base-url`. The CLI exchanges them for an access token and writes it to the same on-disk session used by interactive logins, so subsequent commands like `uipath publish` and `uipath invoke` work without further setup. + +The `--base-url` must point at the tenant scope (`https:////`). The optional `--scope` flag controls the OAuth scopes requested and defaults to `OR.Execution`. Pass a space-separated list (for example `"OR.Execution OR.Queues"`) to request additional scopes — match the scopes you granted to the External Application and the operations you intend to run. + +**Setup:** + +1. In the Automation Cloud **Admin** page, open **External Applications** and create one of type *Confidential*. Grant it the Orchestrator scopes you need (for example `OR.Execution`). See the [External Applications guide](https://docs.uipath.com/automation-cloud/automation-cloud/latest/admin-guide/managing-external-applications) for details. +2. Copy the generated **App ID** and **App Secret** — these become `--client-id` and `--client-secret`. + +**Example:** + + +```shell +> uipath auth --client-id 12345678-c4c5-4f1f-93ff-4f5ab47d57ea \ + --client-secret 'your-secret' \ + --base-url https://cloud.uipath.com/your-org/your-tenant +✓ Authentication successful. +> uipath publish --tenant +``` + +/// warning +Treat `--client-secret` as a credential. In CI, prefer reading it from a secret store and passing it on the command line, rather than committing it to source control or leaving it in shell history. +/// + +**Configuring the same flow in code:** if you would rather skip the CLI session and pass credentials directly to the SDK, the [`asset-modifier-agent` sample](https://github.com/UiPath/uipath-python/tree/main/packages/uipath/samples/asset-modifier-agent) shows how to construct a `UiPath` client with `client_id`, `client_secret`, `scope`, and `base_url` from environment variables. + +/// + --- ::: mkdocs-click From 1732f7a0a5278137e0be05586a72135b9c10ba8a Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Thu, 30 Apr 2026 17:05:57 +0200 Subject: [PATCH 046/191] fix: serialize datetime fields when creating queue items (#670) (#1607) --- packages/uipath-platform/pyproject.toml | 2 +- .../platform/orchestrator/_queues_service.py | 14 +++++--- .../tests/services/test_queues_service.py | 34 +++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 2 +- packages/uipath/uv.lock | 2 +- 6 files changed, 47 insertions(+), 9 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 234dd78b8..a96a8eded 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.40" +version = "0.1.41" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_queues_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_queues_service.py index eede00ecf..1a2985072 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_queues_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_queues_service.py @@ -454,7 +454,9 @@ def _create_item_spec( elif isinstance(item, QueueItem): queue_item = item - item_data = queue_item.model_dump(exclude_unset=True, by_alias=True) + item_data = queue_item.model_dump( + mode="json", exclude_unset=True, by_alias=True + ) resolved_name = queue_name or item_data.get("Name") if resolved_name is None: raise ValueError( @@ -493,9 +495,11 @@ def _create_items_spec( "queueName": queue_name, "commitType": commit_type.value, "queueItems": [ - item.model_dump(exclude_unset=True, by_alias=True) + item.model_dump(mode="json", exclude_unset=True, by_alias=True) if isinstance(item, QueueItem) - else QueueItem(**item).model_dump(exclude_unset=True, by_alias=True) + else QueueItem(**item).model_dump( + mode="json", exclude_unset=True, by_alias=True + ) for item in items ], }, @@ -519,7 +523,7 @@ def _create_transaction_item_spec( transaction_item = item transaction_data = transaction_item.model_dump( - exclude_unset=True, by_alias=True + mode="json", exclude_unset=True, by_alias=True ) resolved_name = queue_name or transaction_data.get("Name") if resolved_name is None: @@ -580,7 +584,7 @@ def _complete_transaction_item_spec( ), json={ "transactionResult": transaction_result.model_dump( - exclude_unset=True, by_alias=True + mode="json", exclude_unset=True, by_alias=True ) }, headers={ diff --git a/packages/uipath-platform/tests/services/test_queues_service.py b/packages/uipath-platform/tests/services/test_queues_service.py index 51cfeaa95..2dd81ccc0 100644 --- a/packages/uipath-platform/tests/services/test_queues_service.py +++ b/packages/uipath-platform/tests/services/test_queues_service.py @@ -1,4 +1,5 @@ import json +from datetime import datetime, timezone import pytest from pytest_httpx import HTTPXMock @@ -518,6 +519,39 @@ async def test_create_item_with_reference_async( == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.create_item_async/{version}" ) + def test_create_item_with_datetime_fields( + self, + httpx_mock: HTTPXMock, + service: QueuesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + defer = datetime(2026, 5, 1, 9, 0, 0, tzinfo=timezone.utc) + due = datetime(2026, 5, 2, 17, 30, 0, tzinfo=timezone.utc) + risk = datetime(2026, 5, 2, 12, 0, 0, tzinfo=timezone.utc) + queue_item = QueueItem( + priority=QueueItemPriority.NORMAL, + specific_content={"key": "value"}, + defer_date=defer, + due_date=due, + risk_sla_date=risk, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.AddQueueItem", + status_code=200, + json={"Id": 1}, + ) + + service.create_item(queue_item, queue_name="test-queue") + + sent_request = httpx_mock.get_request() + assert sent_request is not None + body = json.loads(sent_request.content.decode()) + assert body["itemData"]["DeferDate"] == defer.isoformat() + assert body["itemData"]["DueDate"] == due.isoformat() + assert body["itemData"]["RiskSlaDate"] == risk.isoformat() + def test_create_transaction_item( self, httpx_mock: HTTPXMock, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 773e3ea39..b8f7a0163 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.40" +version = "0.1.41" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index c2ffb843f..7e98f563b 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.8, <0.6.0", "uipath-runtime>=0.10.1, <0.11.0", - "uipath-platform>=0.1.39, <0.2.0", + "uipath-platform>=0.1.41, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index ea05f4f25..0525ffb1c 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.40" +version = "0.1.41" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From ee98f4d79e007b2781b3fa23bc0cf7ba2f76542c Mon Sep 17 00:00:00 2001 From: Gabriel Martin <91462031+gcuip@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:35:35 +0200 Subject: [PATCH 047/191] feat: add support for system indexes [ECS-1791] (#1605) --- packages/uipath-platform/pyproject.toml | 2 +- .../_context_grounding_service.py | 147 ++++- .../src/uipath/platform/errors/__init__.py | 5 + ...ext_grounding_index_not_found_exception.py | 13 + .../test_context_grounding_service.py | 524 ++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 2 +- .../cli/contract/test_sdk_cli_alignment.py | 8 +- packages/uipath/uv.lock | 2 +- 9 files changed, 683 insertions(+), 22 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/errors/_context_grounding_index_not_found_exception.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index a96a8eded..c52b917eb 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.41" +version = "0.1.42" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py b/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py index 706394aca..1c2eabd85 100644 --- a/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py +++ b/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py @@ -19,6 +19,7 @@ from ..errors import ( BatchTransformFailedException, BatchTransformNotCompleteException, + ContextGroundingIndexNotFoundError, IngestionInProgressException, UnsupportedDataSourceException, ) @@ -256,6 +257,42 @@ async def retrieve_across_folders_async( ContextGroundingIndex.model_validate(item) for item in response["value"] ] + @traced(name="contextgrounding_retrieve_system_indexes", run_type="uipath") + def _retrieve_system_indexes( + self, + name: Optional[str] = None, + ) -> List[ContextGroundingIndex]: + spec = self._retrieve_system_indexes_spec(name=name) + + response = self.request( + spec.method, + spec.endpoint, + params=spec.params, + ).json() + + return [ + ContextGroundingIndex.model_validate(item) for item in response["value"] + ] + + @traced(name="contextgrounding_retrieve_system_indexes", run_type="uipath") + async def _retrieve_system_indexes_async( + self, + name: Optional[str] = None, + ) -> List[ContextGroundingIndex]: + spec = self._retrieve_system_indexes_spec(name=name) + + response = ( + await self.request_async( + spec.method, + spec.endpoint, + params=spec.params, + ) + ).json() + + return [ + ContextGroundingIndex.model_validate(item) for item in response["value"] + ] + @resource_override(resource_type="index") @traced(name="contextgrounding_retrieve", run_type="uipath") def retrieve( @@ -263,30 +300,38 @@ def retrieve( name: str, folder_key: Optional[str] = None, folder_path: Optional[str] = None, + include_system_indexes: bool = False, ) -> ContextGroundingIndex: """Retrieve context grounding index information by its name. If no folder_key or folder_path is provided and no folder context is - configured, falls back to searching across all folders. + configured, falls back to searching across all folders. When + ``include_system_indexes`` is True, an additional fallback against + system indexes is attempted before raising not-found. Args: name (str): The name of the context index to retrieve. folder_key (Optional[str]): The key of the folder where the index resides. folder_path (Optional[str]): The path of the folder where the index resides. + include_system_indexes (bool): If True, fall back to system indexes + when the index is not found in the per-folder or across-folders listings. + Defaults to False. Returns: ContextGroundingIndex: The index information, including its configuration and metadata if found. Raises: - Exception: If no index with the given name is found. + ContextGroundingIndexNotFoundError: If no index with the given name is found. """ resolved_folder_key = self._resolve_folder_key(folder_key, folder_path) if not resolved_folder_key: indexes = self.retrieve_across_folders(name=name) try: return next(index for index in indexes if index.name == name) - except StopIteration as e: - raise Exception("ContextGroundingIndex not found") from e + except StopIteration: + if include_system_indexes: + return self._retrieve_from_system_indexes(name) + raise ContextGroundingIndexNotFoundError(name) from None spec = self._retrieve_spec( name, @@ -305,8 +350,10 @@ def retrieve( for item in response["value"] if item["name"] == name ) - except StopIteration as e: - raise Exception("ContextGroundingIndex not found") from e + except StopIteration: + if include_system_indexes: + return self._retrieve_from_system_indexes(name) + raise ContextGroundingIndexNotFoundError(name) from None @resource_override(resource_type="index") @traced(name="contextgrounding_retrieve", run_type="uipath") @@ -315,30 +362,38 @@ async def retrieve_async( name: str, folder_key: Optional[str] = None, folder_path: Optional[str] = None, + include_system_indexes: bool = False, ) -> ContextGroundingIndex: """Asynchronously retrieve context grounding index information by its name. If no folder_key or folder_path is provided and no folder context is - configured, falls back to searching across all folders. + configured, falls back to searching across all folders. When + ``include_system_indexes`` is True, an additional fallback against + system indexes is attempted before raising not-found. Args: name (str): The name of the context index to retrieve. folder_key (Optional[str]): The key of the folder where the index resides. folder_path (Optional[str]): The path of the folder where the index resides. + include_system_indexes (bool): If True, fall back to system indexes when + the index is not found in the per-folder or across-folders listings. + Defaults to False. Returns: ContextGroundingIndex: The index information, including its configuration and metadata if found. Raises: - Exception: If no index with the given name is found. + ContextGroundingIndexNotFoundError: If no index with the given name is found. """ resolved_folder_key = self._resolve_folder_key(folder_key, folder_path) if not resolved_folder_key: indexes = await self.retrieve_across_folders_async(name=name) try: return next(index for index in indexes if index.name == name) - except StopIteration as e: - raise Exception("ContextGroundingIndex not found") from e + except StopIteration: + if include_system_indexes: + return await self._retrieve_from_system_indexes_async(name) + raise ContextGroundingIndexNotFoundError(name) from None spec = self._retrieve_spec( name, @@ -359,8 +414,26 @@ async def retrieve_async( for item in response["value"] if item["name"] == name ) - except StopIteration as e: - raise Exception("ContextGroundingIndex not found") from e + except StopIteration: + if include_system_indexes: + return await self._retrieve_from_system_indexes_async(name) + raise ContextGroundingIndexNotFoundError(name) from None + + def _retrieve_from_system_indexes(self, name: str) -> ContextGroundingIndex: + indexes = self._retrieve_system_indexes(name=name) + try: + return next(index for index in indexes if index.name == name) + except StopIteration: + raise ContextGroundingIndexNotFoundError(name) from None + + async def _retrieve_from_system_indexes_async( + self, name: str + ) -> ContextGroundingIndex: + indexes = await self._retrieve_system_indexes_async(name=name) + try: + return next(index for index in indexes if index.name == name) + except StopIteration: + raise ContextGroundingIndexNotFoundError(name) from None @traced(name="contextgrounding_list", run_type="uipath") def list( @@ -1489,6 +1562,7 @@ def unified_search( scope: Optional[UnifiedSearchScope] = None, folder_key: Optional[str] = None, folder_path: Optional[str] = None, + include_system_indexes: bool = False, ) -> UnifiedQueryResult: """Perform a unified search on a context grounding index. @@ -1504,11 +1578,19 @@ def unified_search( scope (Optional[UnifiedSearchScope]): Optional search scope (folder, extension). folder_key (Optional[str]): The key of the folder where the index resides. folder_path (Optional[str]): The path of the folder where the index resides. + include_system_indexes (bool): If True, fall back to tenant-wide + system indexes when the index is not found in folder or + across-folders listings. Defaults to False. Returns: UnifiedQueryResult: The unified search result containing semantic and/or tabular results. """ - index = self.retrieve(name, folder_key=folder_key, folder_path=folder_path) + index = self.retrieve( + name, + folder_key=folder_key, + folder_path=folder_path, + include_system_indexes=include_system_indexes, + ) folder_key = folder_key or index.folder_key @@ -1544,6 +1626,7 @@ async def unified_search_async( scope: Optional[UnifiedSearchScope] = None, folder_key: Optional[str] = None, folder_path: Optional[str] = None, + include_system_indexes: bool = False, ) -> UnifiedQueryResult: """Asynchronously perform a unified search on a context grounding index. @@ -1559,12 +1642,18 @@ async def unified_search_async( scope (Optional[UnifiedSearchScope]): Optional search scope (folder, extension). folder_key (Optional[str]): The key of the folder where the index resides. folder_path (Optional[str]): The path of the folder where the index resides. + include_system_indexes (bool): If True, fall back to tenant-wide + system indexes when the index is not found in folder or + across-folders listings. Defaults to False. Returns: UnifiedQueryResult: The unified search result containing semantic and/or tabular results. """ index = await self.retrieve_async( - name, folder_key=folder_key, folder_path=folder_path + name, + folder_key=folder_key, + folder_path=folder_path, + include_system_indexes=include_system_indexes, ) if index and index.in_progress_ingestion(): raise IngestionInProgressException(index_name=name) @@ -1911,6 +2000,16 @@ def _ingest_spec( }, ) + @staticmethod + def _odata_name_filter(name: str) -> str: + """Build an OData ``Name eq ''`` filter with single quotes escaped. + + OData string literals escape ``'`` by doubling it. URL encoding of the + resulting filter is handled by the HTTP client when params are passed + as a dict. + """ + return "Name eq '{}'".format(name.replace("'", "''")) + def _retrieve_across_folders_spec( self, name: Optional[str] = None, @@ -1919,7 +2018,7 @@ def _retrieve_across_folders_spec( "$expand": "dataSource", } if name: - params["$filter"] = f"Name eq '{name}'" + params["$filter"] = self._odata_name_filter(name) return RequestSpec( method="GET", @@ -1927,6 +2026,22 @@ def _retrieve_across_folders_spec( params=params, ) + def _retrieve_system_indexes_spec( + self, + name: Optional[str] = None, + ) -> RequestSpec: + params: Dict[str, str] = { + "$expand": "dataSource", + } + if name: + params["$filter"] = self._odata_name_filter(name) + + return RequestSpec( + method="GET", + endpoint=Endpoint("/ecs_/v2/indexes/allsystemindexes"), + params=params, + ) + def _list_spec( self, folder_key: Optional[str] = None, @@ -1954,7 +2069,7 @@ def _retrieve_spec( method="GET", endpoint=Endpoint("/ecs_/v2/indexes"), params={ - "$filter": f"Name eq '{name}'", + "$filter": self._odata_name_filter(name), "$expand": "dataSource", }, headers={ diff --git a/packages/uipath-platform/src/uipath/platform/errors/__init__.py b/packages/uipath-platform/src/uipath/platform/errors/__init__.py index 58afd93f7..97f7e6d98 100644 --- a/packages/uipath-platform/src/uipath/platform/errors/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/errors/__init__.py @@ -8,6 +8,7 @@ - FolderNotFoundException: Raised when a folder cannot be found - UnsupportedDataSourceException: Raised when an operation is attempted on an unsupported data source type - IngestionInProgressException: Raised when a search is attempted on an index during ingestion +- ContextGroundingIndexNotFoundError: Raised when a context grounding index cannot be resolved by name - BatchTransformFailedException: Raised when a batch transform has failed - BatchTransformNotCompleteException: Raised when attempting to get results from an incomplete batch transform - OperationNotCompleteException: Raised when attempting to get results from an incomplete operation @@ -18,6 +19,9 @@ from ._base_url_missing_error import BaseUrlMissingError from ._batch_transform_failed_exception import BatchTransformFailedException from ._batch_transform_not_complete_exception import BatchTransformNotCompleteException +from ._context_grounding_index_not_found_exception import ( + ContextGroundingIndexNotFoundError, +) from ._enriched_exception import EnrichedException, ExtractedErrorInfo from ._folder_not_found_exception import FolderNotFoundException from ._ingestion_in_progress_exception import IngestionInProgressException @@ -30,6 +34,7 @@ "BaseUrlMissingError", "BatchTransformFailedException", "BatchTransformNotCompleteException", + "ContextGroundingIndexNotFoundError", "EnrichedException", "ExtractedErrorInfo", "FolderNotFoundException", diff --git a/packages/uipath-platform/src/uipath/platform/errors/_context_grounding_index_not_found_exception.py b/packages/uipath-platform/src/uipath/platform/errors/_context_grounding_index_not_found_exception.py new file mode 100644 index 000000000..653be92e8 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/_context_grounding_index_not_found_exception.py @@ -0,0 +1,13 @@ +from typing import Optional + + +class ContextGroundingIndexNotFoundError(Exception): + """Raised when a context grounding index cannot be resolved by name.""" + + def __init__(self, index_name: Optional[str] = None): + self.index_name = index_name + if index_name: + self.message = f"ContextGroundingIndex '{index_name}' not found" + else: + self.message = "ContextGroundingIndex not found" + super().__init__(self.message) diff --git a/packages/uipath-platform/tests/services/test_context_grounding_service.py b/packages/uipath-platform/tests/services/test_context_grounding_service.py index 94626b8a1..3fef9f47a 100644 --- a/packages/uipath-platform/tests/services/test_context_grounding_service.py +++ b/packages/uipath-platform/tests/services/test_context_grounding_service.py @@ -31,6 +31,7 @@ from uipath.platform.context_grounding._context_grounding_service import ( ContextGroundingService, ) +from uipath.platform.errors import ContextGroundingIndexNotFoundError from uipath.platform.orchestrator._buckets_service import BucketsService from uipath.platform.orchestrator._folder_service import FolderService @@ -761,6 +762,529 @@ async def test_retrieve_async_falls_back_to_across_folders_when_no_folder_contex assert len(sent_requests) == 1 assert "/ecs_/v2/indexes/allacrossfolders" in str(sent_requests[0].url) + def test_retrieve_system_indexes( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource", + status_code=200, + json={ + "value": [ + { + "id": "sys-index-1", + "name": "system-template-index", + "lastIngestionStatus": "Completed", + }, + { + "id": "sys-index-2", + "name": "system-other-index", + "lastIngestionStatus": "Completed", + }, + ] + }, + ) + + indexes = service._retrieve_system_indexes() + + assert isinstance(indexes, list) + assert len(indexes) == 2 + assert isinstance(indexes[0], ContextGroundingIndex) + assert indexes[0].id == "sys-index-1" + assert indexes[0].name == "system-template-index" + + sent_requests = httpx_mock.get_requests() + assert sent_requests[0].method == "GET" + assert "/ecs_/v2/indexes/allsystemindexes" in str(sent_requests[0].url) + assert "x-uipath-folderkey" not in sent_requests[0].headers + + assert HEADER_USER_AGENT in sent_requests[0].headers + assert ( + sent_requests[0].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService._retrieve_system_indexes/{version}" + ) + + def test_retrieve_system_indexes_with_name_filter( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource&$filter=Name eq 'system-template-index'", + status_code=200, + json={ + "value": [ + { + "id": "sys-index-1", + "name": "system-template-index", + "lastIngestionStatus": "Completed", + }, + ] + }, + ) + + indexes = service._retrieve_system_indexes(name="system-template-index") + + assert len(indexes) == 1 + assert indexes[0].name == "system-template-index" + + sent_requests = httpx_mock.get_requests() + assert "allsystemindexes" in str(sent_requests[0].url) + assert "x-uipath-folderkey" not in sent_requests[0].headers + + @pytest.mark.anyio + async def test_retrieve_system_indexes_async( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource", + status_code=200, + json={ + "value": [ + { + "id": "sys-index-1", + "name": "system-template-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + indexes = await service._retrieve_system_indexes_async() + + assert len(indexes) == 1 + assert indexes[0].id == "sys-index-1" + + sent_requests = httpx_mock.get_requests() + assert sent_requests[0].method == "GET" + assert "/ecs_/v2/indexes/allsystemindexes" in str(sent_requests[0].url) + assert "x-uipath-folderkey" not in sent_requests[0].headers + + assert ( + sent_requests[0].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService._retrieve_system_indexes_async/{version}" + ) + + def test_retrieve_system_indexes_escapes_single_quote_in_name( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource&$filter=Name eq 'O''Brien'", + status_code=200, + json={ + "value": [ + { + "id": "sys-1", + "name": "O'Brien", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + indexes = service._retrieve_system_indexes(name="O'Brien") + + assert len(indexes) == 1 + assert indexes[0].name == "O'Brien" + + def test_retrieve_across_folders_escapes_single_quote_in_name( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allacrossfolders?$expand=dataSource&$filter=Name eq 'O''Brien'", + status_code=200, + json={ + "value": [ + { + "id": "idx-1", + "name": "O'Brien", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + indexes = service.retrieve_across_folders(name="O'Brien") + + assert len(indexes) == 1 + assert indexes[0].name == "O'Brien" + + def test_retrieve_system_indexes_empty( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource", + status_code=200, + json={"value": []}, + ) + + indexes = service._retrieve_system_indexes() + + assert indexes == [] + + def test_retrieve_raises_typed_not_found_when_across_folders_empty( + self, + httpx_mock: HTTPXMock, + service_no_folder: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allacrossfolders?$expand=dataSource&$filter=Name eq 'missing-index'", + status_code=200, + json={"value": []}, + ) + + with pytest.raises(ContextGroundingIndexNotFoundError) as exc_info: + service_no_folder.retrieve(name="missing-index") + + assert exc_info.value.index_name == "missing-index" + + @pytest.mark.anyio + async def test_retrieve_async_raises_typed_not_found_when_across_folders_empty( + self, + httpx_mock: HTTPXMock, + service_no_folder: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allacrossfolders?$expand=dataSource&$filter=Name eq 'missing-index'", + status_code=200, + json={"value": []}, + ) + + with pytest.raises(ContextGroundingIndexNotFoundError): + await service_no_folder.retrieve_async(name="missing-index") + + def test_retrieve_falls_back_to_system_indexes_when_flag_true( + self, + httpx_mock: HTTPXMock, + service_no_folder: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allacrossfolders?$expand=dataSource&$filter=Name eq 'system-template-index'", + status_code=200, + json={"value": []}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource&$filter=Name eq 'system-template-index'", + status_code=200, + json={ + "value": [ + { + "id": "sys-1", + "name": "system-template-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + index = service_no_folder.retrieve( + name="system-template-index", include_system_indexes=True + ) + + assert index.id == "sys-1" + assert index.name == "system-template-index" + + sent_requests = httpx_mock.get_requests() + assert len(sent_requests) == 2 + assert "/ecs_/v2/indexes/allacrossfolders" in str(sent_requests[0].url) + assert "/ecs_/v2/indexes/allsystemindexes" in str(sent_requests[1].url) + + def test_retrieve_does_not_fall_back_to_system_indexes_when_flag_false( + self, + httpx_mock: HTTPXMock, + service_no_folder: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allacrossfolders?$expand=dataSource&$filter=Name eq 'missing-index'", + status_code=200, + json={"value": []}, + ) + + with pytest.raises(ContextGroundingIndexNotFoundError): + service_no_folder.retrieve(name="missing-index") + + sent_requests = httpx_mock.get_requests() + assert len(sent_requests) == 1 + assert "/ecs_/v2/indexes/allacrossfolders" in str(sent_requests[0].url) + + def test_retrieve_skips_system_indexes_when_across_folders_resolves( + self, + httpx_mock: HTTPXMock, + service_no_folder: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allacrossfolders?$expand=dataSource&$filter=Name eq 'tenant-index'", + status_code=200, + json={ + "value": [ + { + "id": "tenant-1", + "name": "tenant-index", + "lastIngestionStatus": "Completed", + "folderKey": "folder-x", + } + ] + }, + ) + + index = service_no_folder.retrieve( + name="tenant-index", include_system_indexes=True + ) + + assert index.id == "tenant-1" + + sent_requests = httpx_mock.get_requests() + assert len(sent_requests) == 1 + assert "/ecs_/v2/indexes/allacrossfolders" in str(sent_requests[0].url) + + def test_retrieve_falls_back_to_system_indexes_after_folder_lookup_misses( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes?$filter=Name eq 'system-template-index'&$expand=dataSource", + status_code=200, + json={"value": []}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource&$filter=Name eq 'system-template-index'", + status_code=200, + json={ + "value": [ + { + "id": "sys-1", + "name": "system-template-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + index = service.retrieve( + name="system-template-index", + folder_path="test-folder-path", + include_system_indexes=True, + ) + + assert index.id == "sys-1" + + @pytest.mark.anyio + async def test_retrieve_async_falls_back_to_system_indexes_when_flag_true( + self, + httpx_mock: HTTPXMock, + service_no_folder: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allacrossfolders?$expand=dataSource&$filter=Name eq 'system-template-index'", + status_code=200, + json={"value": []}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource&$filter=Name eq 'system-template-index'", + status_code=200, + json={ + "value": [ + { + "id": "sys-1", + "name": "system-template-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + index = await service_no_folder.retrieve_async( + name="system-template-index", include_system_indexes=True + ) + + assert index.id == "sys-1" + + def test_retrieve_with_flag_raises_when_system_indexes_also_empty( + self, + httpx_mock: HTTPXMock, + service_no_folder: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allacrossfolders?$expand=dataSource&$filter=Name eq 'missing-index'", + status_code=200, + json={"value": []}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource&$filter=Name eq 'missing-index'", + status_code=200, + json={"value": []}, + ) + + with pytest.raises(ContextGroundingIndexNotFoundError): + service_no_folder.retrieve( + name="missing-index", include_system_indexes=True + ) + + def test_unified_search_forwards_include_system_indexes( + self, + httpx_mock: HTTPXMock, + service_no_folder: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allacrossfolders?$expand=dataSource&$filter=Name eq 'system-template-index'", + status_code=200, + json={"value": []}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource&$filter=Name eq 'system-template-index'", + status_code=200, + json={ + "value": [ + { + "id": "sys-1", + "name": "system-template-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v1.2/search/sys-1", + status_code=200, + json={ + "semanticResults": { + "values": [], + "metadata": {"operation_id": "op-1", "strategy": "semantic"}, + } + }, + ) + + result = service_no_folder.unified_search( + name="system-template-index", + query="hello", + include_system_indexes=True, + ) + + assert isinstance(result, UnifiedQueryResult) + + sent_requests = httpx_mock.get_requests() + assert any("allsystemindexes" in str(r.url) for r in sent_requests) + assert any("search/sys-1" in str(r.url) for r in sent_requests) + + @pytest.mark.anyio + async def test_unified_search_async_forwards_include_system_indexes( + self, + httpx_mock: HTTPXMock, + service_no_folder: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allacrossfolders?$expand=dataSource&$filter=Name eq 'system-template-index'", + status_code=200, + json={"value": []}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource&$filter=Name eq 'system-template-index'", + status_code=200, + json={ + "value": [ + { + "id": "sys-1", + "name": "system-template-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v1.2/search/sys-1", + status_code=200, + json={ + "semanticResults": { + "values": [], + "metadata": {"operation_id": "op-1", "strategy": "semantic"}, + } + }, + ) + + result = await service_no_folder.unified_search_async( + name="system-template-index", + query="hello", + include_system_indexes=True, + ) + + assert isinstance(result, UnifiedQueryResult) + + sent_requests = httpx_mock.get_requests() + assert any("allsystemindexes" in str(r.url) for r in sent_requests) + assert any("search/sys-1" in str(r.url) for r in sent_requests) + def test_search_uses_index_folder_key_when_no_folder_context( self, httpx_mock: HTTPXMock, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index b8f7a0163..8695a33f1 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.41" +version = "0.1.42" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 7e98f563b..406c919af 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.8, <0.6.0", "uipath-runtime>=0.10.1, <0.11.0", - "uipath-platform>=0.1.41, <0.2.0", + "uipath-platform>=0.1.42, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/tests/cli/contract/test_sdk_cli_alignment.py b/packages/uipath/tests/cli/contract/test_sdk_cli_alignment.py index 52699d42e..a690727d4 100644 --- a/packages/uipath/tests/cli/contract/test_sdk_cli_alignment.py +++ b/packages/uipath/tests/cli/contract/test_sdk_cli_alignment.py @@ -192,9 +192,13 @@ def assert_cli_sdk_alignment( # Used when SDK has optional params that CLI doesn't expose SDK_EXCLUSIONS = { "context-grounding_list": set(), - "context-grounding_retrieve": set(), + "context-grounding_retrieve": {"include_system_indexes"}, "context-grounding_create": {"source", "embeddings_enabled", "is_encrypted"}, - "context-grounding_search": {"scope", "number_of_results"}, + "context-grounding_search": { + "scope", + "number_of_results", + "include_system_indexes", + }, "context-grounding_ingest": set(), "context-grounding_delete": set(), "context-grounding_deep-rag_start": set(), diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 0525ffb1c..d59970eea 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.41" +version = "0.1.42" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From af3602e6c83b25ed4e50c4bcd3f06259a7bd9573 Mon Sep 17 00:00:00 2001 From: Chibi Vikramathithan Date: Fri, 1 May 2026 17:12:16 -0700 Subject: [PATCH 048/191] feat(eval): honor UIPATH_AGENT_ID, UIPATH_CLOUD_USER_ID, UIPATH_PROJECT_FILES_SOURCE env vars [AE-1396] (#1608) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/common/_config.py | 18 ++ .../src/uipath/platform/common/constants.py | 3 + .../tests/common/test_config_env_vars.py | 55 +++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 4 +- .../uipath/_cli/_evals/_progress_reporter.py | 59 ++++- .../src/uipath/_cli/_evals/_telemetry.py | 22 +- packages/uipath/src/uipath/_cli/_telemetry.py | 27 +-- .../tests/cli/eval/test_progress_reporter.py | 205 +++++++++++++++++- packages/uipath/uv.lock | 4 +- 11 files changed, 357 insertions(+), 44 deletions(-) create mode 100644 packages/uipath-platform/tests/common/test_config_env_vars.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index c52b917eb..989952eb3 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.42" +version = "0.1.43" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/common/_config.py b/packages/uipath-platform/src/uipath/platform/common/_config.py index b656830f6..cdb5eb383 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_config.py +++ b/packages/uipath-platform/src/uipath/platform/common/_config.py @@ -61,6 +61,24 @@ def project_id(self) -> str | None: return os.getenv(ENV_UIPATH_PROJECT_ID, None) + @property + def agent_id(self) -> str | None: + from uipath.platform.common.constants import ENV_UIPATH_AGENT_ID + + return os.getenv(ENV_UIPATH_AGENT_ID) or self.project_id + + @property + def cloud_user_id(self) -> str | None: + from uipath.platform.common.constants import ENV_UIPATH_CLOUD_USER_ID + + return os.getenv(ENV_UIPATH_CLOUD_USER_ID, None) + + @property + def project_files_source(self) -> str | None: + from uipath.platform.common.constants import ENV_UIPATH_PROJECT_FILES_SOURCE + + return os.getenv(ENV_UIPATH_PROJECT_FILES_SOURCE, None) + @property def project_key(self) -> str | None: from uipath.platform.common.constants import ENV_PROJECT_KEY diff --git a/packages/uipath-platform/src/uipath/platform/common/constants.py b/packages/uipath-platform/src/uipath/platform/common/constants.py index 6184e844d..304ef64a6 100644 --- a/packages/uipath-platform/src/uipath/platform/common/constants.py +++ b/packages/uipath-platform/src/uipath/platform/common/constants.py @@ -17,6 +17,9 @@ ENV_TELEMETRY_ENABLED = "UIPATH_TELEMETRY_ENABLED" ENV_TRACING_ENABLED = "UIPATH_TRACING_ENABLED" ENV_UIPATH_PROJECT_ID = "UIPATH_PROJECT_ID" +ENV_UIPATH_AGENT_ID = "UIPATH_AGENT_ID" +ENV_UIPATH_CLOUD_USER_ID = "UIPATH_CLOUD_USER_ID" +ENV_UIPATH_PROJECT_FILES_SOURCE = "UIPATH_PROJECT_FILES_SOURCE" ENV_PROJECT_KEY = "PROJECT_KEY" ENV_PROCESS_KEY = "UIPATH_PROCESS_KEY" ENV_UIPATH_PROCESS_UUID = "UIPATH_PROCESS_UUID" diff --git a/packages/uipath-platform/tests/common/test_config_env_vars.py b/packages/uipath-platform/tests/common/test_config_env_vars.py new file mode 100644 index 000000000..8ff7b636d --- /dev/null +++ b/packages/uipath-platform/tests/common/test_config_env_vars.py @@ -0,0 +1,55 @@ +import pytest + +from uipath.platform.common._config import UiPathConfig + + +@pytest.fixture(autouse=True) +def _clear_env(monkeypatch): + for var in ( + "UIPATH_PROJECT_ID", + "UIPATH_AGENT_ID", + "UIPATH_CLOUD_USER_ID", + "UIPATH_PROJECT_FILES_SOURCE", + ): + monkeypatch.delenv(var, raising=False) + + +class TestProjectId: + def test_reads_env_var(self, monkeypatch): + monkeypatch.setenv("UIPATH_PROJECT_ID", "file-source-id") + assert UiPathConfig.project_id == "file-source-id" + + def test_returns_none_when_unset(self): + assert UiPathConfig.project_id is None + + +class TestAgentId: + def test_returns_explicit_agent_id_when_set(self, monkeypatch): + monkeypatch.setenv("UIPATH_PROJECT_ID", "debug-project-guid") + monkeypatch.setenv("UIPATH_AGENT_ID", "real-agent-id") + assert UiPathConfig.agent_id == "real-agent-id" + + def test_falls_back_to_project_id_when_agent_id_unset(self, monkeypatch): + monkeypatch.setenv("UIPATH_PROJECT_ID", "cloud-project-id") + assert UiPathConfig.agent_id == "cloud-project-id" + + def test_returns_none_when_neither_set(self): + assert UiPathConfig.agent_id is None + + +class TestCloudUserId: + def test_returns_value_when_set(self, monkeypatch): + monkeypatch.setenv("UIPATH_CLOUD_USER_ID", "user-guid") + assert UiPathConfig.cloud_user_id == "user-guid" + + def test_returns_none_when_unset(self): + assert UiPathConfig.cloud_user_id is None + + +class TestProjectFilesSource: + def test_returns_value_when_set(self, monkeypatch): + monkeypatch.setenv("UIPATH_PROJECT_FILES_SOURCE", "Local") + assert UiPathConfig.project_files_source == "Local" + + def test_returns_none_when_unset(self): + assert UiPathConfig.project_files_source is None diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 8695a33f1..66c8eb62a 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.42" +version = "0.1.43" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 406c919af..b48c898b5 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.10.60" +version = "2.10.61" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.8, <0.6.0", "uipath-runtime>=0.10.1, <0.11.0", - "uipath-platform>=0.1.42, <0.2.0", + "uipath-platform>=0.1.43, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py b/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py index 7c5114516..fd4849076 100644 --- a/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py +++ b/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py @@ -102,11 +102,18 @@ def __init__(self): self._console = console_logger self._rich_console = Console() self._project_id = os.getenv("UIPATH_PROJECT_ID", None) - if not self._project_id: + self._agent_id = os.getenv("UIPATH_AGENT_ID") or self._project_id + if not self._agent_id: logger.warning( "Cannot report data to StudioWeb. Please set UIPATH_PROJECT_ID." ) + # Map UIPATH_PROJECT_FILES_SOURCE (Local/Cloud) to the backend's + # ProjectFilesSource enum integer. Without this every row the worker + # creates lands as Cloud, and the UI's `?projectFilesSource=1` filter + # never matches local-workspace runs. + self._project_files_source = self._resolve_project_files_source() + self.eval_set_ids: dict[str, str] = {} # Track eval_set_id per execution self.eval_set_run_ids: dict[str, str] = {} self.evaluators: dict[str, Any] = {} @@ -1089,6 +1096,29 @@ def _collect_coded_results( evaluator_runs.append(evaluator_run) return evaluator_runs, evaluator_scores_list + @staticmethod + def _resolve_project_files_source() -> int | None: + raw = os.getenv("UIPATH_PROJECT_FILES_SOURCE") + if not raw: + return None + normalized = raw.strip().lower() + if normalized == "local": + return 1 + if normalized == "cloud": + return 0 + try: + return int(normalized) + except ValueError: + logger.warning( + f"Unrecognized UIPATH_PROJECT_FILES_SOURCE value: {raw!r}; ignoring." + ) + return None + + def _project_files_source_field(self) -> dict[str, int]: + if self._project_files_source is None: + return {} + return {"projectFilesSource": self._project_files_source} + def _update_eval_run_spec( self, assertion_runs: list[dict[str, Any]], @@ -1115,6 +1145,7 @@ def _update_eval_run_spec( }, "completionMetrics": {"duration": int(execution_time * 1000)}, "assertionRuns": assertion_runs, + **self._project_files_source_field(), } # Legacy backend expects payload wrapped in "request" field @@ -1133,7 +1164,7 @@ def _update_eval_run_spec( return RequestSpec( method="PUT", endpoint=Endpoint( - f"{self._get_endpoint_prefix()}execution/agents/{self._project_id}/{endpoint_suffix}evalRun" + f"{self._get_endpoint_prefix()}execution/agents/{self._agent_id}/{endpoint_suffix}evalRun" ), json=payload, headers=self._tenant_header(), @@ -1166,6 +1197,7 @@ def _update_coded_eval_run_spec( }, "completionMetrics": {"duration": int(execution_time * 1000)}, "evaluatorRuns": evaluator_runs, + **self._project_files_source_field(), } # Log the payload for debugging coded eval run updates @@ -1181,7 +1213,7 @@ def _update_coded_eval_run_spec( return RequestSpec( method="PUT", endpoint=Endpoint( - f"{self._get_endpoint_prefix()}execution/agents/{self._project_id}/{endpoint_suffix}evalRun" + f"{self._get_endpoint_prefix()}execution/agents/{self._agent_id}/{endpoint_suffix}evalRun" ), json=payload, headers=self._tenant_header(), @@ -1235,6 +1267,7 @@ def _create_eval_run_spec( "evalSnapshot": eval_snapshot, # Backend expects integer status "status": EvaluationStatus.IN_PROGRESS.value, + **self._project_files_source_field(), } # Legacy backend expects payload wrapped in "request" field @@ -1253,7 +1286,7 @@ def _create_eval_run_spec( return RequestSpec( method="POST", endpoint=Endpoint( - f"{self._get_endpoint_prefix()}execution/agents/{self._project_id}/{endpoint_suffix}evalRun" + f"{self._get_endpoint_prefix()}execution/agents/{self._agent_id}/{endpoint_suffix}evalRun" ), json=payload, headers=self._tenant_header(), @@ -1283,7 +1316,7 @@ def _create_eval_set_run_spec( eval_set_id_value = str(uuid.uuid5(uuid.NAMESPACE_DNS, eval_set_id)) inner_payload: dict[str, Any] = { - "agentId": self._project_id, + "agentId": self._agent_id, "evalSetId": eval_set_id_value, "agentSnapshot": agent_snapshot.model_dump(by_alias=True), # Backend expects integer status @@ -1291,6 +1324,7 @@ def _create_eval_set_run_spec( "numberOfEvalsExecuted": no_of_evals, # Source is required by the backend (0 = coded SDK) "source": 0, + **self._project_files_source_field(), } # Both coded and legacy send payload directly at root level @@ -1309,7 +1343,7 @@ def _create_eval_set_run_spec( return RequestSpec( method="POST", endpoint=Endpoint( - f"{self._get_endpoint_prefix()}execution/agents/{self._project_id}/{endpoint_suffix}evalSetRun" + f"{self._get_endpoint_prefix()}execution/agents/{self._agent_id}/{endpoint_suffix}evalSetRun" ), json=payload, headers=self._tenant_header(), @@ -1353,6 +1387,7 @@ def _update_eval_set_run_spec( # Backend expects integer status "status": status.value, "evaluatorScores": evaluator_scores_list, + **self._project_files_source_field(), } # Legacy backend expects payload wrapped in "request" field @@ -1374,7 +1409,7 @@ def _update_eval_set_run_spec( return RequestSpec( method="PUT", endpoint=Endpoint( - f"{self._get_endpoint_prefix()}execution/agents/{self._project_id}/{endpoint_suffix}evalSetRun" + f"{self._get_endpoint_prefix()}execution/agents/{self._agent_id}/{endpoint_suffix}evalSetRun" ), json=payload, headers=self._tenant_header(), @@ -1406,12 +1441,12 @@ def _get_eval_runs_spec( if is_coded: endpoint_path = ( - f"{prefix}execution/agents/{self._project_id}/coded/" + f"{prefix}execution/agents/{self._agent_id}/coded/" f"evalSets/{eval_set_id}/evalSetRuns/{eval_set_run_id}/evalRuns" ) else: endpoint_path = ( - f"{prefix}execution/agents/{self._project_id}/" + f"{prefix}execution/agents/{self._agent_id}/" f"evalSets/{eval_set_id}/evalSetRuns/{eval_set_run_id}/evalRuns" ) @@ -1420,10 +1455,14 @@ def _get_eval_runs_spec( f"eval_set_run_id={eval_set_run_id}, evaluation_id={evaluation_id}, coded={is_coded}" ) + # The backend's listing endpoint filters by projectFilesSource + + # cloudUserId so the UI only shows the caller's local rows. Mirror + # that here so resume lookups match the row written by the same + # worker session. return RequestSpec( method="GET", endpoint=Endpoint(endpoint_path), - params={}, # No query params needed - evalSetRunId is in the path + params=self._project_files_source_field(), headers=self._tenant_header(), ) diff --git a/packages/uipath/src/uipath/_cli/_evals/_telemetry.py b/packages/uipath/src/uipath/_cli/_evals/_telemetry.py index ad9549a6c..e28186576 100644 --- a/packages/uipath/src/uipath/_cli/_evals/_telemetry.py +++ b/packages/uipath/src/uipath/_cli/_evals/_telemetry.py @@ -308,28 +308,26 @@ def _enrich_properties(self, properties: dict[str, Any]) -> None: Args: properties: The properties dictionary to enrich. """ - # Add UiPath context if UiPathConfig.project_id: properties["ProjectId"] = UiPathConfig.project_id - properties["AgentId"] = UiPathConfig.project_id + if UiPathConfig.agent_id: + properties["AgentId"] = UiPathConfig.agent_id - # Get organization ID from UiPathConfig if UiPathConfig.organization_id: properties["CloudOrganizationId"] = UiPathConfig.organization_id - # Get CloudUserId from JWT token - try: - cloud_user_id = get_claim_from_token("sub") - if cloud_user_id: - properties["CloudUserId"] = cloud_user_id - except Exception: - pass # CloudUserId is optional + cloud_user_id = UiPathConfig.cloud_user_id + if not cloud_user_id: + try: + cloud_user_id = get_claim_from_token("sub") + except Exception: + cloud_user_id = None + if cloud_user_id: + properties["CloudUserId"] = cloud_user_id - # Get tenant ID from environment tenant_id = os.getenv("UIPATH_TENANT_ID") if tenant_id: properties["TenantId"] = tenant_id - # Add source identifier properties["Source"] = "uipath-python-cli" properties["ApplicationName"] = "UiPath.Eval" diff --git a/packages/uipath/src/uipath/_cli/_telemetry.py b/packages/uipath/src/uipath/_cli/_telemetry.py index 7adc54410..d245076a4 100644 --- a/packages/uipath/src/uipath/_cli/_telemetry.py +++ b/packages/uipath/src/uipath/_cli/_telemetry.py @@ -41,28 +41,26 @@ def _enrich_properties(self, properties: Dict[str, Any]) -> None: Args: properties: The properties dictionary to enrich. """ - # Add UiPath context - project_key = _get_project_key() - if project_key: - properties["AgentId"] = project_key + agent_id = os.getenv("UIPATH_AGENT_ID") or _get_project_key() + if agent_id: + properties["AgentId"] = agent_id - # Get organization ID if UiPathConfig.organization_id: properties["CloudOrganizationId"] = UiPathConfig.organization_id - # Get tenant ID if UiPathConfig.tenant_id: properties["CloudTenantId"] = UiPathConfig.tenant_id - # Get CloudUserId from JWT token - try: - cloud_user_id = get_claim_from_token("sub") - if cloud_user_id: - properties["CloudUserId"] = cloud_user_id - except Exception: - pass + cloud_user_id = UiPathConfig.cloud_user_id + if not cloud_user_id: + try: + cloud_user_id = get_claim_from_token("sub") + except Exception: + cloud_user_id = None + if cloud_user_id: + properties["CloudUserId"] = cloud_user_id - properties["SessionId"] = "nosession" # Placeholder for session ID + properties["SessionId"] = "nosession" try: properties["SDKVersion"] = version("uipath") @@ -71,7 +69,6 @@ def _enrich_properties(self, properties: Dict[str, Any]) -> None: properties["IsGithubCI"] = bool(os.getenv("GITHUB_ACTIONS")) - # Add source identifier properties["Source"] = "uipath-python-cli" properties["ApplicationName"] = "UiPath.AgentCli" diff --git a/packages/uipath/tests/cli/eval/test_progress_reporter.py b/packages/uipath/tests/cli/eval/test_progress_reporter.py index 87919c2b3..1fd00bf12 100644 --- a/packages/uipath/tests/cli/eval/test_progress_reporter.py +++ b/packages/uipath/tests/cli/eval/test_progress_reporter.py @@ -927,4 +927,207 @@ def test_build_evaluator_snapshot_skips_non_string_model(self, progress_reporter snapshot = progress_reporter._build_evaluator_snapshot(evaluator) assert snapshot["prompt"] == "Evaluate this" - assert "model" not in snapshot + + +class TestAgentIdRouting: + """Eval-set/eval-run API URLs route by AgentId, not file-source project. + + For local-workspace eval runs the file-source project (UIPATH_PROJECT_ID, + typically the cloud debug project's GUID) differs from the logical agent + (UIPATH_AGENT_ID). The route URL must reflect the logical agent so backend + auth/ownership/telemetry don't see the per-run debug project as the agent. + File fetching (UiPathConfig.project_id) is unaffected. + """ + + def _make_reporter(self, monkeypatch, project_id, agent_id): + monkeypatch.setenv("UIPATH_URL", "https://test.uipath.com") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "test-token") + monkeypatch.setenv("UIPATH_TENANT_ID", "test-tenant-id") + if project_id is not None: + monkeypatch.setenv("UIPATH_PROJECT_ID", project_id) + else: + monkeypatch.delenv("UIPATH_PROJECT_ID", raising=False) + if agent_id is not None: + monkeypatch.setenv("UIPATH_AGENT_ID", agent_id) + else: + monkeypatch.delenv("UIPATH_AGENT_ID", raising=False) + return StudioWebProgressReporter() + + def test_agent_id_used_in_url_when_both_set(self, monkeypatch): + reporter = self._make_reporter( + monkeypatch, project_id="debug-project-guid", agent_id="real-agent-id" + ) + assert reporter._agent_id == "real-agent-id" + assert reporter._project_id == "debug-project-guid" + + from uipath._cli._evals._progress_reporter import StudioWebAgentSnapshot + + spec = reporter._create_eval_set_run_spec( + eval_set_id="test-eval-set", + agent_snapshot=StudioWebAgentSnapshot( + input_schema={"type": "object"}, output_schema={"type": "object"} + ), + no_of_evals=1, + is_coded=False, + ) + assert "/agents/real-agent-id/" in spec.endpoint + assert "/agents/debug-project-guid/" not in spec.endpoint + + def test_agent_id_in_eval_set_run_payload(self, monkeypatch): + reporter = self._make_reporter( + monkeypatch, project_id="debug-project-guid", agent_id="real-agent-id" + ) + + from uipath._cli._evals._progress_reporter import StudioWebAgentSnapshot + + spec = reporter._create_eval_set_run_spec( + eval_set_id="test-eval-set", + agent_snapshot=StudioWebAgentSnapshot( + input_schema={"type": "object"}, output_schema={"type": "object"} + ), + no_of_evals=1, + is_coded=False, + ) + assert spec.json["agentId"] == "real-agent-id" + + def test_falls_back_to_project_id_when_agent_id_unset(self, monkeypatch): + reporter = self._make_reporter( + monkeypatch, project_id="cloud-project-id", agent_id=None + ) + assert reporter._agent_id == "cloud-project-id" + + from uipath._cli._evals._progress_reporter import StudioWebAgentSnapshot + + spec = reporter._create_eval_set_run_spec( + eval_set_id="test-eval-set", + agent_snapshot=StudioWebAgentSnapshot( + input_schema={"type": "object"}, output_schema={"type": "object"} + ), + no_of_evals=1, + is_coded=False, + ) + assert "/agents/cloud-project-id/" in spec.endpoint + + +class TestProjectFilesSourcePropagation: + """Reporter must propagate UIPATH_PROJECT_FILES_SOURCE to backend rows. + + Backend filters listings by `projectFilesSource` (Local=1, Cloud=0). Without + the SDK setting it on POST/PUT payloads and GET query params, every row + lands as Cloud and the UI's `?projectFilesSource=1` filter never matches + local-workspace runs. + """ + + def _make_reporter(self, monkeypatch, project_files_source): + monkeypatch.setenv("UIPATH_URL", "https://test.uipath.com") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "test-token") + monkeypatch.setenv("UIPATH_TENANT_ID", "test-tenant-id") + monkeypatch.setenv("UIPATH_PROJECT_ID", "test-project-id") + if project_files_source is not None: + monkeypatch.setenv("UIPATH_PROJECT_FILES_SOURCE", project_files_source) + else: + monkeypatch.delenv("UIPATH_PROJECT_FILES_SOURCE", raising=False) + return StudioWebProgressReporter() + + @pytest.mark.parametrize( + "raw,expected", + [("Local", 1), ("local", 1), ("Cloud", 0), ("cloud", 0), ("1", 1), ("0", 0)], + ) + def test_resolves_env_var_to_int(self, monkeypatch, raw, expected): + reporter = self._make_reporter(monkeypatch, raw) + assert reporter._project_files_source == expected + + def test_returns_none_when_unset_or_garbage(self, monkeypatch): + reporter = self._make_reporter(monkeypatch, None) + assert reporter._project_files_source is None + reporter2 = self._make_reporter(monkeypatch, "Banana") + assert reporter2._project_files_source is None + + def test_post_eval_set_run_payload_carries_source(self, monkeypatch): + from uipath._cli._evals._progress_reporter import StudioWebAgentSnapshot + + reporter = self._make_reporter(monkeypatch, "Local") + spec = reporter._create_eval_set_run_spec( + eval_set_id="test-eval-set", + agent_snapshot=StudioWebAgentSnapshot( + input_schema={"type": "object"}, output_schema={"type": "object"} + ), + no_of_evals=1, + is_coded=False, + ) + assert spec.json["projectFilesSource"] == 1 + + def test_post_eval_run_payload_carries_source(self, monkeypatch): + from uipath.eval.models.evaluation_set import EvaluationItem + + reporter = self._make_reporter(monkeypatch, "Local") + item = EvaluationItem( + id="11111111-1111-1111-1111-111111111111", + name="t", + inputs={}, + evaluation_criterias={}, + ) + spec = reporter._create_eval_run_spec( + eval_item=item, eval_set_run_id="run-1", is_coded=False + ) + assert spec.json["projectFilesSource"] == 1 + + def test_put_eval_run_payload_carries_source(self, monkeypatch): + reporter = self._make_reporter(monkeypatch, "Local") + spec = reporter._update_eval_run_spec( + assertion_runs=[], + evaluator_scores=[], + eval_run_id="run-1", + actual_output={}, + execution_time=1.0, + success=True, + is_coded=False, + ) + assert spec.json["projectFilesSource"] == 1 + + def test_put_coded_eval_run_payload_carries_source(self, monkeypatch): + reporter = self._make_reporter(monkeypatch, "Local") + spec = reporter._update_coded_eval_run_spec( + evaluator_runs=[], + evaluator_scores=[], + eval_run_id="run-1", + actual_output={}, + execution_time=1.0, + success=True, + is_coded=True, + ) + assert spec.json["projectFilesSource"] == 1 + + def test_put_eval_set_run_payload_carries_source(self, monkeypatch): + reporter = self._make_reporter(monkeypatch, "Local") + spec = reporter._update_eval_set_run_spec( + eval_set_run_id="set-run-1", + evaluator_scores={}, + is_coded=False, + success=True, + ) + assert spec.json["projectFilesSource"] == 1 + + def test_get_eval_runs_query_carries_source(self, monkeypatch): + reporter = self._make_reporter(monkeypatch, "Local") + spec = reporter._get_eval_runs_spec( + eval_set_id="set-1", + eval_set_run_id="run-1", + evaluation_id=None, + is_coded=False, + ) + assert spec.params == {"projectFilesSource": 1} + + def test_unset_source_omits_field_from_payloads(self, monkeypatch): + from uipath._cli._evals._progress_reporter import StudioWebAgentSnapshot + + reporter = self._make_reporter(monkeypatch, None) + spec = reporter._create_eval_set_run_spec( + eval_set_id="test-eval-set", + agent_snapshot=StudioWebAgentSnapshot( + input_schema={"type": "object"}, output_schema={"type": "object"} + ), + no_of_evals=1, + is_coded=False, + ) + assert "projectFilesSource" not in spec.json diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index d59970eea..2eacfed8a 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.60" +version = "2.10.61" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.42" +version = "0.1.43" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 67466db5672aa579f76c91426ff3d3acd4d7276c Mon Sep 17 00:00:00 2001 From: ctiliescuuipath Date: Tue, 5 May 2026 14:46:18 +0300 Subject: [PATCH 049/191] feat(platform): add W3C trace context headers to LLM Gateway requests (#1612) Co-authored-by: Claude Opus 4.6 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../platform/chat/_llm_gateway_service.py | 6 +- .../uipath/platform/chat/llm_trace_context.py | 42 +++++ .../src/uipath/platform/common/_config.py | 6 + .../tests/services/test_llm_trace_context.py | 161 ++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 7 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py create mode 100644 packages/uipath-platform/tests/services/test_llm_trace_context.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 989952eb3..36bc271c1 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.43" +version = "0.1.44" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py b/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py index d7c093d0d..ffe0bff99 100644 --- a/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py +++ b/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py @@ -35,6 +35,7 @@ ToolDefinition, ) from .llm_throttle import get_llm_semaphore +from .llm_trace_context import build_trace_context_headers # Common constants API_VERSION = "2024-10-21" # Standard API version for OpenAI-compatible endpoints @@ -224,7 +225,7 @@ async def embeddings( endpoint, json={"input": input}, params={"api-version": API_VERSION}, - headers=self._llm_headers, + headers={**self._llm_headers, **build_trace_context_headers()}, ) return TextEmbedding.model_validate(response.json()) @@ -355,7 +356,7 @@ class Country(BaseModel): endpoint, json=request_body, params={"api-version": API_VERSION}, - headers=self._llm_headers, + headers={**self._llm_headers, **build_trace_context_headers()}, ) return ChatCompletion.model_validate(response.json()) @@ -599,6 +600,7 @@ class Country(BaseModel): headers = { **self._llm_headers, + **build_trace_context_headers(), "X-UiPath-LlmGateway-NormalizedApi-ModelName": model, "X-UiPath-LLMGateway-AllowFull4xxResponse": "true", } diff --git a/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py b/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py new file mode 100644 index 000000000..4c6dd6062 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py @@ -0,0 +1,42 @@ +"""W3C-style trace context headers for LLM Gateway requests.""" + +from opentelemetry import trace +from uipath.core.feature_flags import FeatureFlags + +from ..common._config import UiPathConfig + + +def build_trace_context_headers( + extra_baggage: list[str] | None = None, +) -> dict[str, str]: + """Build W3C-style trace context headers from the current OpenTelemetry span. + + Args: + extra_baggage: Additional baggage entries (e.g. ``["source=agents"]``) + that callers can inject alongside the platform-level entries. + + Returns an empty dict when the ``EnableTraceContextHeaders`` feature flag + is not enabled, or when no active span is present. + """ + if not FeatureFlags.is_flag_enabled("EnableTraceContextHeaders"): + return {} + + headers: dict[str, str] = {} + span = trace.get_current_span() + ctx = span.get_span_context() + if ctx and ctx.trace_id and ctx.span_id: + trace_id = format(ctx.trace_id, "032x") + span_id = format(ctx.span_id, "016x") + headers["x-uipath-traceparent-id"] = f"00-{trace_id}-{span_id}" + + baggage_parts: list[str] = list(extra_baggage) if extra_baggage else [] + if folder_key := UiPathConfig.folder_key: + baggage_parts.append(f"folderKey={folder_key}") + if agent_id := UiPathConfig.process_uuid: + baggage_parts.append(f"agentId={agent_id}") + if process_key := UiPathConfig.process_key: + baggage_parts.append(f"processKey={process_key}") + if baggage_parts: + headers["x-uipath-tracebaggage"] = ",".join(baggage_parts) + + return headers diff --git a/packages/uipath-platform/src/uipath/platform/common/_config.py b/packages/uipath-platform/src/uipath/platform/common/_config.py index cdb5eb383..40db82214 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_config.py +++ b/packages/uipath-platform/src/uipath/platform/common/_config.py @@ -121,6 +121,12 @@ def folder_path(self) -> str | None: return os.getenv(ENV_FOLDER_PATH, None) + @property + def process_key(self) -> str | None: + from uipath.platform.common.constants import ENV_PROCESS_KEY + + return os.getenv(ENV_PROCESS_KEY, None) + @property def process_uuid(self) -> str | None: from uipath.platform.common.constants import ENV_UIPATH_PROCESS_UUID diff --git a/packages/uipath-platform/tests/services/test_llm_trace_context.py b/packages/uipath-platform/tests/services/test_llm_trace_context.py new file mode 100644 index 000000000..83bde3957 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_llm_trace_context.py @@ -0,0 +1,161 @@ +"""Tests for build_trace_context_headers.""" + +import os +from unittest.mock import patch + +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags +from uipath.core.feature_flags import FeatureFlags + +from uipath.platform.chat.llm_trace_context import build_trace_context_headers + +FEATURE_FLAG = "EnableTraceContextHeaders" + + +class TestFeatureFlagDisabled: + """When the feature flag is off, no headers are returned.""" + + def setup_method(self) -> None: + FeatureFlags.reset_flags() + + def test_returns_empty_dict_by_default(self) -> None: + assert build_trace_context_headers() == {} + + def test_returns_empty_dict_when_explicitly_disabled(self) -> None: + FeatureFlags.configure_flags({FEATURE_FLAG: False}) + assert build_trace_context_headers() == {} + + +class TestTraceparentHeader: + """When enabled, x-uipath-traceparent-id is populated from the active span.""" + + def setup_method(self) -> None: + FeatureFlags.reset_flags() + FeatureFlags.configure_flags({FEATURE_FLAG: True}) + + def test_traceparent_from_active_span(self) -> None: + provider = TracerProvider() + tracer = provider.get_tracer("test") + with tracer.start_as_current_span("test-span") as span: + ctx = span.get_span_context() + expected_trace_id = format(ctx.trace_id, "032x") + expected_span_id = format(ctx.span_id, "016x") + + headers = build_trace_context_headers() + + assert "x-uipath-traceparent-id" in headers + value = headers["x-uipath-traceparent-id"] + assert value == f"00-{expected_trace_id}-{expected_span_id}" + # Verify format: version (2) + dash + trace_id (32) + dash + span_id (16) + parts = value.split("-") + assert len(parts) == 3 + assert parts[0] == "00" + assert len(parts[1]) == 32 + assert len(parts[2]) == 16 + + def test_no_traceparent_without_active_span(self) -> None: + # INVALID_SPAN has trace_id=0 and span_id=0 + from opentelemetry.context import attach, detach + + ctx = SpanContext( + trace_id=0, + span_id=0, + is_remote=False, + trace_flags=TraceFlags(0), + ) + non_recording = NonRecordingSpan(ctx) + token = attach(trace.set_span_in_context(non_recording)) + try: + headers = build_trace_context_headers() + finally: + detach(token) + + assert "x-uipath-traceparent-id" not in headers + + +class TestBaggageHeader: + """When enabled, x-uipath-tracebaggage is populated from UiPathConfig.""" + + def setup_method(self) -> None: + FeatureFlags.reset_flags() + FeatureFlags.configure_flags({FEATURE_FLAG: True}) + + def test_all_env_vars_present(self) -> None: + env = { + "UIPATH_FOLDER_KEY": "folder-abc", + "UIPATH_PROCESS_UUID": "agent-123", + "UIPATH_PROCESS_KEY": "process-789", + } + with patch.dict(os.environ, env, clear=True): + headers = build_trace_context_headers() + + baggage = headers["x-uipath-tracebaggage"] + assert "folderKey=folder-abc" in baggage + assert "agentId=agent-123" in baggage + assert "processKey=process-789" in baggage + + def test_partial_env_vars(self) -> None: + env = {"UIPATH_FOLDER_KEY": "folder-only"} + with patch.dict(os.environ, env, clear=True): + headers = build_trace_context_headers() + + baggage = headers["x-uipath-tracebaggage"] + assert "folderKey=folder-only" in baggage + + def test_no_baggage_without_env_vars(self) -> None: + with patch.dict(os.environ, {}, clear=True): + headers = build_trace_context_headers() + + assert "x-uipath-tracebaggage" not in headers + + def test_baggage_comma_separated(self) -> None: + env = { + "UIPATH_FOLDER_KEY": "f1", + "UIPATH_PROCESS_UUID": "a1", + } + with patch.dict(os.environ, env, clear=True): + headers = build_trace_context_headers() + + baggage = headers["x-uipath-tracebaggage"] + parts = baggage.split(",") + assert len(parts) == 2 # folderKey + agentId + + def test_extra_baggage_included(self) -> None: + env = {"UIPATH_FOLDER_KEY": "f1"} + with patch.dict(os.environ, env, clear=True): + headers = build_trace_context_headers(extra_baggage=["source=agents"]) + + baggage = headers["x-uipath-tracebaggage"] + assert "source=agents" in baggage + assert "folderKey=f1" in baggage + + def test_extra_baggage_only(self) -> None: + with patch.dict(os.environ, {}, clear=True): + headers = build_trace_context_headers( + extra_baggage=["source=agents", "custom=value"] + ) + + baggage = headers["x-uipath-tracebaggage"] + assert baggage == "source=agents,custom=value" + + +class TestBothHeaders: + """When enabled with an active span and env vars, both headers are present.""" + + def setup_method(self) -> None: + FeatureFlags.reset_flags() + FeatureFlags.configure_flags({FEATURE_FLAG: True}) + + def test_both_headers_present(self) -> None: + provider = TracerProvider() + tracer = provider.get_tracer("test") + env = {"UIPATH_FOLDER_KEY": "folder-abc"} + with ( + tracer.start_as_current_span("test-span"), + patch.dict(os.environ, env, clear=True), + ): + headers = build_trace_context_headers() + + assert "x-uipath-traceparent-id" in headers + assert "x-uipath-tracebaggage" in headers diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 66c8eb62a..435bc613b 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.43" +version = "0.1.44" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 2eacfed8a..dc6b7286a 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.43" +version = "0.1.44" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From de3b91c4e3b3ea811bcec9e9bfc1bedd824c9ade Mon Sep 17 00:00:00 2001 From: ctiliescuuipath Date: Tue, 5 May 2026 16:36:53 +0300 Subject: [PATCH 050/191] fix(platform): use LLMOps external span in trace context headers (#1613) Co-authored-by: Claude Opus 4.6 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/chat/llm_trace_context.py | 4 +++- packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 2 +- packages/uipath/uv.lock | 2 +- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 36bc271c1..f9e9d14a0 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.44" +version = "0.1.45" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py b/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py index 4c6dd6062..6efbf08dc 100644 --- a/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py +++ b/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py @@ -2,6 +2,7 @@ from opentelemetry import trace from uipath.core.feature_flags import FeatureFlags +from uipath.core.tracing.span_utils import UiPathSpanUtils from ..common._config import UiPathConfig @@ -22,7 +23,8 @@ def build_trace_context_headers( return {} headers: dict[str, str] = {} - span = trace.get_current_span() + llmops_span = UiPathSpanUtils.get_external_current_span() + span = llmops_span or trace.get_current_span() ctx = span.get_span_context() if ctx and ctx.trace_id and ctx.span_id: trace_id = format(ctx.trace_id, "032x") diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 435bc613b..ae35f75a9 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.44" +version = "0.1.45" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index b48c898b5..f3012976e 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.8, <0.6.0", "uipath-runtime>=0.10.1, <0.11.0", - "uipath-platform>=0.1.43, <0.2.0", + "uipath-platform>=0.1.45, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index dc6b7286a..6cbe534e9 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.44" +version = "0.1.45" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 91362d54d063122e0d6dbeabcec9d190152e1460 Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Wed, 6 May 2026 12:55:21 +0300 Subject: [PATCH 051/191] fix: pass job key to is activities for licensing (#1614) --- packages/uipath-platform/pyproject.toml | 2 +- .../connections/_connections_service.py | 15 ++++- .../services/test_connections_service.py | 59 ++++++++++++++++++- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 5 files changed, 73 insertions(+), 7 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index f9e9d14a0..99c6d16ab 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.45" +version = "0.1.46" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py b/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py index af3bb4f78..8ff9a82e7 100644 --- a/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py +++ b/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py @@ -8,7 +8,7 @@ from ..common._base_service import BaseService from ..common._bindings import resource_override -from ..common._config import UiPathApiConfig +from ..common._config import UiPathApiConfig, UiPathConfig from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import header_folder from ..common._models import Endpoint, RequestSpec @@ -24,6 +24,13 @@ logger: logging.Logger = logging.getLogger("uipath") +HEADER_ORIGINATOR = "x-uipath-originator" +HEADER_SOURCE = "x-uipath-source" +# Sent on outbound Integration Service activity invocations so GenAI activities +# can be stitched back to the parent job for licensing attribution. +HEADER_ACTIVITY_JOB_ID = "x-uipath-job-id" +_ORIGINATOR_VALUE = "uipath-python" + class ConnectionsService(BaseService): """Service for managing UiPath external service connections. @@ -768,11 +775,13 @@ def _build_activity_request_spec( # header parameter handling headers = { - "x-uipath-originator": "uipath-python", - "x-uipath-source": "uipath-python", + HEADER_ORIGINATOR: _ORIGINATOR_VALUE, + HEADER_SOURCE: _ORIGINATOR_VALUE, **header_folder(folder_key, None), **header_params, } + if job_key := UiPathConfig.job_key: + headers[HEADER_ACTIVITY_JOB_ID] = job_key # body and files handling json_data: Dict[str, Any] | None = None diff --git a/packages/uipath-platform/tests/services/test_connections_service.py b/packages/uipath-platform/tests/services/test_connections_service.py index 75c89b517..d037c9372 100644 --- a/packages/uipath-platform/tests/services/test_connections_service.py +++ b/packages/uipath-platform/tests/services/test_connections_service.py @@ -17,7 +17,10 @@ ConnectionToken, EventArguments, ) -from uipath.platform.connections._connections_service import ConnectionsService +from uipath.platform.connections._connections_service import ( + HEADER_ACTIVITY_JOB_ID, + ConnectionsService, +) from uipath.platform.orchestrator._folder_service import FolderService @@ -1421,6 +1424,60 @@ def test_invoke_activity_sets_standard_headers( assert sent_request.headers["x-uipath-originator"] == "uipath-python" assert sent_request.headers["x-uipath-source"] == "uipath-python" + def test_invoke_activity_propagates_job_id_header( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + simple_activity_metadata: ActivityMetadata, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Activity invocations carry x-uipath-job-id so GenAI calls can be stitched for licensing.""" + monkeypatch.setenv("UIPATH_JOB_KEY", "job-key-abc") + connection_id = "test-connection-123" + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response(method="POST", status_code=200, json={}) + + service.invoke_activity( + activity_metadata=simple_activity_metadata, + connection_id=connection_id, + activity_input={"body_field1": "x"}, + ) + + sent_request = httpx_mock.get_requests()[1] + assert sent_request.headers[HEADER_ACTIVITY_JOB_ID] == "job-key-abc" + + def test_invoke_activity_omits_job_id_header_when_unset( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + simple_activity_metadata: ActivityMetadata, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """No job-id header is sent when UIPATH_JOB_KEY is not set.""" + monkeypatch.delenv("UIPATH_JOB_KEY", raising=False) + connection_id = "test-connection-123" + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response(method="POST", status_code=200, json={}) + + service.invoke_activity( + activity_metadata=simple_activity_metadata, + connection_id=connection_id, + activity_input={"body_field1": "x"}, + ) + + sent_request = httpx_mock.get_requests()[1] + assert HEADER_ACTIVITY_JOB_ID not in sent_request.headers + def test_invoke_activity_with_body_fields( self, httpx_mock: HTTPXMock, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index ae35f75a9..dbea2b79a 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.45" +version = "0.1.46" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 6cbe534e9..4339a51d2 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.45" +version = "0.1.46" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From a29ed86aab4e7c58005c94da084afcd365cd67d0 Mon Sep 17 00:00:00 2001 From: Dennis-UiPath <84574129+Dennis-UiPath@users.noreply.github.com> Date: Fri, 8 May 2026 15:09:42 +0200 Subject: [PATCH 052/191] fix(connections): preserve filename and content type in multipart file uploads (#1611) --- packages/uipath-platform/pyproject.toml | 2 +- .../connections/_connections_service.py | 22 ++- .../services/test_connections_service.py | 170 ++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 2 +- packages/uipath/uv.lock | 2 +- 6 files changed, 190 insertions(+), 10 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 99c6d16ab..342b22d83 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.46" +version = "0.1.47" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py b/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py index 8ff9a82e7..b7c1e9444 100644 --- a/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py +++ b/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py @@ -797,12 +797,22 @@ def _build_activity_request_spec( # instead of making assumptions on whether or not it's present, we'll handle it defensively if key == json_section: continue - # files not supported yet supported so this will likely not work - files[key] = ( - key, - val, - None, - ) # probably needs to extract content type from val since IS metadata doesn't provide it + if isinstance(val, tuple): + # Caller supplied httpx's (filename, content[, content_type]) + # shape — pass through verbatim. This is the recommended path + # for file uploads so the multipart Content-Disposition gets + # the real filename instead of the form-field name. + files[key] = val + elif isinstance(val, (bytes, bytearray)) or hasattr(val, "read"): + # Raw file content with no filename — fall back to the + # form-field name (legacy behaviour). Backwards compatible + # with callers that still pass bytes directly. + files[key] = (key, val, "application/octet-stream") + else: + # Scalar (string/number/etc.) — send as a plain multipart + # form field, not a file part. The (None, value) shape tells + # httpx to omit `filename=...` from the Content-Disposition. + files[key] = (None, str(val)) files[json_section] = ( "", diff --git a/packages/uipath-platform/tests/services/test_connections_service.py b/packages/uipath-platform/tests/services/test_connections_service.py index d037c9372..27dec7310 100644 --- a/packages/uipath-platform/tests/services/test_connections_service.py +++ b/packages/uipath-platform/tests/services/test_connections_service.py @@ -2173,3 +2173,173 @@ async def test_invoke_activity_async_uses_connection_id_from_retrieve_response( assert f"/element/instances/{original_connection_id}/" not in str( activity_request.url ) + + +def _multipart_part(body: bytes, boundary: str, name: str) -> str: + """Return the raw text of the multipart part with the given form-field name.""" + text = body.decode("utf-8", errors="replace") + for part in text.split(f"--{boundary}"): + if f'name="{name}"' in part: + return part + raise AssertionError(f"part {name!r} not found in multipart body") + + +class TestMultipartFileUpload: + """Regression tests for the multipart serializer that handles file uploads. + + Before this fix, ``_build_activity_request_spec`` always built + ``files[key] = (key, val, None)``, using the form-field name as the + multipart filename and dropping the content type. Downstream services + (e.g. Coupa's ``add_attachment`` endpoint) ended up storing every + attachment with the literal name ``attachment[file]`` and no extension. + + The serializer now branches on the value type: + + * tuple → passed through (caller controls filename + content type) + * bytes → legacy fallback, key as filename, octet-stream content type + * scalar → plain multipart form field (no filename in Content-Disposition) + """ + + def test_invoke_activity_multipart_tuple_3_preserves_filename( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + multipart_activity_metadata: ActivityMetadata, + ) -> None: + """3-tuple input is forwarded verbatim, so the real filename + content type land on the wire.""" + connection_id = "test-connection-123" + activity_input = { + "file_param": ("invoice.pdf", b"%PDF-1.4 fake", "application/pdf"), + "description": "Test file upload", + } + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response(method="POST", status_code=200, json={"ok": True}) + + _ = service.invoke_activity( + activity_metadata=multipart_activity_metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + sent_request = httpx_mock.get_requests()[1] + boundary = sent_request.headers["content-type"].split("boundary=")[1] + part = _multipart_part(sent_request.content, boundary, "file_param") + + assert 'filename="invoice.pdf"' in part + assert "Content-Type: application/pdf" in part + assert b"%PDF-1.4 fake" in sent_request.content + + def test_invoke_activity_multipart_tuple_2_preserves_filename( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + multipart_activity_metadata: ActivityMetadata, + ) -> None: + """2-tuple (filename, content) shorthand: filename preserved, httpx infers the content type.""" + connection_id = "test-connection-123" + activity_input = { + "file_param": ("invoice.pdf", b"%PDF-1.4 fake"), + "description": "Test file upload", + } + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response(method="POST", status_code=200, json={"ok": True}) + + _ = service.invoke_activity( + activity_metadata=multipart_activity_metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + sent_request = httpx_mock.get_requests()[1] + boundary = sent_request.headers["content-type"].split("boundary=")[1] + part = _multipart_part(sent_request.content, boundary, "file_param") + + assert 'filename="invoice.pdf"' in part + assert b"%PDF-1.4 fake" in sent_request.content + + def test_invoke_activity_multipart_bytes_backwards_compatible( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + multipart_activity_metadata: ActivityMetadata, + ) -> None: + """Existing callers passing raw bytes keep working — filename = form-field name (legacy).""" + connection_id = "test-connection-123" + activity_input = { + "file_param": b"raw bytes", + "description": "Test", + } + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response(method="POST", status_code=200, json={"ok": True}) + + _ = service.invoke_activity( + activity_metadata=multipart_activity_metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + sent_request = httpx_mock.get_requests()[1] + boundary = sent_request.headers["content-type"].split("boundary=")[1] + part = _multipart_part(sent_request.content, boundary, "file_param") + + # Legacy fallback: form-field name used as filename, octet-stream content type. + assert 'filename="file_param"' in part + assert "Content-Type: application/octet-stream" in part + assert b"raw bytes" in sent_request.content + + def test_invoke_activity_multipart_scalar_is_plain_form_field( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + ) -> None: + """Scalar multipart_params get sent as plain form fields (no bogus filename).""" + metadata = ActivityMetadata( + object_path="/elements/test-connector/upload", + method_name="POST", + content_type="multipart/form-data", + parameter_location_info=ActivityParameterLocationInfo( + multipart_params=["file_param", "payload"], + body_fields=[], + ), + ) + connection_id = "test-connection-123" + activity_input = { + "file_param": ("doc.pdf", b"data", "application/pdf"), + "payload": "{}", + } + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response(method="POST", status_code=200, json={"ok": True}) + + _ = service.invoke_activity( + activity_metadata=metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + sent_request = httpx_mock.get_requests()[1] + boundary = sent_request.headers["content-type"].split("boundary=")[1] + payload_part = _multipart_part(sent_request.content, boundary, "payload") + + # Scalar payload must NOT carry a filename in Content-Disposition. + assert "filename=" not in payload_part + assert "{}" in payload_part diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index dbea2b79a..f8fa7aced 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.46" +version = "0.1.47" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index f3012976e..ef9f3efb7 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.8, <0.6.0", "uipath-runtime>=0.10.1, <0.11.0", - "uipath-platform>=0.1.45, <0.2.0", + "uipath-platform>=0.1.47, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 4339a51d2..e12347835 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.46" +version = "0.1.47" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From ed18224e1644fd9771f9692918a2db976eed57c3 Mon Sep 17 00:00:00 2001 From: Jeremy Reist <106174102+JeremyReist2@users.noreply.github.com> Date: Fri, 8 May 2026 11:36:03 -0400 Subject: [PATCH 053/191] fix: remove SearchMode.AUTO from context grounding (#1619) --- packages/uipath-platform/pyproject.toml | 2 +- .../context_grounding/_context_grounding_service.py | 10 +++++----- .../platform/context_grounding/context_grounding.py | 1 - .../tests/services/test_context_grounding_service.py | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 2 +- .../src/uipath/_cli/services/cli_context_grounding.py | 6 +++--- packages/uipath/src/uipath/_resources/CLI_REFERENCE.md | 2 +- packages/uipath/src/uipath/_resources/SDK_REFERENCE.md | 4 ++-- packages/uipath/uv.lock | 4 ++-- 10 files changed, 17 insertions(+), 18 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 342b22d83..1220b386a 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.47" +version = "0.1.48" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py b/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py index 1c2eabd85..2e6e40628 100644 --- a/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py +++ b/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py @@ -1556,7 +1556,7 @@ def unified_search( self, name: str, query: str, - search_mode: SearchMode = SearchMode.AUTO, + search_mode: SearchMode = SearchMode.SEMANTIC, number_of_results: int = 10, threshold: float = 0.0, scope: Optional[UnifiedSearchScope] = None, @@ -1572,7 +1572,7 @@ def unified_search( Args: name (str): The name of the context index to search in. query (str): The search query in natural language. - search_mode (SearchMode): The search mode to use. Defaults to AUTO. + search_mode (SearchMode): The search mode to use. Defaults to SEMANTIC. number_of_results (int): Maximum number of results to return. Defaults to 10. threshold (float): Minimum similarity threshold. Defaults to 0.0. scope (Optional[UnifiedSearchScope]): Optional search scope (folder, extension). @@ -1620,7 +1620,7 @@ async def unified_search_async( self, name: str, query: str, - search_mode: SearchMode = SearchMode.AUTO, + search_mode: SearchMode = SearchMode.SEMANTIC, number_of_results: int = 10, threshold: float = 0.0, scope: Optional[UnifiedSearchScope] = None, @@ -1636,7 +1636,7 @@ async def unified_search_async( Args: name (str): The name of the context index to search in. query (str): The search query in natural language. - search_mode (SearchMode): The search mode to use. Defaults to AUTO. + search_mode (SearchMode): The search mode to use. Defaults to SEMANTIC. number_of_results (int): Maximum number of results to return. Defaults to 10. threshold (float): Minimum similarity threshold. Defaults to 0.0. scope (Optional[UnifiedSearchScope]): Optional search scope (folder, extension). @@ -2306,7 +2306,7 @@ def _unified_search_spec( self, index_id: str, query: str, - search_mode: SearchMode = SearchMode.AUTO, + search_mode: SearchMode = SearchMode.SEMANTIC, number_of_results: int = 10, threshold: float = 0.0, scope: Optional[UnifiedSearchScope] = None, diff --git a/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding.py b/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding.py index fdcf5eac9..c5c0915bb 100644 --- a/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding.py +++ b/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding.py @@ -239,7 +239,6 @@ class ContextGroundingSearchResultItem(BaseModel): class SearchMode(str, Enum): """Enum representing possible unified search modes.""" - AUTO = "Auto" SEMANTIC = "Semantic" diff --git a/packages/uipath-platform/tests/services/test_context_grounding_service.py b/packages/uipath-platform/tests/services/test_context_grounding_service.py index 3fef9f47a..ace7affcd 100644 --- a/packages/uipath-platform/tests/services/test_context_grounding_service.py +++ b/packages/uipath-platform/tests/services/test_context_grounding_service.py @@ -3700,7 +3700,7 @@ async def test_unified_search_async( response = await service.unified_search_async( name="test-index", query="test query", - search_mode=SearchMode.AUTO, + search_mode=SearchMode.SEMANTIC, ) assert isinstance(response, UnifiedQueryResult) diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index f8fa7aced..1e7878b10 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.47" +version = "0.1.48" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index ef9f3efb7..cc074cad1 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.61" +version = "2.10.62" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/services/cli_context_grounding.py b/packages/uipath/src/uipath/_cli/services/cli_context_grounding.py index 075ffe869..1e25def5e 100644 --- a/packages/uipath/src/uipath/_cli/services/cli_context_grounding.py +++ b/packages/uipath/src/uipath/_cli/services/cli_context_grounding.py @@ -460,9 +460,9 @@ def ingest_index( ) @click.option( "--search-mode", - type=click.Choice(["Auto", "Semantic"]), - default="Auto", - help="Search mode (default: Auto)", + type=click.Choice(["Semantic"]), + default="Semantic", + help="Search mode (default: Semantic)", ) @common_service_options @service_command diff --git a/packages/uipath/src/uipath/_resources/CLI_REFERENCE.md b/packages/uipath/src/uipath/_resources/CLI_REFERENCE.md index 98524ddfd..ff4009b22 100644 --- a/packages/uipath/src/uipath/_resources/CLI_REFERENCE.md +++ b/packages/uipath/src/uipath/_resources/CLI_REFERENCE.md @@ -923,7 +923,7 @@ Options: - `--query`: Search query in natural language (default: `Sentinel.UNSET`) - `--limit`: Maximum number of results (default: 10) (default: `10`) - `--threshold`: Minimum similarity threshold (default: 0.0) (default: `0.0`) -- `--search-mode`: Search mode (default: Auto) (default: `Auto`) +- `--search-mode`: Search mode (default: Semantic) (default: `Semantic`) - `--folder-path`: Folder path (e.g., "Shared"). Can also be set via UIPATH_FOLDER_PATH environment variable. (default: `Sentinel.UNSET`) - `--folder-key`: Folder key (UUID) (default: `Sentinel.UNSET`) - `--format`: Output format (overrides global) (default: `Sentinel.UNSET`) diff --git a/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md b/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md index 2e6a4a585..d92dcfded 100644 --- a/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md +++ b/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md @@ -386,10 +386,10 @@ sdk.context_grounding.start_deep_rag_ephemeral(name: str, prompt: Annotated[str, sdk.context_grounding.start_deep_rag_ephemeral_async(name: str, prompt: Annotated[str, FieldInfo(annotation=NoneType, required=True, metadata=[MaxLen(max_length=250000)])], glob_pattern: Annotated[str, FieldInfo(annotation=NoneType, required=False, default='*', metadata=[MaxLen(max_length=512)])]="**", citation_mode: uipath.platform.context_grounding.context_grounding.DeepRagCreationResponse # Perform a unified search on a context grounding index. -sdk.context_grounding.unified_search(name: str, query: str, search_mode: uipath.platform.context_grounding.context_grounding.UnifiedQueryResult +sdk.context_grounding.unified_search(name: str, query: str, search_mode: uipath.platform.context_grounding.context_grounding.UnifiedQueryResult # Asynchronously perform a unified search on a context grounding index. -sdk.context_grounding.unified_search_async(name: str, query: str, search_mode: uipath.platform.context_grounding.context_grounding.UnifiedQueryResult +sdk.context_grounding.unified_search_async(name: str, query: str, search_mode: uipath.platform.context_grounding.context_grounding.UnifiedQueryResult ``` diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index e12347835..3d4c916d3 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.61" +version = "2.10.62" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.47" +version = "0.1.48" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 03191c4f0a5cb0e68aec80ea6165d64020789623 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Mon, 11 May 2026 14:05:47 +0200 Subject: [PATCH 054/191] ci: add coverage report on PRs (#1618) --- .github/workflows/ci.yml | 6 ++ .github/workflows/test-packages.yml | 115 +++++++++++++++++++++++- packages/uipath-core/pyproject.toml | 23 ++++- packages/uipath-platform/pyproject.toml | 23 ++++- packages/uipath/pyproject.toml | 23 ++++- sonar-project.properties | 13 +++ 6 files changed, 188 insertions(+), 15 deletions(-) create mode 100644 sonar-project.properties diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e05e9c73..8be2b6b53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,9 @@ on: pull_request: branches: - main + push: + branches: + - main permissions: contents: read @@ -18,6 +21,9 @@ jobs: test: uses: ./.github/workflows/test-packages.yml + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} check-versions: + if: github.event_name == 'pull_request' uses: ./.github/workflows/check-version-availability.yml diff --git a/.github/workflows/test-packages.yml b/.github/workflows/test-packages.yml index dab4b8d7c..8ee6b5120 100644 --- a/.github/workflows/test-packages.yml +++ b/.github/workflows/test-packages.yml @@ -2,6 +2,9 @@ name: Test Packages on: workflow_call: + secrets: + SONAR_TOKEN: + required: false permissions: contents: read @@ -77,10 +80,31 @@ jobs: run: uv sync --all-extras --python ${{ matrix.python-version }} - name: Run tests - if: steps.check.outputs.skip != 'true' + if: steps.check.outputs.skip != 'true' && !(matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13') working-directory: packages/uipath-core run: uv run pytest + - name: Run tests with coverage + if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' + working-directory: packages/uipath-core + run: uv run pytest --cov-report=xml --cov-report=html --tb=short + + - name: Upload coverage HTML report + if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() + uses: actions/upload-artifact@v4 + with: + name: coverage-html-uipath-core + path: packages/uipath-core/htmlcov/ + retention-days: 30 + + - name: Upload coverage XML report + if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() + uses: actions/upload-artifact@v4 + with: + name: coverage-xml-uipath-core + path: packages/uipath-core/coverage.xml + retention-days: 30 + test-uipath-platform: name: Test (uipath-platform, ${{ matrix.python-version }}, ${{ matrix.os }}) needs: detect-changed-packages @@ -126,10 +150,31 @@ jobs: run: uv sync --all-extras --python ${{ matrix.python-version }} - name: Run tests - if: steps.check.outputs.skip != 'true' + if: steps.check.outputs.skip != 'true' && !(matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13') working-directory: packages/uipath-platform run: uv run pytest + - name: Run tests with coverage + if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' + working-directory: packages/uipath-platform + run: uv run pytest --cov-report=xml --cov-report=html --tb=short + + - name: Upload coverage HTML report + if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() + uses: actions/upload-artifact@v4 + with: + name: coverage-html-uipath-platform + path: packages/uipath-platform/htmlcov/ + retention-days: 30 + + - name: Upload coverage XML report + if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() + uses: actions/upload-artifact@v4 + with: + name: coverage-xml-uipath-platform + path: packages/uipath-platform/coverage.xml + retention-days: 30 + e2e-uipath-platform: name: E2E (uipath-platform, memory) needs: detect-changed-packages @@ -225,12 +270,76 @@ jobs: run: uv sync --all-extras --python ${{ matrix.python-version }} - name: Run tests - if: steps.check.outputs.skip != 'true' + if: steps.check.outputs.skip != 'true' && !(matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13') working-directory: packages/uipath run: uv run pytest + - name: Run tests with coverage + if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' + working-directory: packages/uipath + run: uv run pytest --cov-report=xml --cov-report=html --tb=short + + - name: Upload coverage HTML report + if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() + uses: actions/upload-artifact@v4 + with: + name: coverage-html-uipath + path: packages/uipath/htmlcov/ + retention-days: 30 + + - name: Upload coverage XML report + if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() + uses: actions/upload-artifact@v4 + with: + name: coverage-xml-uipath + path: packages/uipath/coverage.xml + retention-days: 30 + continue-on-error: true + sonarcloud: + name: SonarCloud + needs: [test-uipath-core, test-uipath-platform, test-uipath] + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' && always() && needs.test-uipath-core.result != 'failure' && needs.test-uipath-platform.result != 'failure' && needs.test-uipath.result != 'failure' + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download uipath-core coverage + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: coverage-xml-uipath-core + path: packages/uipath-core + + - name: Download uipath-platform coverage + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: coverage-xml-uipath-platform + path: packages/uipath-platform + + - name: Download uipath coverage + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: coverage-xml-uipath + path: packages/uipath + + - name: Rewrite coverage XML to repo-relative paths + run: | + sed -i 's|src|packages/uipath-core/src|g' packages/uipath-core/coverage.xml || true + sed -i 's|src|packages/uipath-platform/src|g' packages/uipath-platform/coverage.xml || true + sed -i 's|src|packages/uipath/src|g' packages/uipath/coverage.xml || true + + - name: SonarCloud Scan + uses: SonarSource/sonarqube-scan-action@2f77a1ec69fb1d595b06f35ab27e97605bdef703 # v5 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + test-gate: name: Test needs: [test-uipath-core, test-uipath-platform, test-uipath, e2e-uipath-platform] diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index cb188084e..8a001489b 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -95,15 +95,30 @@ warn_required_dynamic_aliases = true [tool.pytest.ini_options] testpaths = ["tests"] python_files = "test_*.py" -addopts = "-ra -q --cov=src/uipath --cov-report=term-missing" +addopts = "-ra -q --cov=src --cov-report=term-missing" asyncio_default_fixture_loop_scope = "function" asyncio_mode = "auto" -[tool.coverage.report] -show_missing = true - [tool.coverage.run] source = ["src"] +relative_files = true +omit = [ + "*/tests/*", + "*/__pycache__/*", + "*/site-packages/*", + "*/conftest.py", +] + +[tool.coverage.report] +show_missing = true +precision = 2 +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "@(abc\\.)?abstractmethod", +] [[tool.uv.index]] name = "testpypi" diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 1220b386a..80546ec3a 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -98,18 +98,33 @@ warn_required_dynamic_aliases = true [tool.pytest.ini_options] testpaths = ["tests"] python_files = "test_*.py" -addopts = "-ra -q --cov=src/uipath --cov-report=term-missing -m 'not e2e'" +addopts = "-ra -q --cov=src --cov-report=term-missing -m 'not e2e'" asyncio_default_fixture_loop_scope = "function" asyncio_mode = "auto" markers = [ "e2e: end-to-end tests against real ECS/LLMOps (requires UIPATH_URL, UIPATH_ACCESS_TOKEN, UIPATH_FOLDER_KEY)", ] -[tool.coverage.report] -show_missing = true - [tool.coverage.run] source = ["src"] +relative_files = true +omit = [ + "*/tests/*", + "*/__pycache__/*", + "*/site-packages/*", + "*/conftest.py", +] + +[tool.coverage.report] +show_missing = true +precision = 2 +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "@(abc\\.)?abstractmethod", +] [tool.uv.sources] uipath-core = { path = "../uipath-core", editable = true } diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index cc074cad1..3302e528e 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -137,15 +137,30 @@ warn_required_dynamic_aliases = true [tool.pytest.ini_options] testpaths = ["tests"] python_files = "test_*.py" -addopts = "-ra -q --cov" +addopts = "-ra -q --cov=src --cov-report=term-missing" asyncio_default_fixture_loop_scope = "function" asyncio_mode = "auto" -[tool.coverage.report] -show_missing = true - [tool.coverage.run] source = ["src"] +relative_files = true +omit = [ + "*/tests/*", + "*/__pycache__/*", + "*/site-packages/*", + "*/conftest.py", +] + +[tool.coverage.report] +show_missing = true +precision = 2 +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "@(abc\\.)?abstractmethod", +] [tool.uv.sources] uipath-core = { path = "../uipath-core", editable = true } diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 000000000..939a3a112 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,13 @@ +sonar.projectKey=UiPath_uipath-python +sonar.organization=ui +sonar.host.url=https://sonarcloud.io + +sonar.sources=packages/uipath/src,packages/uipath-core/src,packages/uipath-platform/src +sonar.tests=packages/uipath/tests,packages/uipath-core/tests,packages/uipath-platform/tests + +sonar.python.version=3.11,3.12,3.13 +sonar.python.coverage.reportPaths=packages/uipath/coverage.xml,packages/uipath-core/coverage.xml,packages/uipath-platform/coverage.xml + +sonar.exclusions=**/samples/**,**/testcases/**,**/template/**,**/_resources/** + +sonar.sourceEncoding=UTF-8 From 3a4f4ad2ea3e53b9956d16a81bb683e7dc36c53b Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Mon, 11 May 2026 14:14:10 +0200 Subject: [PATCH 055/191] fix(ci): run sonarcloud scan on push to main too (#1622) --- .github/workflows/test-packages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-packages.yml b/.github/workflows/test-packages.yml index 8ee6b5120..5bc300c2f 100644 --- a/.github/workflows/test-packages.yml +++ b/.github/workflows/test-packages.yml @@ -301,7 +301,7 @@ jobs: name: SonarCloud needs: [test-uipath-core, test-uipath-platform, test-uipath] runs-on: ubuntu-latest - if: github.event_name == 'pull_request' && always() && needs.test-uipath-core.result != 'failure' && needs.test-uipath-platform.result != 'failure' && needs.test-uipath.result != 'failure' + if: always() && needs.test-uipath-core.result != 'failure' && needs.test-uipath-platform.result != 'failure' && needs.test-uipath.result != 'failure' steps: - name: Checkout uses: actions/checkout@v4 From c5c4bbd37232ea688eec80d4d8f84746864acc61 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Mon, 11 May 2026 14:38:04 +0200 Subject: [PATCH 056/191] ci: scope sonarcloud job permissions to contents:read (#1623) --- .github/workflows/test-packages.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test-packages.yml b/.github/workflows/test-packages.yml index 5bc300c2f..f35d77e8f 100644 --- a/.github/workflows/test-packages.yml +++ b/.github/workflows/test-packages.yml @@ -302,6 +302,8 @@ jobs: needs: [test-uipath-core, test-uipath-platform, test-uipath] runs-on: ubuntu-latest if: always() && needs.test-uipath-core.result != 'failure' && needs.test-uipath-platform.result != 'failure' && needs.test-uipath.result != 'failure' + permissions: + contents: read steps: - name: Checkout uses: actions/checkout@v4 From dc5319f25c52581a79b2f9512b8915627c634509 Mon Sep 17 00:00:00 2001 From: AAgnihotry <95259907+AAgnihotry@users.noreply.github.com> Date: Tue, 12 May 2026 14:14:12 -0700 Subject: [PATCH 057/191] feat(eval): add runtime simulation via --simulation flag (#1624) Co-authored-by: Claude Sonnet 4.6 --- packages/uipath/pyproject.toml | 2 +- .../runtime-simulations-agent/input.json | 4 + .../samples/runtime-simulations-agent/main.py | 186 ++++++++++++++++++ .../runtime-simulations-agent/pyproject.toml | 14 ++ .../runtime-simulations-agent/simulation.json | 15 ++ .../runtime-simulations-agent/uipath.json | 5 + packages/uipath/src/uipath/_cli/cli_run.py | 41 +++- .../uipath/src/uipath/eval/mocks/__init__.py | 15 +- .../src/uipath/eval/mocks/_mock_runtime.py | 105 ++++++---- .../uipath/src/uipath/eval/mocks/_types.py | 15 ++ .../simulation-testcase/pyproject.toml | 12 ++ .../testcases/simulation-testcase/run.sh | 29 +++ .../simulation-testcase/src/assert.py | 57 ++++++ .../uipath/tests/cli/eval/mocks/test_mocks.py | 127 ++++++++++++ packages/uipath/tests/cli/test_run.py | 111 +++++++++++ packages/uipath/uv.lock | 2 +- 16 files changed, 689 insertions(+), 51 deletions(-) create mode 100644 packages/uipath/samples/runtime-simulations-agent/input.json create mode 100644 packages/uipath/samples/runtime-simulations-agent/main.py create mode 100644 packages/uipath/samples/runtime-simulations-agent/pyproject.toml create mode 100644 packages/uipath/samples/runtime-simulations-agent/simulation.json create mode 100644 packages/uipath/samples/runtime-simulations-agent/uipath.json create mode 100644 packages/uipath/testcases/simulation-testcase/pyproject.toml create mode 100644 packages/uipath/testcases/simulation-testcase/run.sh create mode 100644 packages/uipath/testcases/simulation-testcase/src/assert.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 3302e528e..aba6ae877 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.62" +version = "2.10.63" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/samples/runtime-simulations-agent/input.json b/packages/uipath/samples/runtime-simulations-agent/input.json new file mode 100644 index 000000000..9bfb2eef8 --- /dev/null +++ b/packages/uipath/samples/runtime-simulations-agent/input.json @@ -0,0 +1,4 @@ +{ + "code": "def add(a, b):\n return a+b\n\ndef divide(a,b):\n return a/b", + "language": "python" +} diff --git a/packages/uipath/samples/runtime-simulations-agent/main.py b/packages/uipath/samples/runtime-simulations-agent/main.py new file mode 100644 index 000000000..46440b459 --- /dev/null +++ b/packages/uipath/samples/runtime-simulations-agent/main.py @@ -0,0 +1,186 @@ +"""Coding agent that reviews code and suggests improvements. + +This sample demonstrates the --simulation flag: the three tool functions +(check_syntax, check_style, suggest_improvements) are decorated with @mockable, +so they can be intercepted by an LLM during a simulated run instead of +requiring a real linter or compiler to be installed. + +Run with real tools: + uipath run main.py:main -f input.json + +Run with simulation (no real tools needed): + uipath run main.py:main -f input.json --simulation "$(cat simulation.json)" +""" + +import logging + +from pydantic import BaseModel +from pydantic.dataclasses import dataclass + +from uipath.eval.mocks import ExampleCall, mockable +from uipath.tracing import traced + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Input / Output models +# --------------------------------------------------------------------------- + + +@dataclass +class CodeReviewInput: + code: str + language: str = "python" + + +class SyntaxResult(BaseModel): + valid: bool + errors: list[str] = [] + + +class StyleResult(BaseModel): + score: int # 0-100 + violations: list[str] = [] + + +class ImprovementResult(BaseModel): + suggestions: list[str] = [] + refactored_snippet: str = "" + + +class CodeReviewOutput(BaseModel): + syntax: SyntaxResult + style: StyleResult + improvements: ImprovementResult + summary: str + + +# --------------------------------------------------------------------------- +# Mockable tool functions +# --------------------------------------------------------------------------- + +CHECK_SYNTAX_EXAMPLES = [ + ExampleCall( + id="valid-python", + input='{"code": "def hello():\\n return 42", "language": "python"}', + output='{"valid": true, "errors": []}', + ), + ExampleCall( + id="syntax-error", + input='{"code": "def hello(\\n return 42", "language": "python"}', + output='{"valid": false, "errors": ["SyntaxError: unexpected EOF"]}', + ), +] + + +@traced(name="check_syntax", span_type="tool") +@mockable(example_calls=CHECK_SYNTAX_EXAMPLES) +async def check_syntax(code: str, language: str = "python") -> SyntaxResult: + """Check code for syntax errors using the language's parser. + + Args: + code: Source code to check. + language: Programming language (default: python). + + Returns: + SyntaxResult with valid flag and list of error messages. + """ + if language != "python": + return SyntaxResult(valid=True, errors=[]) + + try: + compile(code, "", "exec") + return SyntaxResult(valid=True, errors=[]) + except SyntaxError as exc: + return SyntaxResult(valid=False, errors=[str(exc)]) + + +CHECK_STYLE_EXAMPLES = [ + ExampleCall( + id="clean-code", + input='{"code": "def hello():\\n return 42\\n", "language": "python"}', + output='{"score": 95, "violations": []}', + ), + ExampleCall( + id="style-issues", + input='{"code": "def hello( ):\\n return 42", "language": "python"}', + output='{"score": 60, "violations": ["E211 whitespace before \'(\'", "W291 trailing whitespace"]}', + ), +] + + +@traced(name="check_style", span_type="tool") +@mockable(example_calls=CHECK_STYLE_EXAMPLES) +async def check_style(code: str, language: str = "python") -> StyleResult: + """Run style checks (e.g. PEP 8 for Python) on the provided code. + + Args: + code: Source code to check. + language: Programming language (default: python). + + Returns: + StyleResult with a 0-100 score and list of style violations. + """ + # Real implementation would call ruff / pycodestyle / eslint etc. + # For demo purposes we return a perfect score when not simulated. + return StyleResult(score=100, violations=[]) + + +SUGGEST_IMPROVEMENTS_EXAMPLES = [ + ExampleCall( + id="basic-function", + input='{"code": "def add(a, b):\\n return a + b"}', + output=( + '{"suggestions": ["Add type annotations", "Add a docstring"],' + ' "refactored_snippet": "def add(a: int, b: int) -> int:\\n ' + "'''Return the sum of a and b.'''\\n return a + b\"}" + ), + ) +] + + +@traced(name="suggest_improvements", span_type="tool") +@mockable(example_calls=SUGGEST_IMPROVEMENTS_EXAMPLES) +async def suggest_improvements(code: str) -> ImprovementResult: + """Analyse code and return actionable improvement suggestions. + + Args: + code: Source code to analyse. + + Returns: + ImprovementResult with suggestions and an optional refactored snippet. + """ + # Real implementation would call an LLM or static analysis tool. + return ImprovementResult(suggestions=[], refactored_snippet=code) + + +# --------------------------------------------------------------------------- +# Agent entrypoint +# --------------------------------------------------------------------------- + + +@traced(name="main") +async def main(input: CodeReviewInput) -> CodeReviewOutput: + """Orchestrate three code-review tools and produce a unified report. + + Each tool call creates its own OpenTelemetry span with span_type="tool", + which enables trajectory-based evaluation and simulation. + """ + syntax = await check_syntax(input.code, input.language) + style = await check_style(input.code, input.language) + improvements = await suggest_improvements(input.code) + + issues = len(syntax.errors) + len(style.violations) + summary = ( + f"Found {issues} issue(s). " + f"Style score: {style.score}/100. " + f"{len(improvements.suggestions)} improvement suggestion(s)." + ) + + return CodeReviewOutput( + syntax=syntax, + style=style, + improvements=improvements, + summary=summary, + ) diff --git a/packages/uipath/samples/runtime-simulations-agent/pyproject.toml b/packages/uipath/samples/runtime-simulations-agent/pyproject.toml new file mode 100644 index 000000000..335c55783 --- /dev/null +++ b/packages/uipath/samples/runtime-simulations-agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "runtime-simulations-agent" +version = "0.0.1" +description = "Code review agent demonstrating runtime simulation" +authors = [{ name = "UiPath", email = "python-sdk@uipath.com" }] +dependencies = [ + "uipath", +] +requires-python = ">=3.11" + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath/samples/runtime-simulations-agent/simulation.json b/packages/uipath/samples/runtime-simulations-agent/simulation.json new file mode 100644 index 000000000..d89bb253f --- /dev/null +++ b/packages/uipath/samples/runtime-simulations-agent/simulation.json @@ -0,0 +1,15 @@ +{ + "enabled": true, + "toolsToSimulate": [ + { + "name": "check_syntax" + }, + { + "name": "check_style" + }, + { + "name": "suggest_improvements" + } + ], + "instructions": "You are simulating a code review system. Given a tool name and its input arguments, produce a realistic JSON response that matches the tool's output schema.\n\n- check_syntax: return {\"valid\": , \"errors\": [, ...]}. If the code looks syntactically correct return valid=true and an empty errors list. Otherwise list the syntax errors.\n- check_style: return {\"score\": <0-100>, \"violations\": [, ...]}. Evaluate PEP 8 compliance for Python code. Deduct points for missing spaces, missing type annotations, etc.\n- suggest_improvements: return {\"suggestions\": [, ...], \"refactored_snippet\": \"\"}. Suggest concrete improvements such as adding type hints, docstrings, or handling edge cases (e.g. division by zero)." +} \ No newline at end of file diff --git a/packages/uipath/samples/runtime-simulations-agent/uipath.json b/packages/uipath/samples/runtime-simulations-agent/uipath.json new file mode 100644 index 000000000..9b02c2654 --- /dev/null +++ b/packages/uipath/samples/runtime-simulations-agent/uipath.json @@ -0,0 +1,5 @@ +{ + "functions": { + "main": "main.py:main" + } +} diff --git a/packages/uipath/src/uipath/_cli/cli_run.py b/packages/uipath/src/uipath/_cli/cli_run.py index 5bfd811ed..48f42018b 100644 --- a/packages/uipath/src/uipath/_cli/cli_run.py +++ b/packages/uipath/src/uipath/_cli/cli_run.py @@ -1,12 +1,14 @@ import asyncio import click +from pydantic import ValidationError from uipath._cli._chat._bridge import get_chat_bridge from uipath._cli._debug._bridge import ConsoleDebugBridge from uipath._cli._utils._common import read_resource_overwrites_from_file from uipath._cli._utils._debug import setup_debugging from uipath.core.tracing import UiPathTraceManager +from uipath.eval.mocks import SimulationConfig, UiPathMockRuntime, build_mocking_context from uipath.platform.common import ResourceOverwritesContext, UiPathConfig from uipath.runtime import ( UiPathExecuteOptions, @@ -101,6 +103,12 @@ def get_usage_help(self) -> list[str]: is_flag=True, help="Keep the temporary state file even when not resuming and no job id is provided", ) +@click.option( + "--simulation", + required=False, + default=None, + help="Simulation config as a JSON object (same schema as simulation.json)", +) @track_command("run") def run( entrypoint: str | None, @@ -114,6 +122,7 @@ def run( debug: bool, debug_port: int, keep_state_file: bool, + simulation: str | None, ) -> None: """Execute the project.""" input_file = file or input_file @@ -122,6 +131,14 @@ def run( if not setup_debugging(debug, debug_port): console.error(f"Failed to start debug server on port {debug_port}") + simulation_config: SimulationConfig | None = None + if simulation: + try: + simulation_config = SimulationConfig.model_validate_json(simulation) + except (ValidationError, ValueError) as e: + console.error(f"Invalid --simulation config: {e}") + return + result = Middlewares.next( "run", entrypoint, @@ -193,6 +210,7 @@ async def execute() -> None: lambda: read_resource_overwrites_from_file(ctx.runtime_dir) ): with ctx: + base_runtime: UiPathRuntimeProtocol | None = None runtime: UiPathRuntimeProtocol | None = None chat_runtime: UiPathRuntimeProtocol | None = None factory: UiPathRuntimeFactoryProtocol | None = None @@ -213,10 +231,27 @@ async def execute() -> None: if factory_settings else None ) - runtime = await factory.new_runtime( + base_runtime = await factory.new_runtime( resolved_entrypoint, ctx.conversation_id or ctx.job_id or "default", ) + runtime = base_runtime + + if simulation_config: + schema = await base_runtime.get_schema() + agent_model = None + if schema.metadata and "settings" in schema.metadata: + agent_model = schema.metadata["settings"].get( + "model" + ) + mocking_context = build_mocking_context( + simulation_config, agent_model + ) + if mocking_context: + runtime = UiPathMockRuntime( + delegate=base_runtime, + mocking_context=mocking_context, + ) if ctx.job_id: if UiPathConfig.is_tracing_enabled: @@ -243,8 +278,10 @@ async def execute() -> None: finally: if chat_runtime: await chat_runtime.dispose() - if runtime: + if runtime is not None and runtime is not base_runtime: await runtime.dispose() + if base_runtime is not None: + await base_runtime.dispose() if factory: await factory.dispose() diff --git a/packages/uipath/src/uipath/eval/mocks/__init__.py b/packages/uipath/src/uipath/eval/mocks/__init__.py index 95dfb877e..f9e7da177 100644 --- a/packages/uipath/src/uipath/eval/mocks/__init__.py +++ b/packages/uipath/src/uipath/eval/mocks/__init__.py @@ -1,14 +1,21 @@ """Mock interface.""" from ._mock_context import is_tool_simulated -from ._mock_runtime import UiPathMockRuntime -from ._types import ExampleCall, MockingContext +from ._mock_runtime import ( + UiPathMockRuntime, + build_mocking_context, + build_mocking_context_from_dict, +) +from ._types import ExampleCall, MockingContext, SimulationConfig from .mockable import mockable __all__ = [ "ExampleCall", - "UiPathMockRuntime", "MockingContext", - "mockable", + "SimulationConfig", + "UiPathMockRuntime", + "build_mocking_context", + "build_mocking_context_from_dict", "is_tool_simulated", + "mockable", ] diff --git a/packages/uipath/src/uipath/eval/mocks/_mock_runtime.py b/packages/uipath/src/uipath/eval/mocks/_mock_runtime.py index df41dadeb..71036215b 100644 --- a/packages/uipath/src/uipath/eval/mocks/_mock_runtime.py +++ b/packages/uipath/src/uipath/eval/mocks/_mock_runtime.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import logging import uuid from collections.abc import AsyncGenerator @@ -29,12 +28,70 @@ MockingContext, MockingStrategyType, ModelSettings, - ToolSimulation, + SimulationConfig, ) logger = logging.getLogger(__name__) +def build_mocking_context( + config: SimulationConfig, agent_model: str | None = None +) -> MockingContext | None: + """Build a MockingContext from a validated SimulationConfig. + + Args: + config: Validated simulation config. + agent_model: Optional agent model name to use as fallback. + + Returns: + MockingContext if enabled and tools are specified, None otherwise. + """ + if not config.enabled or not config.tools_to_simulate: + return None + + model = ( + ModelSettings(model=config.model) + if config.model + else ModelSettings(model=agent_model) + if agent_model + else None + ) + + mocking_strategy = LLMMockingStrategy( + type=MockingStrategyType.LLM, + prompt=config.instructions, + tools_to_simulate=config.tools_to_simulate, + model=model, + ) + + logger.debug( + f"Loaded simulation config for {len(config.tools_to_simulate)} tool(s)" + ) + return MockingContext( + strategy=mocking_strategy, + name="debug-simulation", + inputs={}, + ) + + +def build_mocking_context_from_dict( + simulation_data: dict[str, Any], agent_model: str | None = None +) -> MockingContext | None: + """Build a MockingContext from a simulation config dictionary. + + Deprecated: prefer build_mocking_context with a validated SimulationConfig. + + Args: + simulation_data: Parsed simulation config (same schema as simulation.json). + agent_model: Optional agent model name to use as fallback. + + Returns: + MockingContext if valid and enabled, None otherwise. + """ + config = SimulationConfig.model_validate(simulation_data) + return build_mocking_context(config, agent_model) + + def load_simulation_config(agent_model: str | None = None) -> MockingContext | None: """Load simulation.json from current directory and convert to MockingContext. @@ -48,48 +105,10 @@ def load_simulation_config(agent_model: str | None = None) -> MockingContext | N return None try: - with open(simulation_path, "r", encoding="utf-8") as f: - simulation_data = json.load(f) - - # Check if simulation is enabled - if not simulation_data.get("enabled", True): - return None - - # Extract tools to simulate - tools_to_simulate = [ - ToolSimulation(name=tool["name"]) - for tool in simulation_data.get("toolsToSimulate", []) - ] - - if not tools_to_simulate: - return None - - # Honor model from simulation config if specified, otherwise use the agent model - simulation_model = simulation_data.get("model") - model = ( - ModelSettings(model=simulation_model) - if simulation_model - else ModelSettings(model=agent_model) - if agent_model - else None - ) - - mocking_strategy = LLMMockingStrategy( - type=MockingStrategyType.LLM, - prompt=simulation_data.get("instructions", ""), - tools_to_simulate=tools_to_simulate, - model=model, - ) - - # Create MockingContext for debugging - mocking_context = MockingContext( - strategy=mocking_strategy, - name="debug-simulation", - inputs={}, + config = SimulationConfig.model_validate_json( + simulation_path.read_text(encoding="utf-8") ) - - logger.info(f"Loaded simulation config for {len(tools_to_simulate)} tool(s)") - return mocking_context + return build_mocking_context(config, agent_model) except Exception as e: logger.warning(f"Failed to load simulation.json: {e}") diff --git a/packages/uipath/src/uipath/eval/mocks/_types.py b/packages/uipath/src/uipath/eval/mocks/_types.py index 827569879..070040b65 100644 --- a/packages/uipath/src/uipath/eval/mocks/_types.py +++ b/packages/uipath/src/uipath/eval/mocks/_types.py @@ -129,6 +129,21 @@ class MockingContext(BaseModel): name: str = Field(default="debug") +class SimulationConfig(BaseModel): + """Top-level schema for simulation.json / --simulation flag.""" + + enabled: bool = True + tools_to_simulate: list[ToolSimulation] = Field( + default_factory=list, alias="toolsToSimulate" + ) + instructions: str = "" + model: str | None = None + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + class ExampleCall(BaseModel): """Example call for a resource containing resource I/O.""" diff --git a/packages/uipath/testcases/simulation-testcase/pyproject.toml b/packages/uipath/testcases/simulation-testcase/pyproject.toml new file mode 100644 index 000000000..d37877dbf --- /dev/null +++ b/packages/uipath/testcases/simulation-testcase/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "simulation-testcase" +version = "0.0.1" +description = "simulation-testcase" +authors = [{ name = "UiPath", email = "python-sdk@uipath.com" }] +dependencies = [ + "uipath", +] +requires-python = ">=3.11" + +[tool.uv.sources] +uipath = { path = "../../", editable = true } diff --git a/packages/uipath/testcases/simulation-testcase/run.sh b/packages/uipath/testcases/simulation-testcase/run.sh new file mode 100644 index 000000000..0095cc904 --- /dev/null +++ b/packages/uipath/testcases/simulation-testcase/run.sh @@ -0,0 +1,29 @@ +#!/bin/bash +set -e + +TESTCASE_DIR="$(cd "$(dirname "$0")" && pwd)" +SAMPLE_DIR="$(cd "$TESTCASE_DIR/../../samples/runtime-simulations-agent" && pwd)" + +echo "Syncing testcase dependencies (local editable uipath)..." +uv sync --project "$TESTCASE_DIR" + +UIPATH_BIN="$TESTCASE_DIR/.venv/bin/uipath" + +# Run auth and agent from the sample dir so credentials are stored and read +# from the same location. +cd "$SAMPLE_DIR" + +echo "Authenticating with UiPath..." +"$UIPATH_BIN" auth \ + --client-id="$CLIENT_ID" \ + --client-secret="$CLIENT_SECRET" \ + --base-url="$BASE_URL" + +echo "Running agent with simulation..." +"$UIPATH_BIN" run main \ + -f input.json \ + --simulation "$(cat simulation.json)" 2>&1 | tee "$TESTCASE_DIR/run.log" + +# Copy the runtime output file back to the testcase dir for assert.py +mkdir -p "$TESTCASE_DIR/__uipath" +cp "$SAMPLE_DIR/__uipath/output.json" "$TESTCASE_DIR/__uipath/output.json" diff --git a/packages/uipath/testcases/simulation-testcase/src/assert.py b/packages/uipath/testcases/simulation-testcase/src/assert.py new file mode 100644 index 000000000..fd7e89697 --- /dev/null +++ b/packages/uipath/testcases/simulation-testcase/src/assert.py @@ -0,0 +1,57 @@ +import json +import os + +# ── 1. Verify agent output exists and succeeded ────────────────────────────── +output_file = "__uipath/output.json" +assert os.path.isfile(output_file), "Agent output file not found" + +with open(output_file, "r", encoding="utf-8") as f: + output_data = json.load(f) + +status = output_data.get("status") +assert status == "successful", f"Agent execution failed with status: {status}" + +output = output_data.get("output", {}) + +assert "syntax" in output, "Missing 'syntax' in output" +assert "style" in output, "Missing 'style' in output" +assert "improvements" in output, "Missing 'improvements' in output" +assert "summary" in output, "Missing 'summary' in output" + +assert isinstance(output["syntax"]["valid"], bool), "'syntax.valid' must be a bool" +assert isinstance(output["syntax"]["errors"], list), "'syntax.errors' must be a list" + +score = output["style"]["score"] +assert isinstance(score, int), "'style.score' must be an int" +assert 0 <= score <= 100, f"'style.score' out of range: {score}" +assert isinstance(output["style"]["violations"], list), ( + "'style.violations' must be a list" +) + +assert isinstance(output["improvements"]["suggestions"], list), ( + "'improvements.suggestions' must be a list" +) +assert isinstance(output["improvements"]["refactored_snippet"], str), ( + "'improvements.refactored_snippet' must be a str" +) + +# ── 2. Verify simulation produced non-default values ───────────────────────── +# Real tool impls always return: score=100, violations=[], suggestions=[]. +# The LLM simulation should detect issues in the input code and return richer output. +simulated_something = ( + score < 100 + or len(output["style"]["violations"]) > 0 + or len(output["improvements"]["suggestions"]) > 0 +) +assert simulated_something, ( + "Output matches hardcoded real-tool defaults — simulation may not have run. " + f"style.score={score}, violations={output['style']['violations']}, " + f"suggestions={output['improvements']['suggestions']}" +) + +print( + f"Simulation confirmed: score={score}, " + f"violations={len(output['style']['violations'])}, " + f"suggestions={len(output['improvements']['suggestions'])}" +) +print("All assertions passed.") diff --git a/packages/uipath/tests/cli/eval/mocks/test_mocks.py b/packages/uipath/tests/cli/eval/mocks/test_mocks.py index bdbdd3dc2..c4bc26ee3 100644 --- a/packages/uipath/tests/cli/eval/mocks/test_mocks.py +++ b/packages/uipath/tests/cli/eval/mocks/test_mocks.py @@ -929,3 +929,130 @@ async def foo(*args, **kwargs) -> dict[str, Any]: }, }, } + + +class TestUiPathMockRuntime: + """Tests for UiPathMockRuntime execute/stream/get_schema paths.""" + + def _make_context(self) -> MockingContext: + return MockingContext( + strategy=LLMMockingStrategy( + prompt="test", + tools_to_simulate=[ToolSimulation(name="my_tool")], + ), + name="test", + inputs={}, + ) + + async def test_execute_with_mocking_context_sets_and_clears(self): + from unittest.mock import AsyncMock, patch + + from uipath.eval.mocks._mock_runtime import UiPathMockRuntime + + delegate = MagicMock() + mock_result = MagicMock() + delegate.execute = AsyncMock(return_value=mock_result) + + runtime = UiPathMockRuntime( + delegate=delegate, + mocking_context=self._make_context(), + ) + + with ( + patch("uipath.eval.mocks._mock_runtime.set_execution_context") as mock_set, + patch( + "uipath.eval.mocks._mock_runtime.clear_execution_context" + ) as mock_clear, + ): + result = await runtime.execute({"key": "value"}) + + assert result is mock_result + mock_set.assert_called_once() + mock_clear.assert_called_once() + + async def test_stream_with_mocking_context_sets_and_clears(self): + from unittest.mock import patch + + from uipath.eval.mocks._mock_runtime import UiPathMockRuntime + + sentinel = object() + + async def _gen(*args, **kwargs): + yield sentinel + + delegate = MagicMock() + delegate.stream = _gen + + runtime = UiPathMockRuntime( + delegate=delegate, + mocking_context=self._make_context(), + ) + + with ( + patch("uipath.eval.mocks._mock_runtime.set_execution_context") as mock_set, + patch( + "uipath.eval.mocks._mock_runtime.clear_execution_context" + ) as mock_clear, + ): + events = [e async for e in runtime.stream({})] + + assert events == [sentinel] + mock_set.assert_called_once() + mock_clear.assert_called_once() + + async def test_stream_without_mocking_context_passes_through(self): + from unittest.mock import patch + + from uipath.eval.mocks._mock_runtime import UiPathMockRuntime + + sentinel = object() + + async def _gen(*args, **kwargs): + yield sentinel + + delegate = MagicMock() + delegate.stream = _gen + + runtime = UiPathMockRuntime(delegate=delegate, mocking_context=None) + with patch( + "uipath.eval.mocks._mock_runtime.load_simulation_config", return_value=None + ): + runtime._mocking_context = None + events = [e async for e in runtime.stream({})] + + assert events == [sentinel] + + async def test_get_schema_delegates(self): + from unittest.mock import AsyncMock, patch + + from uipath.eval.mocks._mock_runtime import UiPathMockRuntime + + schema = MagicMock() + delegate = MagicMock() + delegate.get_schema = AsyncMock(return_value=schema) + + runtime = UiPathMockRuntime(delegate=delegate, mocking_context=None) + with patch( + "uipath.eval.mocks._mock_runtime.load_simulation_config", return_value=None + ): + result = await runtime.get_schema() + + assert result is schema + + def test_set_execution_context_handles_mocker_creation_failure(self): + from unittest.mock import patch + + from uipath.eval._execution_context import ExecutionSpanCollector + from uipath.eval.mocks._mock_context import mocker_context + from uipath.eval.mocks._mock_runtime import set_execution_context + + context = self._make_context() + with patch( + "uipath.eval.mocks._mock_runtime.MockerFactory.create", + side_effect=RuntimeError("boom"), + ): + set_execution_context(context, ExecutionSpanCollector(), "test-id") + + # mocking_context is set, but mocker_context must be None on failure + assert mocker_context.get() is None + clear_execution_context() diff --git a/packages/uipath/tests/cli/test_run.py b/packages/uipath/tests/cli/test_run.py index 479fc9953..aa182c7c5 100644 --- a/packages/uipath/tests/cli/test_run.py +++ b/packages/uipath/tests/cli/test_run.py @@ -1,4 +1,5 @@ # type: ignore +import json import os from contextlib import asynccontextmanager from unittest.mock import AsyncMock, Mock, patch @@ -448,3 +449,113 @@ def main(input_data: PersonIn) -> PersonOut: assert output_data["email"] == "john@example.com" assert output_data["is_adult"] is True assert output_data["greeting"] == "Hello, John Doe!" + + +_SIMULATION_JSON = { + "enabled": True, + "toolsToSimulate": [{"name": "check_syntax"}, {"name": "check_style"}], + "instructions": "Simulate.", +} + + +class TestRunSimulation: + """Tests for the --simulation flag on the run command.""" + + def _make_factory(self): + factory = Mock() + runtime = Mock() + runtime.stream = Mock(side_effect=_empty_async_gen) + runtime.dispose = AsyncMock() + runtime.get_schema = AsyncMock(return_value=Mock(metadata=None)) + factory.discover_entrypoints.return_value = ["main"] + factory.get_settings = AsyncMock(return_value=None) + factory.dispose = AsyncMock() + factory.new_runtime = AsyncMock(return_value=runtime) + return factory, runtime + + def test_invalid_simulation_json_exits_with_error( + self, runner: CliRunner, temp_dir: str + ): + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as f: + json.dump({"functions": {"main": "main.py:main"}}, f) + with open("main.py", "w") as f: + f.write("async def main(input): return {}") + + result = runner.invoke( + cli, ["run", "main", "--simulation", "{ not valid json }"] + ) + assert result.exit_code == 1 + assert "Invalid JSON" in result.output + + def test_simulation_wraps_runtime_with_mock_runtime( + self, runner: CliRunner, temp_dir: str + ): + factory, _ = self._make_factory() + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as f: + json.dump({"functions": {"main": "main.py:main"}}, f) + with open("main.py", "w") as f: + f.write("async def main(input): return {}") + + with ( + patch( + "uipath._cli.cli_run.Middlewares.next", + return_value=_middleware_continue(), + ), + patch( + "uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get", + return_value=factory, + ), + patch( + "uipath._cli.cli_run.ResourceOverwritesContext", + side_effect=_mock_resource_overwrites_context, + ), + patch("uipath._cli.cli_run.UiPathMockRuntime") as mock_cls, + ): + mock_cls.return_value = Mock( + stream=Mock(side_effect=_empty_async_gen), + dispose=AsyncMock(), + get_schema=AsyncMock(return_value=Mock(metadata=None)), + ) + runner.invoke( + cli, + ["run", "main", "--simulation", json.dumps(_SIMULATION_JSON)], + ) + + assert mock_cls.called + assert mock_cls.call_args.kwargs["mocking_context"] is not None + + def test_simulation_disabled_does_not_wrap_runtime( + self, runner: CliRunner, temp_dir: str + ): + factory, _ = self._make_factory() + disabled = {**_SIMULATION_JSON, "enabled": False} + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as f: + json.dump({"functions": {"main": "main.py:main"}}, f) + with open("main.py", "w") as f: + f.write("async def main(input): return {}") + + with ( + patch( + "uipath._cli.cli_run.Middlewares.next", + return_value=_middleware_continue(), + ), + patch( + "uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get", + return_value=factory, + ), + patch( + "uipath._cli.cli_run.ResourceOverwritesContext", + side_effect=_mock_resource_overwrites_context, + ), + patch("uipath._cli.cli_run.UiPathMockRuntime") as mock_cls, + ): + runner.invoke( + cli, ["run", "main", "--simulation", json.dumps(disabled)] + ) + + assert not mock_cls.called diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 3d4c916d3..c51486b96 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.62" +version = "2.10.63" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From f2cffbbbfd5403fbacd4068013e2a885d1df07e3 Mon Sep 17 00:00:00 2001 From: Ion Mincu Date: Fri, 15 May 2026 17:00:15 +0300 Subject: [PATCH 058/191] fix(tracing): set Traces source to CodedAgents (#1631) Co-authored-by: Claude Opus 4.7 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/common/_span_utils.py | 4 ++-- .../tests/services/test_span_utils.py | 12 ++++++------ packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/tracing/_otel_exporters.py | 2 +- packages/uipath/tests/tracing/test_otel_exporters.py | 6 +++--- packages/uipath/uv.lock | 4 ++-- 8 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 80546ec3a..bb97d8c38 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.48" +version = "0.1.49" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py index cd7e15e23..00329ecc8 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -15,8 +15,8 @@ logger = logging.getLogger(__name__) -# SourceEnum.Robots = 4 (default for Python SDK / coded agents) -DEFAULT_SOURCE = 4 +# SourceEnum.CodedAgents = 10 (default for Python SDK / coded agents) +DEFAULT_SOURCE = 10 class AttachmentProvider(IntEnum): diff --git a/packages/uipath-platform/tests/services/test_span_utils.py b/packages/uipath-platform/tests/services/test_span_utils.py index 80cd0d2db..268ffc34c 100644 --- a/packages/uipath-platform/tests/services/test_span_utils.py +++ b/packages/uipath-platform/tests/services/test_span_utils.py @@ -363,8 +363,8 @@ def test_uipath_span_missing_execution_type_and_agent_version(self): assert span_dict["AgentVersion"] is None @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) - def test_uipath_span_source_defaults_to_robots(self): - """Test that Source defaults to 4 (Robots) and ignores attributes.source.""" + def test_uipath_span_source_defaults_to_coded_agents(self): + """Test that Source defaults to 10 (CodedAgents) and ignores attributes.source.""" mock_span = Mock(spec=OTelSpan) trace_id = 0x123456789ABCDEF0123456789ABCDEF0 @@ -387,9 +387,9 @@ def test_uipath_span_source_defaults_to_robots(self): uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) span_dict = uipath_span.to_dict() - # Top-level Source should be 4 (Robots), string "runtime" is ignored - assert uipath_span.source == 4 - assert span_dict["Source"] == 4 + # Top-level Source should be 10 (CodedAgents), string "runtime" is ignored + assert uipath_span.source == 10 + assert span_dict["Source"] == 10 # attributes.source string should still be in Attributes JSON attrs = json.loads(span_dict["Attributes"]) @@ -408,7 +408,7 @@ def test_uipath_span_source_override_with_uipath_source(self): mock_span.name = "test-span" mock_span.parent = None mock_span.status.status_code = StatusCode.OK - # uipath.source=1 (Agents) overrides default of 4 (Robots) + # uipath.source=1 (Agents) overrides default of 10 (CodedAgents) mock_span.attributes = {"uipath.source": 1, "source": "runtime"} mock_span.events = [] mock_span.links = [] diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 1e7878b10..3320bcd9d 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.48" +version = "0.1.49" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index aba6ae877..6870318e3 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.63" +version = "2.10.64" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/tracing/_otel_exporters.py b/packages/uipath/src/uipath/tracing/_otel_exporters.py index 423473065..d2bf3a7c1 100644 --- a/packages/uipath/src/uipath/tracing/_otel_exporters.py +++ b/packages/uipath/src/uipath/tracing/_otel_exporters.py @@ -389,7 +389,7 @@ def _process_span_attributes(self, span_data: Dict[str, Any]) -> None: def _build_url(self, span_list: list[Dict[str, Any]]) -> str: """Construct the URL for the API request.""" trace_id = str(span_list[0]["TraceId"]) - return f"{self.base_url}/api/Traces/spans?traceId={trace_id}&source=Robots" + return f"{self.base_url}/api/Traces/spans?traceId={trace_id}&source=CodedAgents" def _send_with_retries( self, url: str, payload: list[Dict[str, Any]], max_retries: int = 4 diff --git a/packages/uipath/tests/tracing/test_otel_exporters.py b/packages/uipath/tests/tracing/test_otel_exporters.py index a55fa5d60..648dd9190 100644 --- a/packages/uipath/tests/tracing/test_otel_exporters.py +++ b/packages/uipath/tests/tracing/test_otel_exporters.py @@ -54,7 +54,7 @@ def exporter(mock_env_vars): exporter = LlmOpsHttpExporter() # Mock _build_url to include query parameters as in the actual implementation exporter._build_url = MagicMock( # type: ignore - return_value="https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/spans?traceId=test-trace-id&source=Robots" + return_value="https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/spans?traceId=test-trace-id&source=CodedAgents" ) yield exporter @@ -107,7 +107,7 @@ def test_export_success(exporter, mock_span): [{"span": "data", "TraceId": "test-trace-id"}] ) exporter.http_client.post.assert_called_once_with( - "https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/spans?traceId=test-trace-id&source=Robots", + "https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/spans?traceId=test-trace-id&source=CodedAgents", json=[{"span": "data", "TraceId": "test-trace-id"}], ) @@ -685,7 +685,7 @@ def exporter_with_mocks(self, mock_env_vars): with patch("uipath.tracing._otel_exporters.httpx.Client"): exporter = LlmOpsHttpExporter() exporter._build_url = MagicMock( # type: ignore - return_value="https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/spans?traceId=test-trace-id&source=Robots" + return_value="https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/spans?traceId=test-trace-id&source=CodedAgents" ) yield exporter diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index c51486b96..a184861d5 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.63" +version = "2.10.64" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.48" +version = "0.1.49" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 82ed987ad72e9e92e191b088821774d8a0e4da97 Mon Sep 17 00:00:00 2001 From: Ion Mincu Date: Mon, 18 May 2026 12:49:34 +0300 Subject: [PATCH 059/191] fix(tracing): use agentId as span reference_id (#1636) Co-authored-by: Claude Opus 4.7 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../uipath-platform/src/uipath/platform/common/_span_utils.py | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index bb97d8c38..944f279da 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.49" +version = "0.1.50" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py index 00329ecc8..054bb3c9c 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -283,7 +283,7 @@ def otel_span_to_uipath_span( # Top-level fields for internal tracing schema execution_type = attributes_dict.get("executionType") agent_version = attributes_dict.get("agentVersion") - reference_id = attributes_dict.get("referenceId") + reference_id = attributes_dict.get("agentId") # Source: override via uipath.source attribute, else DEFAULT_SOURCE uipath_source = attributes_dict.get("uipath.source") diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 3320bcd9d..1e3cd3430 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.49" +version = "0.1.50" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index a184861d5..4729add5f 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.49" +version = "0.1.50" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 2c3b92307f7be42bc19ed466548e679e81db7b13 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Mon, 18 May 2026 15:43:20 +0200 Subject: [PATCH 060/191] fix(jobs): allow null folder_key on Job model (#1637) --- packages/uipath-platform/pyproject.toml | 2 +- .../uipath-platform/src/uipath/platform/orchestrator/job.py | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 944f279da..ac114087b 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.50" +version = "0.1.51" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/job.py b/packages/uipath-platform/src/uipath/platform/orchestrator/job.py index 7ade631e5..6464405b4 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/job.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/job.py @@ -79,5 +79,5 @@ class Job(BaseModel): has_errors: Optional[bool] = Field(default=None, alias="HasErrors") has_warnings: Optional[bool] = Field(default=None, alias="HasWarnings") job_error: Optional[JobErrorInfo] = Field(default=None, alias="JobError") - folder_key: str = Field(alias="FolderKey") + folder_key: Optional[str] = Field(default=None, alias="FolderKey") id: int = Field(alias="Id") diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 1e3cd3430..3e7f668bf 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.50" +version = "0.1.51" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 4729add5f..c89c40005 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.50" +version = "0.1.51" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 67c4d0bea960cfd9c19a609751bd4d64e9a77caa Mon Sep 17 00:00:00 2001 From: AAgnihotry <95259907+AAgnihotry@users.noreply.github.com> Date: Mon, 18 May 2026 17:18:03 -0700 Subject: [PATCH 061/191] fix(tracing): use UIPATH_AGENT_ID for reference_id instead of PROJECT_KEY (#1643) Co-authored-by: Claude Sonnet 4.6 --- packages/uipath-platform/pyproject.toml | 2 +- .../uipath-platform/src/uipath/platform/common/_span_utils.py | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 2 +- packages/uipath/uv.lock | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index ac114087b..649a10962 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.51" +version = "0.1.52" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py index 054bb3c9c..9cb49826b 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -283,7 +283,7 @@ def otel_span_to_uipath_span( # Top-level fields for internal tracing schema execution_type = attributes_dict.get("executionType") agent_version = attributes_dict.get("agentVersion") - reference_id = attributes_dict.get("agentId") + reference_id = env.get("UIPATH_AGENT_ID") or attributes_dict.get("agentId") # Source: override via uipath.source attribute, else DEFAULT_SOURCE uipath_source = attributes_dict.get("uipath.source") diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 3e7f668bf..23d1e040f 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.51" +version = "0.1.52" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 6870318e3..d1297735e 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.64" +version = "2.10.65" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index c89c40005..4f0c96640 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.64" +version = "2.10.65" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.51" +version = "0.1.52" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 91049df2de93cc09e1084eeea71f2e27dc41471f Mon Sep 17 00:00:00 2001 From: Chibi Vikramathithan Date: Tue, 19 May 2026 00:01:44 -0700 Subject: [PATCH 062/191] fix(eval): trim legacy trajectory span history (#1653) --- packages/uipath/pyproject.toml | 2 +- .../evaluators/legacy_trajectory_evaluator.py | 7 +- .../test_legacy_trajectory_evaluator.py | 64 +++++++++++++++++++ packages/uipath/uv.lock | 2 +- 4 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 packages/uipath/tests/evaluators/test_legacy_trajectory_evaluator.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index d1297735e..9ba94e18a 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.65" +version = "2.10.66" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py index 17b69d0d0..cff0e8788 100644 --- a/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py @@ -12,13 +12,13 @@ from ..._utils.constants import COMMUNITY_agents_SUFFIX from .._execution_context import eval_set_run_id_context +from .._helpers.evaluators_helpers import trace_to_str from .._helpers.helpers import is_empty_value from ..models import EvaluationResult from ..models.models import ( AgentExecution, LLMResponse, NumericEvaluationResult, - TrajectoryEvaluationTrace, UiPathEvaluationError, UiPathEvaluationErrorCategory, ) @@ -140,10 +140,7 @@ def _create_evaluation_prompt( and agent_run_history and isinstance(agent_run_history[0], ReadableSpan) ): - trajectory_trace = TrajectoryEvaluationTrace.from_readable_spans( - agent_run_history - ) - agent_run_history = str(trajectory_trace.spans) + agent_run_history = trace_to_str(agent_run_history) else: agent_run_history = str(agent_run_history) diff --git a/packages/uipath/tests/evaluators/test_legacy_trajectory_evaluator.py b/packages/uipath/tests/evaluators/test_legacy_trajectory_evaluator.py new file mode 100644 index 000000000..687fce08d --- /dev/null +++ b/packages/uipath/tests/evaluators/test_legacy_trajectory_evaluator.py @@ -0,0 +1,64 @@ +import uuid + +from opentelemetry.sdk.trace import ReadableSpan + +from uipath.eval.evaluators import LegacyTrajectoryEvaluator +from uipath.eval.evaluators.base_legacy_evaluator import LegacyEvaluationCriteria +from uipath.eval.evaluators.legacy_trajectory_evaluator import ( + LegacyTrajectoryEvaluatorConfig, +) +from uipath.eval.models.models import LegacyEvaluatorCategory, LegacyEvaluatorType + + +def _legacy_trajectory_evaluator() -> LegacyTrajectoryEvaluator: + return LegacyTrajectoryEvaluator( + id=str(uuid.uuid4()), + name="Legacy trajectory", + config_type=LegacyTrajectoryEvaluatorConfig, + evaluation_criteria_type=LegacyEvaluationCriteria, + justification_type=str, + category=LegacyEvaluatorCategory.Trajectory, + type=LegacyEvaluatorType.Trajectory, + prompt="History:\n{{AgentRunHistory}}\nExpected:\n{{ExpectedAgentBehavior}}", + createdAt="2026-05-14T00:00:00Z", + updatedAt="2026-05-14T00:00:00Z", + ) + + +def test_legacy_trajectory_prompt_uses_compact_tool_history() -> None: + long_prompt = "SYSTEM_PROMPT_" + ("x" * 10_000) + spans = [ + ReadableSpan( + name="agent_llm_call", + start_time=0, + end_time=1, + attributes={ + "openinference.span.kind": "LLM", + "input.value": f'{{"messages": [{{"role": "system", "content": "{long_prompt}"}}]}}', + "output.value": '{"generations": []}', + }, + ), + ReadableSpan( + name="search_profiles", + start_time=1, + end_time=2, + attributes={ + "openinference.span.kind": "TOOL", + "tool.name": "search_profiles", + "input.value": '{"query": "mentor"}', + "output.value": '{"content": "found mentor profile"}', + "metadata": f'{{"agent_prompt": "{long_prompt}"}}', + }, + ), + ] + + prompt = _legacy_trajectory_evaluator()._create_evaluation_prompt( + expected_agent_behavior="The agent should search matching profiles.", + agent_run_history=spans, + ) + + assert "SYSTEM_PROMPT_" not in prompt + assert "Tool: search_profiles" in prompt + assert '{"query": "mentor"}' in prompt + assert "found mentor profile" in prompt + assert "agent_llm_call" not in prompt diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 4f0c96640..1af3b0d0a 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.65" +version = "2.10.66" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 790112887acb93d14fe8a42c76930163b6f02d89 Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Tue, 19 May 2026 11:19:43 +0300 Subject: [PATCH 063/191] chore: enforce 2 days min age for third parties (#1654) --- packages/uipath-core/pyproject.toml | 3 +++ packages/uipath-core/uv.lock | 4 ++++ packages/uipath-platform/pyproject.toml | 6 ++++++ packages/uipath-platform/uv.lock | 7 +++++++ packages/uipath/pyproject.toml | 8 ++++++++ packages/uipath/uv.lock | 9 +++++++++ 6 files changed, 37 insertions(+) diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 8a001489b..959122a11 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -120,6 +120,9 @@ exclude_lines = [ "@(abc\\.)?abstractmethod", ] +[tool.uv] +exclude-newer = "2 days" + [[tool.uv.index]] name = "testpypi" url = "https://test.pypi.org/simple/" diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index bf73c603d..ab6e6aa14 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -2,6 +2,10 @@ version = 1 revision = 3 requires-python = ">=3.11" +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P2D" + [[package]] name = "annotated-types" version = "0.7.0" diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 649a10962..cad224be8 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -126,6 +126,12 @@ exclude_lines = [ "@(abc\\.)?abstractmethod", ] +[tool.uv] +exclude-newer = "2 days" + +[tool.uv.exclude-newer-package] +uipath-core = false + [tool.uv.sources] uipath-core = { path = "../uipath-core", editable = true } diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 23d1e040f..72baba331 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -2,6 +2,13 @@ version = 1 revision = 3 requires-python = ">=3.11" +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P2D" + +[options.exclude-newer-package] +uipath-core = false + [[package]] name = "annotated-types" version = "0.7.0" diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 9ba94e18a..12777e925 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -162,6 +162,14 @@ exclude_lines = [ "@(abc\\.)?abstractmethod", ] +[tool.uv] +exclude-newer = "2 days" + +[tool.uv.exclude-newer-package] +uipath-core = false +uipath-runtime = false +uipath-platform = false + [tool.uv.sources] uipath-core = { path = "../uipath-core", editable = true } uipath-platform = { path = "../uipath-platform", editable = true } diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 1af3b0d0a..3cc0f8f89 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.11" +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P2D" + +[options.exclude-newer-package] +uipath-runtime = false +uipath-platform = false +uipath-core = false + [[package]] name = "aiohappyeyeballs" version = "2.6.1" From 4de530c137337ee451919cdf2a387d056500b615 Mon Sep 17 00:00:00 2001 From: Cristian Cotovanu <87022468+cotovanu-cristian@users.noreply.github.com> Date: Tue, 19 May 2026 15:19:55 +0300 Subject: [PATCH 064/191] feat(platform): promote verbosityLevel attribute to top-level VerbosityLevel field (#1627) Co-authored-by: Claude Opus 4.7 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/common/_span_utils.py | 18 ++++- .../tests/services/test_span_utils.py | 79 +++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 4 +- .../uipath/src/uipath/tracing/__init__.py | 2 + .../tests/tracing/test_otel_exporters.py | 13 +++ packages/uipath/uv.lock | 4 +- 8 files changed, 117 insertions(+), 7 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index cad224be8..9879982c3 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.52" +version = "0.1.53" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py index 9cb49826b..120c39b68 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -29,6 +29,16 @@ class AttachmentDirection(IntEnum): OUT = 2 +class VerbosityLevel(IntEnum): + VERBOSE = 0 + TRACE = 1 + INFORMATION = 2 + WARNING = 3 + ERROR = 4 + CRITICAL = 5 + OFF = 6 + + class SpanAttachment(BaseModel): """Represents an attachment in the UiPath tracing system.""" @@ -87,6 +97,7 @@ class UiPathSpan: # Top-level fields for internal tracing schema execution_type: Optional[int] = None agent_version: Optional[str] = None + verbosity_level: Optional[int] = None attachments: Optional[List[SpanAttachment]] = None def to_dict(self, serialize_attributes: bool = True) -> Dict[str, Any]: @@ -114,7 +125,7 @@ def to_dict(self, serialize_attributes: bool = True) -> Dict[str, Any]: for att in self.attachments ] - return { + result: Dict[str, Any] = { "Id": self.id, "TraceId": self.trace_id, "ParentId": self.parent_id, @@ -138,6 +149,9 @@ def to_dict(self, serialize_attributes: bool = True) -> Dict[str, Any]: "AgentVersion": self.agent_version, "Attachments": attachments_out, } + if self.verbosity_level is not None: + result["VerbosityLevel"] = self.verbosity_level + return result class _SpanUtils: @@ -284,6 +298,7 @@ def otel_span_to_uipath_span( execution_type = attributes_dict.get("executionType") agent_version = attributes_dict.get("agentVersion") reference_id = env.get("UIPATH_AGENT_ID") or attributes_dict.get("agentId") + verbosity_level = attributes_dict.get("verbosityLevel") # Source: override via uipath.source attribute, else DEFAULT_SOURCE uipath_source = attributes_dict.get("uipath.source") @@ -334,6 +349,7 @@ def otel_span_to_uipath_span( span_type=span_type, execution_type=execution_type, agent_version=agent_version, + verbosity_level=verbosity_level, reference_id=reference_id, source=source, attachments=attachments, diff --git a/packages/uipath-platform/tests/services/test_span_utils.py b/packages/uipath-platform/tests/services/test_span_utils.py index 268ffc34c..c10729d31 100644 --- a/packages/uipath-platform/tests/services/test_span_utils.py +++ b/packages/uipath-platform/tests/services/test_span_utils.py @@ -10,6 +10,85 @@ from uipath.platform.common import UiPathSpan, _SpanUtils +class TestOTelToUiPathSpan: + """OTEL attribute -> top-level UiPathSpan field mapping. + + `_SpanUtils.otel_span_to_uipath_span` lifts a small set of OTEL + span attributes onto dedicated `UiPathSpan` fields surfaced under + `to_dict()`. This test documents that mapping — adding a new row + means the attribute is newly mapped, removing one breaks + downstream consumers. + """ + + ATTRIBUTE_FIELD_MAP = [ + ("executionType", "execution_type", "ExecutionType", 1), + ("agentVersion", "agent_version", "AgentVersion", "1.2.3"), + ("agentId", "reference_id", "ReferenceId", "ref-abc"), + ("verbosityLevel", "verbosity_level", "VerbosityLevel", 6), + ] + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_attributes_map_to_top_level_fields(self) -> None: + attrs = { + otel_attr: value for otel_attr, _, _, value in self.ATTRIBUTE_FIELD_MAP + } + + mock_span = Mock(spec=OTelSpan) + mock_context = SpanContext( + trace_id=0x123456789ABCDEF0123456789ABCDEF0, + span_id=0x0123456789ABCDEF, + is_remote=False, + ) + mock_span.get_span_context.return_value = mock_context + mock_span.name = "test-span" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + mock_span.attributes = attrs + mock_span.events = [] + mock_span.links = [] + now_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = now_ns + mock_span.end_time = now_ns + 1_000_000 + + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + span_dict = uipath_span.to_dict() + + for _, span_field, top_level_key, value in self.ATTRIBUTE_FIELD_MAP: + assert getattr(uipath_span, span_field) == value, span_field + assert span_dict[top_level_key] == value, top_level_key + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_verbosity_level_omitted_when_unset(self) -> None: + """Spans that don't set verbosityLevel must not carry the key on the wire. + + Backwards compat: pre-existing spans never emitted VerbosityLevel; the + LLMOps backend applies its own default. Adding `"VerbosityLevel": null` + unconditionally would change the wire format for every existing span. + """ + mock_span = Mock(spec=OTelSpan) + mock_context = SpanContext( + trace_id=0x123456789ABCDEF0123456789ABCDEF0, + span_id=0x0123456789ABCDEF, + is_remote=False, + ) + mock_span.get_span_context.return_value = mock_context + mock_span.name = "legacy-span" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + mock_span.attributes = {"someOtherAttr": "value"} + mock_span.events = [] + mock_span.links = [] + now_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = now_ns + mock_span.end_time = now_ns + 1_000_000 + + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + span_dict = uipath_span.to_dict() + + assert uipath_span.verbosity_level is None + assert "VerbosityLevel" not in span_dict + + class TestNormalizeIds: """Tests for OTEL ID normalization functions.""" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 72baba331..4293ee066 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.52" +version = "0.1.53" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 12777e925..2d50973d8 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.10.66" +version = "2.10.67" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.8, <0.6.0", "uipath-runtime>=0.10.1, <0.11.0", - "uipath-platform>=0.1.47, <0.2.0", + "uipath-platform>=0.1.53, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/src/uipath/tracing/__init__.py b/packages/uipath/src/uipath/tracing/__init__.py index aaef6328c..e6c37bc99 100644 --- a/packages/uipath/src/uipath/tracing/__init__.py +++ b/packages/uipath/src/uipath/tracing/__init__.py @@ -5,6 +5,7 @@ AttachmentDirection, AttachmentProvider, SpanAttachment, + VerbosityLevel, ) from ._live_tracking_processor import LiveTrackingSpanProcessor @@ -23,4 +24,5 @@ "AttachmentDirection", "AttachmentProvider", "SpanAttachment", + "VerbosityLevel", ] diff --git a/packages/uipath/tests/tracing/test_otel_exporters.py b/packages/uipath/tests/tracing/test_otel_exporters.py index 648dd9190..fc5a370c0 100644 --- a/packages/uipath/tests/tracing/test_otel_exporters.py +++ b/packages/uipath/tests/tracing/test_otel_exporters.py @@ -810,5 +810,18 @@ def test_none_stays_none(self, mock_env_vars, mock_span): assert payload["ProcessKey"] is None +class TestVerbosityLevelReexport: + """VerbosityLevel from uipath-platform is re-exported via uipath.tracing.""" + + def test_uipath_tracing_reexports_verbosity_level(self) -> None: + from uipath.platform.common._span_utils import ( + VerbosityLevel as _CommonVerbosity, + ) + from uipath.tracing import VerbosityLevel as _TracingVerbosity + + assert _TracingVerbosity is _CommonVerbosity + assert _TracingVerbosity.OFF == 6 + + if __name__ == "__main__": unittest.main() diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 3cc0f8f89..c97b619da 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.66" +version = "2.10.67" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.52" +version = "0.1.53" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 010a8b7a6c11818184ee6478de1e76c82bfcd0ae Mon Sep 17 00:00:00 2001 From: yashwagle1 Date: Tue, 19 May 2026 09:56:40 -0700 Subject: [PATCH 065/191] fix(automation-ops): handle empty deployed-policy response (#1642) Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: AAgnihotry <95259907+AAgnihotry@users.noreply.github.com> --- packages/uipath-platform/pyproject.toml | 2 +- .../automation_ops/_automation_ops_service.py | 10 ++++-- .../services/test_automation_ops_service.py | 36 +++++++++++++++++++ packages/uipath-platform/uv.lock | 4 +-- packages/uipath/uv.lock | 4 +-- 5 files changed, 49 insertions(+), 7 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 9879982c3..67fa9de2e 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.53" +version = "0.1.54" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/automation_ops/_automation_ops_service.py b/packages/uipath-platform/src/uipath/platform/automation_ops/_automation_ops_service.py index 05c37bbb5..b5eac8cdd 100644 --- a/packages/uipath-platform/src/uipath/platform/automation_ops/_automation_ops_service.py +++ b/packages/uipath-platform/src/uipath/platform/automation_ops/_automation_ops_service.py @@ -30,7 +30,8 @@ def get_deployed_policy(self) -> dict[str, Any]: """Retrieve the deployed policy. Returns: - The deployed policy response as a dictionary. + The deployed policy response as a dictionary. Returns an empty + dict when no policy is deployed (empty 200 response body). """ spec = self._deployed_policy_spec() response = self.request( @@ -39,6 +40,8 @@ def get_deployed_policy(self) -> dict[str, Any]: headers=spec.headers, scoped="tenant", ) + if not response.content: + return {} return response.json() @traced(name="automation_ops_get_deployed_policy", run_type="uipath") @@ -46,7 +49,8 @@ async def get_deployed_policy_async(self) -> dict[str, Any]: """Retrieve the deployed policy (async). Returns: - The deployed policy response as a dictionary. + The deployed policy response as a dictionary. Returns an empty + dict when no policy is deployed (empty 200 response body). """ spec = self._deployed_policy_spec() response = await self.request_async( @@ -55,6 +59,8 @@ async def get_deployed_policy_async(self) -> dict[str, Any]: headers=spec.headers, scoped="tenant", ) + if not response.content: + return {} return response.json() def _deployed_policy_spec(self) -> RequestSpec: diff --git a/packages/uipath-platform/tests/services/test_automation_ops_service.py b/packages/uipath-platform/tests/services/test_automation_ops_service.py index c90e1f80a..e19a02a5a 100644 --- a/packages/uipath-platform/tests/services/test_automation_ops_service.py +++ b/packages/uipath-platform/tests/services/test_automation_ops_service.py @@ -49,6 +49,24 @@ def test_returns_policy_dict( assert result == expected_policy + def test_returns_empty_dict_when_no_policy_deployed( + self, + httpx_mock: HTTPXMock, + service: AutomationOpsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/agenthub_/api/policies/deployed-policy", + status_code=200, + content=b"", + ) + + result = service.get_deployed_policy() + + assert result == {} + def test_uses_post_method( self, httpx_mock: HTTPXMock, @@ -102,6 +120,24 @@ async def test_returns_policy_dict( assert result == expected_policy + async def test_returns_empty_dict_when_no_policy_deployed( + self, + httpx_mock: HTTPXMock, + service: AutomationOpsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/agenthub_/api/policies/deployed-policy", + status_code=200, + content=b"", + ) + + result = await service.get_deployed_policy_async() + + assert result == {} + async def test_url_is_tenant_scoped( self, httpx_mock: HTTPXMock, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 4293ee066..777679614 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-05-17T16:21:45.1868663Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.53" +version = "0.1.54" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index c97b619da..d7e9a9ee9 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-05-17T16:21:47.0725551Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.53" +version = "0.1.54" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 5d8889476ccf9f2a031429afe97e50ba83b14820 Mon Sep 17 00:00:00 2001 From: Radu Mihai Gheorghe Date: Wed, 20 May 2026 16:26:29 +0300 Subject: [PATCH 066/191] feat: introduce ToolsConfiguration on AgentMcpResourceConfig (#1655) --- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/agent/models/agent.py | 41 +++++++++++++++++-- packages/uipath/uv.lock | 4 +- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 2d50973d8..36550f54d 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.67" +version = "2.10.68" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 38bc8a815..ba20b8165 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -446,13 +446,48 @@ class AgentMcpTool(BaseCfg): class DynamicToolsMode(str, CaseInsensitiveEnum): - """Dynamic tools mode enumeration.""" + """Dynamic tools mode enumeration. + + Deprecated: kept for backwards compatibility with older ``agent.json`` files + that still serialize the ``dynamicTools`` field. New code should use + :class:`ToolsConfiguration` (see ``AgentMcpResourceConfig.tools_configuration``). + """ NONE = "none" SCHEMA = "schema" ALL = "all" +class CachedToolsConfig(BaseCfg): + """Cached tools configuration: use the tools saved in the agent definition snapshot.""" + + type: Literal["cached"] = Field(default="cached", frozen=True) + + +class DynamicToolsConfig(BaseCfg): + """Dynamic tools configuration: fetch the tool list from the MCP server at runtime. + + When ``allow_all`` is true, every tool the server exposes is forwarded + to the agent. When false, the live list is filtered by the snapshot's + ``available_tools`` allowlist (live schemas, curated tool set). + """ + + type: Literal["dynamic"] = Field(default="dynamic", frozen=True) + allow_all: bool = Field(alias="allowAll") + + +DiscoveryMode = Annotated[ + Union[CachedToolsConfig, DynamicToolsConfig], + Field(discriminator="type"), +] + + +class ToolsConfiguration(BaseCfg): + """Configuration describing how tools are sourced for an MCP resource.""" + + discovery_mode: DiscoveryMode = Field(alias="discoveryMode") + + class AgentMcpResourceConfig(BaseAgentResourceConfig): """Agent MCP resource configuration model.""" @@ -462,8 +497,8 @@ class AgentMcpResourceConfig(BaseAgentResourceConfig): folder_path: str = Field(alias="folderPath") slug: str = Field(..., alias="slug") available_tools: List[AgentMcpTool] = Field(..., alias="availableTools") - dynamic_tools: DynamicToolsMode = Field( - default=DynamicToolsMode.NONE, alias="dynamicTools" + tools_configuration: Optional[ToolsConfiguration] = Field( + default=None, alias="toolsConfiguration" ) diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index d7e9a9ee9..41ae12119 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-05-17T16:21:47.0725551Z" +exclude-newer = "2026-05-17T17:25:34.9197064Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.67" +version = "2.10.68" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 3658f1728b77ee134b3ad0a478eb3cc4ee7715f8 Mon Sep 17 00:00:00 2001 From: Cristian Cotovanu <87022468+cotovanu-cristian@users.noreply.github.com> Date: Thu, 21 May 2026 14:43:57 +0300 Subject: [PATCH 067/191] feat(agent): add Flow tool type for Maestro Flow processes (#1670) Co-authored-by: Claude Opus 4.7 (1M context) --- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/agent/models/agent.py | 3 + .../uipath/tests/agent/models/test_agent.py | 69 +++++++++++++++++++ packages/uipath/uv.lock | 4 +- 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 36550f54d..0d70cb383 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.68" +version = "2.10.69" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index ba20b8165..8a4ad1ac7 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -112,6 +112,7 @@ class AgentToolType(str, CaseInsensitiveEnum): PROCESS = "Process" API = "Api" PROCESS_ORCHESTRATION = "ProcessOrchestration" + FLOW = "Flow" INTEGRATION = "Integration" INTERNAL = "Internal" IXP = "Ixp" @@ -779,6 +780,7 @@ class AgentProcessToolResourceConfig(BaseAgentToolResourceConfig): AgentToolType.PROCESS, AgentToolType.API, AgentToolType.PROCESS_ORCHESTRATION, + AgentToolType.FLOW, ] output_schema: Dict[str, Any] = Field(EMPTY_SCHEMA, alias="outputSchema") properties: AgentProcessToolProperties @@ -1322,6 +1324,7 @@ def _normalize_resources(v: Dict[str, Any]) -> None: "process": "Process", "api": "Api", "processorchestration": "ProcessOrchestration", + "flow": "Flow", "integration": "Integration", "internal": "Internal", "ixp": "Ixp", diff --git a/packages/uipath/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py index 4daae105a..14ca8018d 100644 --- a/packages/uipath/tests/agent/models/test_agent.py +++ b/packages/uipath/tests/agent/models/test_agent.py @@ -3558,6 +3558,75 @@ def test_process_tool_missing_output_schema(self): assert isinstance(tool_resource, AgentProcessToolResourceConfig) assert tool_resource.output_schema == {"type": "object", "properties": {}} + def test_flow_tool_type_enum_value(self): + """AgentToolType.FLOW exists with the wire value 'Flow' and is case-insensitive.""" + assert AgentToolType.FLOW.value == "Flow" + assert AgentToolType("flow") is AgentToolType.FLOW + assert AgentToolType("FLOW") is AgentToolType.FLOW + + def test_flow_tool_resource_deserialization(self): + """A resource with type='Flow' is parsed as AgentProcessToolResourceConfig.""" + resources = [ + { + "$resourceType": "tool", + "type": "Flow", + "id": "flow-tool-1", + "inputSchema": { + "type": "object", + "properties": {"input": {"type": "string"}}, + }, + "outputSchema": {"type": "object", "properties": {}}, + "arguments": {}, + "settings": {"timeout": 0, "maxAttempts": 0, "retryDelay": 0}, + "properties": { + "processName": "MyFlow", + "folderPath": "/Shared/Flows", + }, + "name": "Flow Tool", + "description": "Test Flow tool", + } + ] + + json_data = self._agent_dict_with_resources(resources) + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + tool_resource = config.resources[0] + assert isinstance(tool_resource, AgentProcessToolResourceConfig) + assert tool_resource.type == AgentToolType.FLOW + assert tool_resource.properties.process_name == "MyFlow" + assert tool_resource.properties.folder_path == "/Shared/Flows" + + def test_flow_tool_resource_case_insensitive(self): + """A resource with lowercase type='flow' also deserializes via CaseInsensitiveEnum.""" + resources = [ + { + "$resourceType": "tool", + "type": "flow", + "id": "flow-tool-2", + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": {"type": "object", "properties": {}}, + "arguments": {}, + "settings": {"timeout": 0, "maxAttempts": 0, "retryDelay": 0}, + "properties": { + "processName": "MyFlow", + "folderPath": "/Shared/Flows", + }, + "name": "Flow Tool", + "description": "Test Flow tool", + } + ] + + json_data = self._agent_dict_with_resources(resources) + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + tool_resource = config.resources[0] + assert isinstance(tool_resource, AgentProcessToolResourceConfig) + assert tool_resource.type == AgentToolType.FLOW + def test_escalation_missing_escalation_type_defaults_to_zero(self): """Test that missing escalationType defaults to 0.""" resources = [ diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 41ae12119..19b0d047b 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-05-17T17:25:34.9197064Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.68" +version = "2.10.69" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 7e3163b2676b18b7f44a126f39ba0250f16af2c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrei=20Ancu=C8=9Ba?= Date: Thu, 21 May 2026 16:39:49 +0300 Subject: [PATCH 068/191] feat: add arrayBuilder ArgumentProperties variant (#1672) --- packages/uipath/pyproject.toml | 2 +- packages/uipath/src/uipath/agent/models/agent.py | 11 +++++++++++ packages/uipath/uv.lock | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 0d70cb383..8e9c9f581 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.69" +version = "2.10.70" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 8a4ad1ac7..72e7c438f 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -189,6 +189,7 @@ class AgentToolArgumentPropertiesVariant(str, CaseInsensitiveEnum): ARGUMENT = "argument" STATIC = "static" TEXT_BUILDER = "textBuilder" + ARRAY_BUILDER = "arrayBuilder" class TextTokenType(str, CaseInsensitiveEnum): @@ -277,11 +278,21 @@ class AgentToolTextBuilderArgumentProperties(BaseAgentToolArgumentProperties): tokens: List[TextToken] +class AgentToolArrayBuilderArgumentProperties(BaseCfg): + """Agent array builder argument properties model.""" + + variant: Literal[AgentToolArgumentPropertiesVariant.ARRAY_BUILDER] = Field( + default=AgentToolArgumentPropertiesVariant.ARRAY_BUILDER, + frozen=True, + ) + + AgentToolArgumentProperties = Annotated[ Union[ AgentToolStaticArgumentProperties, AgentToolArgumentArgumentProperties, AgentToolTextBuilderArgumentProperties, + AgentToolArrayBuilderArgumentProperties, ], Field(discriminator="variant"), _case_insensitive_enum_validator("variant", AgentToolArgumentPropertiesVariant), diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 19b0d047b..273bfe181 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.69" +version = "2.10.70" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 651b3b22568c4ce4ae4c09b094b4b688b6502ca5 Mon Sep 17 00:00:00 2001 From: Ion Mincu Date: Thu, 21 May 2026 17:02:33 +0300 Subject: [PATCH 069/191] fix(tracing): fall back to referenceId attribute when agentId is missing (#1673) Co-authored-by: Claude Opus 4.7 --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/common/_span_utils.py | 6 +- .../tests/services/test_span_utils.py | 77 +++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 5 files changed, 85 insertions(+), 4 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 67fa9de2e..9e82df730 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.54" +version = "0.1.55" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py index 120c39b68..ab91b3623 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -297,7 +297,11 @@ def otel_span_to_uipath_span( # Top-level fields for internal tracing schema execution_type = attributes_dict.get("executionType") agent_version = attributes_dict.get("agentVersion") - reference_id = env.get("UIPATH_AGENT_ID") or attributes_dict.get("agentId") + reference_id = ( + env.get("UIPATH_AGENT_ID") + or attributes_dict.get("agentId") + or attributes_dict.get("referenceId") + ) verbosity_level = attributes_dict.get("verbosityLevel") # Source: override via uipath.source attribute, else DEFAULT_SOURCE diff --git a/packages/uipath-platform/tests/services/test_span_utils.py b/packages/uipath-platform/tests/services/test_span_utils.py index c10729d31..03f728eb8 100644 --- a/packages/uipath-platform/tests/services/test_span_utils.py +++ b/packages/uipath-platform/tests/services/test_span_utils.py @@ -89,6 +89,83 @@ def test_verbosity_level_omitted_when_unset(self) -> None: assert "VerbosityLevel" not in span_dict +class TestReferenceIdResolution: + """`reference_id` resolution chain. + + Priority: `UIPATH_AGENT_ID` env var > `agentId` attribute > `referenceId` + attribute. Falsy values (missing / empty string) at each step fall through + to the next source. The `referenceId` fallback exists for backwards + compatibility with older producers that only emit that attribute. + """ + + @pytest.mark.parametrize( + ("env_value", "attributes", "expected"), + [ + pytest.param( + "env-agent", + {"agentId": "attr-agent", "referenceId": "attr-ref"}, + "env-agent", + id="env-var-wins", + ), + pytest.param( + None, + {"agentId": "attr-agent", "referenceId": "attr-ref"}, + "attr-agent", + id="agent-id-attr-when-env-unset", + ), + pytest.param( + None, + {"referenceId": "attr-ref"}, + "attr-ref", + id="reference-id-fallback-when-agent-id-missing", + ), + pytest.param( + None, + {"agentId": "", "referenceId": "attr-ref"}, + "attr-ref", + id="reference-id-fallback-when-agent-id-empty", + ), + pytest.param( + None, + {}, + None, + id="none-when-all-sources-missing", + ), + ], + ) + def test_reference_id_chain( + self, + env_value: str | None, + attributes: dict[str, object], + expected: str | None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + if env_value is None: + monkeypatch.delenv("UIPATH_AGENT_ID", raising=False) + else: + monkeypatch.setenv("UIPATH_AGENT_ID", env_value) + + mock_span = Mock(spec=OTelSpan) + mock_context = SpanContext( + trace_id=0x123456789ABCDEF0123456789ABCDEF0, + span_id=0x0123456789ABCDEF, + is_remote=False, + ) + mock_span.get_span_context.return_value = mock_context + mock_span.name = "test-span" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + mock_span.attributes = attributes + mock_span.events = [] + mock_span.links = [] + now_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = now_ns + mock_span.end_time = now_ns + 1_000_000 + + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + assert uipath_span.reference_id == expected + + class TestNormalizeIds: """Tests for OTEL ID normalization functions.""" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 777679614..ed99b6c36 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.54" +version = "0.1.55" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 273bfe181..72e9fde23 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.54" +version = "0.1.55" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 3bae6bb4319ebcb7b98b1a19e62e2d97ec37a8d9 Mon Sep 17 00:00:00 2001 From: Andrei Petraru Date: Fri, 22 May 2026 16:59:54 +0300 Subject: [PATCH 070/191] docs: add LangChain guardrails nav entry and remove deprecated PromptInjection [AL-437] (#1656) Co-authored-by: Claude Sonnet 4.6 --- packages/uipath/docs/core/guardrails.md | 45 ++++++------------------- packages/uipath/mkdocs.yml | 4 +++ 2 files changed, 14 insertions(+), 35 deletions(-) diff --git a/packages/uipath/docs/core/guardrails.md b/packages/uipath/docs/core/guardrails.md index a7e91f43f..4c380c20c 100644 --- a/packages/uipath/docs/core/guardrails.md +++ b/packages/uipath/docs/core/guardrails.md @@ -1,6 +1,6 @@ # Guardrails -Guardrails are safeguards applied before and/or after execution to inspect inputs and outputs for policy violations — PII, harmful content, prompt injection, intellectual property, and custom rules — and respond by logging, blocking, or modifying the data. +Guardrails are safeguards applied before and/or after execution to inspect inputs and outputs for policy violations — PII, harmful content, prompt attacks, intellectual property, and custom rules — and respond by logging, blocking, or modifying the data. They can be applied at three scopes: @@ -44,9 +44,11 @@ When using LangChain's `@tool`, `@guardrail` must be placed **above** `@tool`: from langchain_core.tools import tool @guardrail( - validator=PromptInjectionValidator(threshold=0.5), + validator=PIIValidator( + entities=[PIIDetectionEntity(PIIDetectionEntityType.EMAIL, threshold=0.5)] + ), action=BlockAction(), - name="No prompt injection", + name="No PII in tool input", stage=GuardrailExecutionStage.PRE, ) @tool # @guardrail wraps the already-decorated tool object @@ -58,9 +60,9 @@ def analyze_joke(joke: str) -> str: ```python @guardrail( - validator=PromptInjectionValidator(threshold=0.5), + validator=UserPromptAttacksValidator(), action=BlockAction(), - name="LLM Prompt Injection Detection", + name="LLM User Prompt Attacks Detection", stage=GuardrailExecutionStage.PRE, ) def create_llm(): @@ -92,7 +94,7 @@ The `stage` parameter controls when the guardrail evaluates. Not all validators | Stage | When evaluated | Supported by | |-------|---------------|--------------| | `PRE` | Before the function runs | All validators | -| `POST` | After the function runs | All except `PromptInjectionValidator`, `UserPromptAttacksValidator` | +| `POST` | After the function runs | All except `UserPromptAttacksValidator` | | `PRE_AND_POST` | Both before and after | `PIIValidator`, `HarmfulContentValidator`, `CustomValidator` | ## Built-in Validators @@ -156,28 +158,6 @@ def generate_response(prompt: str) -> str: ... ``` -### Prompt Injection - -Detects prompt injection attacks in user input. Restricted to `PRE` stage only — this is an input concern. - -```python -from uipath.platform.guardrails import ( - BlockAction, - GuardrailExecutionStage, - PromptInjectionValidator, - guardrail, -) - -@guardrail( - validator=PromptInjectionValidator(threshold=0.5), - action=BlockAction(), - name="No prompt injection", - stage=GuardrailExecutionStage.PRE, -) -def run_agent_step(user_input: str) -> str: - ... -``` - ### User Prompt Attacks Detects adversarial user prompt patterns (e.g. jailbreak attempts). No configuration parameters required. Restricted to `PRE` stage only. @@ -337,9 +317,9 @@ Multiple `@guardrail` decorators can be stacked on the same function. Each is ev ```python @guardrail( - validator=PromptInjectionValidator(), + validator=UserPromptAttacksValidator(), action=BlockAction(), - name="No injection", + name="No prompt attacks", stage=GuardrailExecutionStage.PRE, ) @guardrail( @@ -448,11 +428,6 @@ print(result.result, result.reason) members: - HarmfulContentValidator -::: uipath.platform.guardrails.decorators.validators.prompt_injection - options: - members: - - PromptInjectionValidator - ::: uipath.platform.guardrails.decorators.validators.intellectual_property options: members: diff --git a/packages/uipath/mkdocs.yml b/packages/uipath/mkdocs.yml index abffe1161..5b6a2678a 100644 --- a/packages/uipath/mkdocs.yml +++ b/packages/uipath/mkdocs.yml @@ -6,6 +6,9 @@ repo_url: https://github.com/UiPath/uipath-python copyright: Copyright © 2025 UiPath +hooks: + - docs/langchain/hooks/enum_lists.py + extra_css: - stylesheets/extra.css @@ -101,6 +104,7 @@ nav: - Getting Started: langchain/quick_start.md - Chat Models: langchain/chat_models.md - Context Grounding: langchain/context_grounding.md + - Guardrails: langchain/guardrails.md - Human In The Loop: langchain/human_in_the_loop.md - Sample Agents: https://github.com/UiPath/uipath-langchain-python/tree/main/samples - UiPath LlamaIndex SDK: From f396de9fe1d2d6ab764ac2dcc169b88f2077281f Mon Sep 17 00:00:00 2001 From: Andrei Petraru Date: Fri, 22 May 2026 18:26:25 +0300 Subject: [PATCH 071/191] docs(guardrails): add value tables to enum docstrings, remove MkDocs hook (#1676) Co-authored-by: Claude Sonnet 4.6 --- packages/uipath-platform/pyproject.toml | 2 +- .../platform/guardrails/decorators/_enums.py | 38 +++++++++++++++++-- packages/uipath-platform/uv.lock | 4 +- packages/uipath/mkdocs.yml | 3 -- packages/uipath/uv.lock | 2 +- 5 files changed, 38 insertions(+), 11 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 9e82df730..cb9ca69b4 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.55" +version = "0.1.56" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_enums.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_enums.py index 49956f62f..c88df5acd 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_enums.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_enums.py @@ -19,8 +19,27 @@ class GuardrailExecutionStage(str, Enum): class PIIDetectionEntityType(str, Enum): """PII detection entity types supported by UiPath guardrails. - These entities match the available options from the UiPath guardrails service - backend. The enum values correspond to the exact strings expected by the API. + | Value | + |---| + | `PERSON` | + | `ADDRESS` | + | `DATE` | + | `PHONE_NUMBER` | + | `EUGPS_COORDINATES` | + | `EMAIL` | + | `CREDIT_CARD_NUMBER` | + | `INTERNATIONAL_BANKING_ACCOUNT_NUMBER` | + | `SWIFT_CODE` | + | `ABA_ROUTING_NUMBER` | + | `US_DRIVERS_LICENSE_NUMBER` | + | `UK_DRIVERS_LICENSE_NUMBER` | + | `US_INDIVIDUAL_TAXPAYER_IDENTIFICATION` | + | `UK_UNIQUE_TAXPAYER_NUMBER` | + | `US_BANK_ACCOUNT_NUMBER` | + | `US_SOCIAL_SECURITY_NUMBER` | + | `USUK_PASSPORT_NUMBER` | + | `URL` | + | `IP_ADDRESS` | """ PERSON = "Person" @@ -47,7 +66,12 @@ class PIIDetectionEntityType(str, Enum): class HarmfulContentEntityType(str, Enum): """Harmful content entity types supported by UiPath guardrails. - These entities correspond to the Azure Content Safety categories. + | Value | + |---| + | `HATE` | + | `SELF_HARM` | + | `SEXUAL` | + | `VIOLENCE` | """ HATE = "Hate" @@ -57,7 +81,13 @@ class HarmfulContentEntityType(str, Enum): class IntellectualPropertyEntityType(str, Enum): - """Intellectual property entity types supported by UiPath guardrails.""" + """Intellectual property entity types supported by UiPath guardrails. + + | Value | + |---| + | `TEXT` | + | `CODE` | + """ TEXT = "Text" CODE = "Code" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index ed99b6c36..53728076b 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-05-17T16:21:45.1868663Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.55" +version = "0.1.56" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/mkdocs.yml b/packages/uipath/mkdocs.yml index 5b6a2678a..de3b9a278 100644 --- a/packages/uipath/mkdocs.yml +++ b/packages/uipath/mkdocs.yml @@ -6,9 +6,6 @@ repo_url: https://github.com/UiPath/uipath-python copyright: Copyright © 2025 UiPath -hooks: - - docs/langchain/hooks/enum_lists.py - extra_css: - stylesheets/extra.css diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 72e9fde23..d29f9a146 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.55" +version = "0.1.56" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From b5d462ac384c8de8463a2c454d27e65caa494d78 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Fri, 22 May 2026 19:42:41 +0300 Subject: [PATCH 072/191] fix(uipath-core): accept minimal conversation message envelope on input (#1677) --- packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/chat/content.py | 13 ++-- .../src/uipath/core/chat/message.py | 17 ++++-- packages/uipath-core/tests/chat/__init__.py | 0 .../uipath-core/tests/chat/test_message.py | 59 +++++++++++++++++++ packages/uipath-core/uv.lock | 4 +- packages/uipath-platform/uv.lock | 4 +- packages/uipath/uv.lock | 4 +- 8 files changed, 86 insertions(+), 17 deletions(-) create mode 100644 packages/uipath-core/tests/chat/__init__.py create mode 100644 packages/uipath-core/tests/chat/test_message.py diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 959122a11..147726394 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.15" +version = "0.5.16" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/chat/content.py b/packages/uipath-core/src/uipath/core/chat/content.py index cc6300490..4ae8b169c 100644 --- a/packages/uipath-core/src/uipath/core/chat/content.py +++ b/packages/uipath-core/src/uipath/core/chat/content.py @@ -2,6 +2,7 @@ from __future__ import annotations +import uuid from typing import Any, Sequence from pydantic import BaseModel, ConfigDict, Field @@ -95,7 +96,7 @@ class UiPathConversationContentPartData(BaseModel): mime_type: str = Field(..., alias="mimeType") data: InlineOrExternal - citations: Sequence[UiPathConversationCitationData] + citations: Sequence[UiPathConversationCitationData] = Field(default_factory=list) is_transcript: bool | None = Field(None, alias="isTranscript") is_incomplete: bool | None = Field(None, alias="isIncomplete") name: str | None = None @@ -106,11 +107,13 @@ class UiPathConversationContentPartData(BaseModel): class UiPathConversationContentPart(UiPathConversationContentPartData): """Represents a single part of message content.""" - content_part_id: str = Field(..., alias="contentPartId") - created_at: str = Field(..., alias="createdAt") - updated_at: str = Field(..., alias="updatedAt") + content_part_id: str = Field( + default_factory=lambda: str(uuid.uuid4()), alias="contentPartId" + ) + created_at: str | None = Field(None, alias="createdAt") + updated_at: str | None = Field(None, alias="updatedAt") # Override to use full type - citations: Sequence[UiPathConversationCitation] + citations: Sequence[UiPathConversationCitation] = Field(default_factory=list) model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) diff --git a/packages/uipath-core/src/uipath/core/chat/message.py b/packages/uipath-core/src/uipath/core/chat/message.py index 9d6aa248d..37aa2bd76 100644 --- a/packages/uipath-core/src/uipath/core/chat/message.py +++ b/packages/uipath-core/src/uipath/core/chat/message.py @@ -1,5 +1,6 @@ """Message-level events.""" +import uuid from typing import Any, Sequence from pydantic import BaseModel, ConfigDict, Field @@ -61,7 +62,9 @@ class UiPathConversationMessageData(BaseModel): content_parts: Sequence[UiPathConversationContentPartData] = Field( ..., alias="contentParts" ) - tool_calls: Sequence[UiPathConversationToolCallData] = Field(..., alias="toolCalls") + tool_calls: Sequence[UiPathConversationToolCallData] = Field( + default_factory=list, alias="toolCalls" + ) model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) @@ -69,15 +72,19 @@ class UiPathConversationMessageData(BaseModel): class UiPathConversationMessage(UiPathConversationMessageData): """Represents a single message within an exchange.""" - message_id: str = Field(..., alias="messageId") - created_at: str = Field(..., alias="createdAt") - updated_at: str = Field(..., alias="updatedAt") + message_id: str = Field( + default_factory=lambda: str(uuid.uuid4()), alias="messageId" + ) + created_at: str | None = Field(None, alias="createdAt") + updated_at: str | None = Field(None, alias="updatedAt") span_id: str | None = Field(None, alias="spanId") # Overrides to use full types content_parts: Sequence[UiPathConversationContentPart] = Field( ..., alias="contentParts" ) - tool_calls: Sequence[UiPathConversationToolCall] = Field(..., alias="toolCalls") + tool_calls: Sequence[UiPathConversationToolCall] = Field( + default_factory=list, alias="toolCalls" + ) model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) diff --git a/packages/uipath-core/tests/chat/__init__.py b/packages/uipath-core/tests/chat/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/uipath-core/tests/chat/test_message.py b/packages/uipath-core/tests/chat/test_message.py new file mode 100644 index 000000000..113d34a66 --- /dev/null +++ b/packages/uipath-core/tests/chat/test_message.py @@ -0,0 +1,59 @@ +"""Tests for `UiPathConversationMessage` input validation. + +Conversational Agent Service contract treats `role` + `contentParts` as +the load-bearing fields for an inbound user message. `messageId` and +`contentPartId` are GUIDs that identify entities in the conversation +hierarchy; when omitted on input, the model fills them with fresh +UUIDs (matching what `uipath dev` does server-side). `createdAt`, +`updatedAt`, `spanId`, and `toolCalls` are server-allocated and absent +from client input. + +These tests pin that behavior so `--input-file` payloads from +`uip codedagent run` validate against the model without requiring +callers to hand-generate UUIDs. +""" + +from __future__ import annotations + +from uipath.core.chat import UiPathConversationMessage + + +def test_minimal_user_message_validates_and_fills_ids() -> None: + msg = UiPathConversationMessage.model_validate( + { + "role": "user", + "contentParts": [ + { + "mimeType": "text/plain", + "data": {"inline": "hello world"}, + } + ], + } + ) + assert msg.role == "user" + assert msg.tool_calls == [] + assert msg.created_at is None + assert msg.updated_at is None + assert msg.message_id # auto-generated UUID + assert msg.content_parts[0].content_part_id # auto-generated UUID + assert msg.content_parts[0].citations == [] + + +def test_explicit_ids_are_preserved() -> None: + msg = UiPathConversationMessage.model_validate( + { + "messageId": "00000000-0000-0000-0000-000000000001", + "role": "user", + "contentParts": [ + { + "contentPartId": "00000000-0000-0000-0000-000000000002", + "mimeType": "text/plain", + "data": {"inline": "hello world"}, + } + ], + } + ) + assert msg.message_id == "00000000-0000-0000-0000-000000000001" + assert ( + msg.content_parts[0].content_part_id == "00000000-0000-0000-0000-000000000002" + ) diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index ab6e6aa14..9b043599c 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-05-20T15:11:06.1716446Z" exclude-newer-span = "P2D" [[package]] @@ -1011,7 +1011,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.15" +version = "0.5.16" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 53728076b..c3b60638d 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-05-20T15:43:10.4544027Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1063,7 +1063,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.15" +version = "0.5.16" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index d29f9a146..449f50872 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-05-20T15:14:33.9075119Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2659,7 +2659,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.15" +version = "0.5.16" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, From 81338db66c5f4c6706195bc475699fcb77700f34 Mon Sep 17 00:00:00 2001 From: avichalsri24 Date: Mon, 25 May 2026 15:51:51 +0530 Subject: [PATCH 073/191] feat: expand Data Fabric entities service [DS-8360] (#1616) Co-authored-by: Claude Opus 4.7 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/entities/__init__.py | 44 +- .../platform/entities/_entities_service.py | 1948 ++++++++++++----- .../platform/entities/_entity_data_service.py | 1403 ++++++++++++ .../entities/_entity_schema_service.py | 522 +++++ .../src/uipath/platform/entities/entities.py | 456 +++- .../tests/services/test_entities_service.py | 1603 +++++++++++++- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 9 files changed, 5337 insertions(+), 645 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/entities/_entity_schema_service.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index cb9ca69b4..ba264c1ba 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.56" +version = "0.1.57" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/entities/__init__.py b/packages/uipath-platform/src/uipath/platform/entities/__init__.py index aca80997b..67572ec87 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/entities/__init__.py @@ -5,42 +5,78 @@ from ._entities_service import EntitiesService from .entities import ( + AggregateRow, ChoiceSetValue, DataFabricEntityItem, Entity, + EntityAggregate, + EntityAggregateFunction, + EntityBinning, + EntityCreateFieldOptions, + EntityCreateOptions, EntityField, + EntityFieldDataType, EntityFieldMetadata, + EntityImportRecordsResponse, + EntityJoin, + EntityMetadataUpdateOptions, + EntityQueryFilter, + EntityQueryFilterGroup, + EntityQuerySortOption, EntityRecord, EntityRecordsBatchResponse, + EntityRecordsListResponse, EntityRouting, EntitySetResolution, ExternalField, ExternalObject, ExternalSourceFields, + FailureRecord, FieldDataType, FieldMetadata, + LogicalOperator, + QueryFilterOperator, QueryRoutingOverrideContext, ReferenceType, + RetrieveEntityRecordsResponse, SourceJoinCriteria, ) __all__ = [ + "AggregateRow", "ChoiceSetValue", "DataFabricEntityItem", "EntitiesService", "Entity", + "EntityAggregate", + "EntityAggregateFunction", + "EntityBinning", + "EntityCreateFieldOptions", + "EntityCreateOptions", "EntityField", - "EntityRecord", + "EntityFieldDataType", "EntityFieldMetadata", + "EntityImportRecordsResponse", + "EntityJoin", + "EntityMetadataUpdateOptions", + "EntityQueryFilter", + "EntityQueryFilterGroup", + "EntityQuerySortOption", + "EntityRecord", + "EntityRecordsBatchResponse", + "EntityRecordsListResponse", "EntityRouting", "EntitySetResolution", - "FieldDataType", - "FieldMetadata", - "EntityRecordsBatchResponse", "ExternalField", "ExternalObject", "ExternalSourceFields", + "FailureRecord", + "FieldDataType", + "FieldMetadata", + "LogicalOperator", + "QueryFilterOperator", "QueryRoutingOverrideContext", "ReferenceType", + "RetrieveEntityRecordsResponse", "SourceJoinCriteria", ] diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py index 951a4b07b..fa3c0da9e 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py @@ -1,22 +1,32 @@ -import json as json_module +"""Public facade for the Data Fabric entities surface. + +:class:`EntitiesService` keeps the existing ``sdk.entities.*`` API flat and +unchanged from a caller's perspective while delegating each operation to the +appropriate underlying service: + +* :class:`EntitySchemaService` — entity definitions, choice set listings, + create / delete / update-metadata lifecycle. +* :class:`EntityDataService` — record CRUD (single and batch), structured + queries, attachments, choice-set values, bulk import, and federated SQL + queries. + +The facade additionally owns cross-cutting concerns such as agent entity-set +resolution. +""" + import logging from typing import Any, Dict, List, Optional, Type -import sqlparse from httpx import Response -from sqlparse.sql import Function, Identifier, IdentifierList, Parenthesis, Where -from sqlparse.tokens import DML, Keyword, Whitespace, Wildcard from uipath.core.tracing import traced from ..common._base_service import BaseService from ..common._bindings import _resource_overwrites from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext -from ..common._models import Endpoint, RequestSpec -from ..common.constants import HEADER_FOLDER_KEY from ..orchestrator._folder_service import FolderService +from ._entity_data_service import EntityDataService, FileContent from ._entity_resolution import ( - RoutingStrategy, build_resolution_service, create_resolution_plan, create_resolution_plan_async, @@ -24,46 +34,45 @@ fetch_resolved_entities, fetch_resolved_entities_async, ) +from ._entity_schema_service import EntitySchemaService from .entities import ( ChoiceSetValue, DataFabricEntityItem, Entity, + EntityAggregate, + EntityBinning, + EntityCreateFieldOptions, + EntityCreateOptions, + EntityImportRecordsResponse, + EntityJoin, + EntityMetadataUpdateOptions, + EntityQueryFilterGroup, + EntityQuerySortOption, EntityRecord, EntityRecordsBatchResponse, + EntityRecordsListResponse, EntitySetResolution, QueryRoutingOverrideContext, + RetrieveEntityRecordsResponse, ) logger = logging.getLogger(__name__) -_FORBIDDEN_DML = {"INSERT", "UPDATE", "DELETE", "MERGE", "REPLACE"} -_FORBIDDEN_DDL = {"DROP", "ALTER", "CREATE", "TRUNCATE"} -_DISALLOWED_KEYWORDS = [ - "WITH", - "UNION", - "INTERSECT", - "EXCEPT", - "OVER", - "ROLLUP", - "CUBE", - "GROUPING", - "PARTITION", -] -_AGGREGATE_FUNCTIONS = ("COUNT", "SUM", "AVG", "MIN", "MAX") - class EntitiesService(BaseService): """Service for managing UiPath Data Service entities. - Entities are database tables in UiPath Data Service that can store - structured data for automation processes. + Entities are database tables in UiPath Data Service that store structured + data for automation processes. This service is the unified entry point for + every entity operation: schema management, record CRUD, structured and + SQL queries, file attachments, choice sets, and bulk import. See Also: https://docs.uipath.com/data-service/automation-cloud/latest/user-guide/introduction !!! warning "Preview Feature" - This function is currently experimental. - Behavior and parameters are subject to change in future versions. + This service is currently experimental. Behavior and parameters are + subject to change in future versions. """ def __init__( @@ -75,14 +84,30 @@ def __init__( entity_name_overrides: Optional[Dict[str, str]] = None, routing_context: Optional[QueryRoutingOverrideContext] = None, ) -> None: + """Initialise the facade and its underlying schema and data services.""" super().__init__(config=config, execution_context=execution_context) self._folders_service = folders_service - self._routing_strategy: RoutingStrategy = create_routing_strategy( + self._routing_strategy = create_routing_strategy( folders_map=folders_map, effective_entity_names=entity_name_overrides, routing_context=routing_context, folders_service=folders_service, ) + self._schema = EntitySchemaService( + config=config, + execution_context=execution_context, + folders_service=folders_service, + ) + self._data = EntityDataService( + config=config, + execution_context=execution_context, + folders_service=folders_service, + routing_strategy=self._routing_strategy, + ) + + # ------------------------------------------------------------------ + # Schema operations — delegate to EntitySchemaService + # ------------------------------------------------------------------ @traced(name="entity_retrieve", run_type="uipath") def retrieve(self, entity_key: str) -> Entity: @@ -117,10 +142,7 @@ def retrieve(self, entity_key: str) -> Entity: print(f" Required: {field.is_required}") print(f" Primary Key: {field.is_primary_key}") """ - spec = self._retrieve_spec(entity_key) - response = self.request(spec.method, spec.endpoint) - - return Entity.model_validate(response.json()) + return self._schema.retrieve(entity_key) @traced(name="entity_retrieve", run_type="uipath") async def retrieve_async(self, entity_key: str) -> Entity: @@ -155,11 +177,7 @@ async def retrieve_async(self, entity_key: str) -> Entity: print(f" Required: {field.is_required}") print(f" Primary Key: {field.is_primary_key}") """ - spec = self._retrieve_spec(entity_key) - - response = await self.request_async(spec.method, spec.endpoint) - - return Entity.model_validate(response.json()) + return await self._schema.retrieve_async(entity_key) @traced(name="entity_retrieve_by_name", run_type="uipath") def retrieve_by_name( @@ -175,11 +193,7 @@ def retrieve_by_name( entity_name: The name of the entity. folder_key: Optional folder key for disambiguation. """ - spec = self._retrieve_by_name_spec(entity_name) - headers = self._folder_key_headers(folder_key) - response = self.request(spec.method, spec.endpoint, headers=headers) - - return Entity.model_validate(response.json()) + return self._schema.retrieve_by_name(entity_name, folder_key=folder_key) @traced(name="entity_retrieve_by_name", run_type="uipath") async def retrieve_by_name_async( @@ -195,11 +209,9 @@ async def retrieve_by_name_async( entity_name: The name of the entity. folder_key: Optional folder key for disambiguation. """ - spec = self._retrieve_by_name_spec(entity_name) - headers = self._folder_key_headers(folder_key) - response = await self.request_async(spec.method, spec.endpoint, headers=headers) - - return Entity.model_validate(response.json()) + return await self._schema.retrieve_by_name_async( + entity_name, folder_key=folder_key + ) @traced(name="list_entities", run_type="uipath") def list_entities(self) -> List[Entity]: @@ -238,11 +250,7 @@ def list_entities(self) -> List[Entity]: print(f"Total records: {total_records}") print(f"Total storage: {total_storage:.2f} MB") """ - spec = self._list_entities_spec() - response = self.request(spec.method, spec.endpoint) - - entities_data = response.json() - return [Entity.model_validate(entity) for entity in entities_data] + return self._schema.list_entities() @traced(name="list_entities", run_type="uipath") async def list_entities_async(self) -> List[Entity]: @@ -281,11 +289,7 @@ async def list_entities_async(self) -> List[Entity]: print(f"Total records: {total_records}") print(f"Total storage: {total_storage:.2f} MB") """ - spec = self._list_entities_spec() - response = await self.request_async(spec.method, spec.endpoint) - - entities_data = response.json() - return [Entity.model_validate(entity) for entity in entities_data] + return await self._schema.list_entities_async() @traced(name="list_choicesets", run_type="uipath") def list_choicesets(self) -> List[Entity]: @@ -301,9 +305,7 @@ def list_choicesets(self) -> List[Entity]: for cs in choicesets: print(f"{cs.display_name} ({cs.id})") """ - spec = self._list_choicesets_spec() - response = self.request(spec.method, spec.endpoint) - return [Entity.model_validate(item) for item in response.json()] + return self._schema.list_choicesets() @traced(name="list_choicesets", run_type="uipath") async def list_choicesets_async(self) -> List[Entity]: @@ -312,16 +314,215 @@ async def list_choicesets_async(self) -> List[Entity]: Returns: List[Entity]: A list of all choice set entities. """ - spec = self._list_choicesets_spec() - response = await self.request_async(spec.method, spec.endpoint) - return [Entity.model_validate(item) for item in response.json()] + return await self._schema.list_choicesets_async() + + @traced(name="entity_create", run_type="uipath") + def create_entity( + self, + name: str, + fields: List[EntityCreateFieldOptions], + options: Optional[EntityCreateOptions] = None, + ) -> str: + """Create a new entity with the given schema and return its id. + + Args: + name (str): Entity name. Must start with a letter and contain + only letters, digits, and underscores (3-100 characters). + fields (List[EntityCreateFieldOptions]): Field definitions for + the new entity. Each entry declares the field's name, type, + and optional constraints such as ``length_limit``, + ``decimal_precision``, ``is_required``, ``is_unique``, etc. + options (Optional[EntityCreateOptions]): Optional entity-level + settings such as display name, description, folder + placement, and RBAC / analytics flags. + + Returns: + str: The id (UUID) of the newly created entity. + + Raises: + ValueError: If the entity name or any field name fails the + client-side validation (regex / length / reserved names) or + if a per-field constraint is not supported for that field + type or is out of range. + + Examples: + Create a simple entity:: + + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityCreateOptions, + EntityFieldDataType, + ) + + entity_id = entities_service.create_entity( + "ProductCatalog", + [ + EntityCreateFieldOptions( + field_name="product_name", + type=EntityFieldDataType.STRING, + is_required=True, + is_unique=True, + ), + EntityCreateFieldOptions( + field_name="price", + type=EntityFieldDataType.DECIMAL, + decimal_precision=2, + ), + ], + options=EntityCreateOptions( + display_name="Product Catalog", + description="Inventory of available products", + is_rbac_enabled=True, + ), + ) + """ + return self._schema.create_entity(name, fields, options) + + @traced(name="entity_create", run_type="uipath") + async def create_entity_async( + self, + name: str, + fields: List[EntityCreateFieldOptions], + options: Optional[EntityCreateOptions] = None, + ) -> str: + """Asynchronously create a new entity with the given schema. + + Args: + name (str): Entity name; same validation rules as :meth:`create_entity`. + fields (List[EntityCreateFieldOptions]): Field definitions. + options (Optional[EntityCreateOptions]): Optional entity-level settings. + + Returns: + str: The id (UUID) of the newly created entity. + + Raises: + ValueError: For client-side validation failures. + + Examples: + Create a simple entity:: + + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + entity_id = await entities_service.create_entity_async( + "ProductCatalog", + [ + EntityCreateFieldOptions( + field_name="product_name", + type=EntityFieldDataType.STRING, + is_required=True, + ), + ], + ) + """ + return await self._schema.create_entity_async(name, fields, options) + + @traced(name="entity_delete", run_type="uipath") + def delete_entity(self, entity_id: str) -> None: + """Delete an entity and all of its records. + + Args: + entity_id (str): The unique identifier of the entity to delete. + + Examples: + Delete an entity by id:: + + entities_service.delete_entity("a1b2c3d4-...") + """ + self._schema.delete_entity(entity_id) + + @traced(name="entity_delete", run_type="uipath") + async def delete_entity_async(self, entity_id: str) -> None: + """Asynchronously delete an entity and all of its records. + + Args: + entity_id (str): The unique identifier of the entity to delete. + + Examples: + Delete an entity by id:: + + await entities_service.delete_entity_async("a1b2c3d4-...") + """ + await self._schema.delete_entity_async(entity_id) + + @traced(name="entity_update_metadata", run_type="uipath") + def update_entity_metadata( + self, + entity_id: str, + metadata: EntityMetadataUpdateOptions | Dict[str, Any], + ) -> None: + """Update an entity's display name, description, and/or RBAC flag. + + Args: + entity_id (str): The unique identifier of the entity. + metadata (EntityMetadataUpdateOptions | Dict[str, Any]): + An :class:`EntityMetadataUpdateOptions` instance or a dict + with any of ``display_name``, ``description``, + ``is_rbac_enabled``. Dict keys may be snake_case + (``display_name``) or camelCase (``displayName``); both + serialize correctly to the API. + + Examples: + Rename and update description:: + + from uipath.platform.entities import EntityMetadataUpdateOptions + + entities_service.update_entity_metadata( + "a1b2c3d4-...", + EntityMetadataUpdateOptions( + display_name="New Display Name", + description="Refreshed description", + ), + ) + + From a plain dict:: + + entities_service.update_entity_metadata( + "a1b2c3d4-...", + {"display_name": "X", "is_rbac_enabled": True}, + ) + """ + self._schema.update_entity_metadata(entity_id, metadata) + + @traced(name="entity_update_metadata", run_type="uipath") + async def update_entity_metadata_async( + self, + entity_id: str, + metadata: EntityMetadataUpdateOptions | Dict[str, Any], + ) -> None: + """Asynchronously update an entity's display name, description, and/or RBAC flag. + + Args: + entity_id (str): The unique identifier of the entity. + metadata (EntityMetadataUpdateOptions | Dict[str, Any]): + An :class:`EntityMetadataUpdateOptions` instance or a dict + with any of ``display_name``, ``description``, + ``is_rbac_enabled``. + + Examples: + Rename:: + + from uipath.platform.entities import EntityMetadataUpdateOptions + + await entities_service.update_entity_metadata_async( + "a1b2c3d4-...", + EntityMetadataUpdateOptions(display_name="Renamed Entity"), + ) + """ + await self._schema.update_entity_metadata_async(entity_id, metadata) + + # ------------------------------------------------------------------ + # Data operations — delegate to EntityDataService + # ------------------------------------------------------------------ @traced(name="get_choiceset_values", run_type="uipath") def get_choiceset_values( self, choiceset_id: str, - start: int | None = None, - limit: int | None = None, + start: Optional[int] = None, + limit: Optional[int] = None, ) -> List[ChoiceSetValue]: """Get the values of a choice set by its ID. @@ -341,23 +542,14 @@ def get_choiceset_values( for v in values: print(f"{v.number_id}: {v.display_name}") """ - spec = self._get_choiceset_values_spec(choiceset_id, start=start, limit=limit) - response = self.request( - spec.method, spec.endpoint, params=spec.params, json=spec.json - ) - data = response.json() - raw_values = data.get("jsonValue", "[]") - items = ( - json_module.loads(raw_values) if isinstance(raw_values, str) else raw_values - ) - return [ChoiceSetValue.model_validate(item) for item in items] + return self._data.get_choiceset_values(choiceset_id, start=start, limit=limit) @traced(name="get_choiceset_values", run_type="uipath") async def get_choiceset_values_async( self, choiceset_id: str, - start: int | None = None, - limit: int | None = None, + start: Optional[int] = None, + limit: Optional[int] = None, ) -> List[ChoiceSetValue]: """Asynchronously get the values of a choice set by its ID. @@ -369,25 +561,23 @@ async def get_choiceset_values_async( Returns: List[ChoiceSetValue]: The values in the choice set. """ - spec = self._get_choiceset_values_spec(choiceset_id, start=start, limit=limit) - response = await self.request_async( - spec.method, spec.endpoint, params=spec.params, json=spec.json + return await self._data.get_choiceset_values_async( + choiceset_id, start=start, limit=limit ) - data = response.json() - raw_values = data.get("jsonValue", "[]") - items = ( - json_module.loads(raw_values) if isinstance(raw_values, str) else raw_values - ) - return [ChoiceSetValue.model_validate(item) for item in items] @traced(name="entity_list_records", run_type="uipath") def list_records( self, entity_key: str, - schema: Optional[Type[Any]] = None, # Optional schema + schema: Optional[Type[Any]] = None, start: Optional[int] = None, limit: Optional[int] = None, - ) -> List[EntityRecord]: + expansion_level: Optional[int] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + select: Optional[List[str]] = None, + expand: Optional[List[str]] = None, + ) -> EntityRecordsListResponse: """List records from an entity with optional pagination and schema validation. The schema parameter enables type-safe access to entity records by validating the @@ -424,11 +614,23 @@ class CustomerRecord: start (Optional[int]): Starting index for pagination (0-based). limit (Optional[int]): Maximum number of records to return. + expansion_level (Optional[int]): Depth of foreign-key expansion in the + response (``0`` means no expansion). Higher values inline related + records up to that many hops. + filter (Optional[str]): OData ``$filter`` expression + (e.g. ``"status eq 'active'"``). + orderby (Optional[str]): OData ``$orderby`` expression + (e.g. ``"created_at desc"``). + select (Optional[List[str]]): Column projection — field names to + include (rendered as ``$select``). + expand (Optional[List[str]]): Relationship names to expand inline + (rendered as ``$expand``). Returns: - List[EntityRecord]: A list of entity records. Each record contains an 'id' field - and all other fields from the entity. Fields can be accessed as attributes - or dictionary keys on the EntityRecord object. + EntityRecordsListResponse: A list-compatible response with + ``total_count``, ``has_next_page`` and ``next_cursor`` pagination + metadata. Iteration, indexing, and ``len()`` continue to work + like a plain list of :class:`EntityRecord`. Raises: ValueError: If schema validation fails for any record, including cases where @@ -446,6 +648,22 @@ class CustomerRecord: # Get first 50 records records = entities_service.list_records("Customers", start=0, limit=50) + print(f"Showing {len(records)} of {records.total_count} total") + if records.has_next_page: + next_page = entities_service.list_records( + "Customers", start=50, limit=50 + ) + + With OData filter, sorting, projection, and expansion:: + + records = entities_service.list_records( + "Customers", + filter="status eq 'active'", + orderby="created_at desc", + select=["name", "email", "status"], + expand=["company"], + expansion_level=1, + ) With schema validation:: @@ -465,28 +683,31 @@ class CustomerRecord: for record in records: print(f"{record.name}: {record.email}") """ - # Example method to generate the API request specification (mocked here) - spec = self._list_records_spec(entity_key, start, limit) - - # Make the HTTP request (assumes self.request exists) - response = self.request(spec.method, spec.endpoint, params=spec.params) - - # Parse the response JSON and extract the "value" field - records_data = response.json().get("value", []) - - # Validate and wrap records - return [ - EntityRecord.from_data(data=record, model=schema) for record in records_data - ] + return self._data.list_records( + entity_key, + schema=schema, + start=start, + limit=limit, + expansion_level=expansion_level, + filter=filter, + orderby=orderby, + select=select, + expand=expand, + ) @traced(name="entity_list_records", run_type="uipath") async def list_records_async( self, entity_key: str, - schema: Optional[Type[Any]] = None, # Optional schema + schema: Optional[Type[Any]] = None, start: Optional[int] = None, limit: Optional[int] = None, - ) -> List[EntityRecord]: + expansion_level: Optional[int] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + select: Optional[List[str]] = None, + expand: Optional[List[str]] = None, + ) -> EntityRecordsListResponse: """Asynchronously list records from an entity with optional pagination and schema validation. The schema parameter enables type-safe access to entity records by validating the @@ -523,11 +744,23 @@ class CustomerRecord: start (Optional[int]): Starting index for pagination (0-based). limit (Optional[int]): Maximum number of records to return. + expansion_level (Optional[int]): Depth of foreign-key expansion in the + response (``0`` means no expansion). Higher values inline related + records up to that many hops. + filter (Optional[str]): OData ``$filter`` expression + (e.g. ``"status eq 'active'"``). + orderby (Optional[str]): OData ``$orderby`` expression + (e.g. ``"created_at desc"``). + select (Optional[List[str]]): Column projection — field names to + include (rendered as ``$select``). + expand (Optional[List[str]]): Relationship names to expand inline + (rendered as ``$expand``). Returns: - List[EntityRecord]: A list of entity records. Each record contains an 'id' field - and all other fields from the entity. Fields can be accessed as attributes - or dictionary keys on the EntityRecord object. + EntityRecordsListResponse: A list-compatible response with + ``total_count``, ``has_next_page`` and ``next_cursor`` pagination + metadata. Iteration, indexing, and ``len()`` continue to work + like a plain list of :class:`EntityRecord`. Raises: ValueError: If schema validation fails for any record, including cases where @@ -545,6 +778,22 @@ class CustomerRecord: # Get first 50 records records = await entities_service.list_records_async("Customers", start=0, limit=50) + print(f"Showing {len(records)} of {records.total_count} total") + if records.has_next_page: + next_page = await entities_service.list_records_async( + "Customers", start=50, limit=50 + ) + + With OData filter, sorting, projection, and expansion:: + + records = await entities_service.list_records_async( + "Customers", + filter="status eq 'active'", + orderby="created_at desc", + select=["name", "email", "status"], + expand=["company"], + expansion_level=1, + ) With schema validation:: @@ -564,193 +813,330 @@ class CustomerRecord: for record in records: print(f"{record.name}: {record.email}") """ - spec = self._list_records_spec(entity_key, start, limit) - - # Make the HTTP request (assumes self.request exists) - response = await self.request_async( - spec.method, spec.endpoint, params=spec.params + return await self._data.list_records_async( + entity_key, + schema=schema, + start=start, + limit=limit, + expansion_level=expansion_level, + filter=filter, + orderby=orderby, + select=select, + expand=expand, ) - # Parse the response JSON and extract the "value" field - records_data = response.json().get("value", []) - - # Validate and wrap records - return [ - EntityRecord.from_data(data=record, model=schema) for record in records_data - ] - - @traced(name="entity_query_records", run_type="uipath") - def query_entity_records( + @traced(name="entity_insert_record", run_type="uipath") + def insert_record( self, - sql_query: str, - ) -> List[Dict[str, Any]]: - """Query entity records using a validated SQL query. + entity_key: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Insert a single record into an entity and return the inserted row. - PREVIEW: This method is in preview and may change in future releases. + Note: + Unlike :meth:`insert_records` (batch), this single-record endpoint + fires Data Fabric trigger events. Use this method when triggers + attached to the entity must run. Args: - sql_query (str): A SQL SELECT query to execute against Data Service entities. - Only SELECT statements are allowed. Queries without WHERE must include - a LIMIT clause. Subqueries and multi-statement queries are not permitted. - - Notes: - A routing context is always derived from the configured ``folders_map`` - when present and included in the request body. + entity_key (str): The unique key/identifier of the entity. + data (Any): Record payload — a dict, a Pydantic model, an + :class:`EntityRecord`, or any object exposing ``__dict__``. + expansion_level (Optional[int]): Depth of foreign-key expansion in + the response (``0`` means no expansion). Returns: - List[Dict[str, Any]]: A list of result records as dictionaries. + EntityRecord: The inserted record with its server-assigned ``Id`` + plus any expanded relationships. - Raises: - ValueError: If the SQL query fails validation (e.g., non-SELECT, missing - WHERE/LIMIT, forbidden keywords, subqueries). + Examples: + Insert from a dict:: + + record = entities_service.insert_record( + "Customers", + {"name": "Alice", "email": "alice@example.com"}, + ) + print(record.id) + + Insert from a Pydantic model:: + + class CustomerInput(BaseModel): + name: str + email: str + + record = entities_service.insert_record( + "Customers", + CustomerInput(name="Bob", email="bob@example.com"), + expansion_level=1, + ) """ - return self._query_entities_for_records(sql_query) + return self._data.insert_record( + entity_key, data, expansion_level=expansion_level + ) - @traced(name="entity_query_records", run_type="uipath") - async def query_entity_records_async( + @traced(name="entity_insert_record", run_type="uipath") + async def insert_record_async( self, - sql_query: str, - ) -> List[Dict[str, Any]]: - """Asynchronously query entity records using a validated SQL query. + entity_key: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Asynchronously insert a single record into an entity. - PREVIEW: This method is in preview and may change in future releases. + Note: + Unlike :meth:`insert_records_async` (batch), this single-record + endpoint fires Data Fabric trigger events. Use this method when + triggers attached to the entity must run. Args: - sql_query (str): A SQL SELECT query to execute against Data Service entities. - Only SELECT statements are allowed. Queries without WHERE must include - a LIMIT clause. Subqueries and multi-statement queries are not permitted. - - Notes: - A routing context is always derived from the configured ``folders_map`` - when present and included in the request body. + entity_key (str): The unique key/identifier of the entity. + data (Any): Record payload — a dict, a Pydantic model, an + :class:`EntityRecord`, or any object exposing ``__dict__``. + expansion_level (Optional[int]): Depth of foreign-key expansion in + the response (``0`` means no expansion). Returns: - List[Dict[str, Any]]: A list of result records as dictionaries. + EntityRecord: The inserted record with its server-assigned ``Id``. - Raises: - ValueError: If the SQL query fails validation (e.g., non-SELECT, missing - WHERE/LIMIT, forbidden keywords, subqueries). - """ - return await self._query_entities_for_records_async(sql_query) + Examples: + Insert from a dict:: - @traced(name="resolve_entity_set", run_type="uipath") - def resolve_entity_set( - self, - items: list[DataFabricEntityItem], - ) -> EntitySetResolution: - """Resolve an agent entity set, applying resource overwrites.""" - plan = create_resolution_plan( - items, - _resource_overwrites.get() or {}, - lambda folder_path: ( - self._folders_service.retrieve_key(folder_path=folder_path) - if self._folders_service is not None - else None - ), - ) - entities = fetch_resolved_entities( - plan, - self.retrieve, - self.retrieve_by_name, - logger, - ) - resolution_service: EntitiesService = build_resolution_service( # type: ignore[assignment] - config=self._config, - execution_context=self._execution_context, - folders_service=self._folders_service, - plan=plan, - service_factory=EntitiesService, - ) - return EntitySetResolution( - entities=entities, - entities_service=resolution_service, + record = await entities_service.insert_record_async( + "Customers", + {"name": "Alice", "email": "alice@example.com"}, + ) + print(record.id) + """ + return await self._data.insert_record_async( + entity_key, data, expansion_level=expansion_level ) - @traced(name="resolve_entity_set", run_type="uipath") - async def resolve_entity_set_async( + @traced(name="entity_get_record", run_type="uipath") + def get_record( self, - items: list[DataFabricEntityItem], - ) -> EntitySetResolution: - """Resolve an agent entity set, applying resource overwrites.""" + entity_key: str, + record_id: str, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Fetch a single entity record by its id. - async def _resolve_folder_path(folder_path: str) -> Optional[str]: - if self._folders_service is None: - return None - return await self._folders_service.retrieve_key_async( - folder_path=folder_path - ) + Args: + entity_key (str): The unique key/identifier of the entity. + record_id (str): The unique identifier of the record to fetch. + expansion_level (Optional[int]): Depth of foreign-key expansion in + the response (``0`` means no expansion). - plan = await create_resolution_plan_async( - items, - _resource_overwrites.get() or {}, - _resolve_folder_path, - ) - entities = await fetch_resolved_entities_async( - plan, - self.retrieve_async, - self.retrieve_by_name_async, - logger, - ) - resolution_service: EntitiesService = build_resolution_service( # type: ignore[assignment] - config=self._config, - execution_context=self._execution_context, - folders_service=self._folders_service, - plan=plan, - service_factory=EntitiesService, - ) - return EntitySetResolution( - entities=entities, - entities_service=resolution_service, - ) + Returns: + EntityRecord: The record, with optional expanded relationships. - def _query_entities_for_records( - self, - sql_query: str, - ) -> List[Dict[str, Any]]: - self._validate_sql_query(sql_query) - routing_context = self._routing_strategy.resolve() - spec = self._query_entity_records_spec(sql_query, routing_context) - response = self.request(spec.method, spec.endpoint, json=spec.json) - return response.json().get("results", []) - - async def _query_entities_for_records_async( - self, - sql_query: str, - ) -> List[Dict[str, Any]]: - self._validate_sql_query(sql_query) - routing_context = await self._routing_strategy.resolve_async() - spec = self._query_entity_records_spec(sql_query, routing_context) - response = await self.request_async(spec.method, spec.endpoint, json=spec.json) - return response.json().get("results", []) + Examples: + Basic usage:: - @traced(name="entity_record_insert_batch", run_type="uipath") - def insert_records( + record = entities_service.get_record("Customers", "rec-1") + print(record.id, record.name) + + With FK expansion:: + + # Inline the related Company record on the returned Customer + record = entities_service.get_record( + "Customers", "rec-1", expansion_level=1 + ) + """ + return self._data.get_record( + entity_key, record_id, expansion_level=expansion_level + ) + + @traced(name="entity_get_record", run_type="uipath") + async def get_record_async( self, entity_key: str, - records: List[Any], - schema: Optional[Type[Any]] = None, - ) -> EntityRecordsBatchResponse: - """Insert multiple records into an entity in a single batch operation. + record_id: str, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Asynchronously fetch a single entity record by its id. Args: entity_key (str): The unique key/identifier of the entity. - records (List[Any]): List of records to insert. Each record should be an object - with attributes matching the entity's field names. - schema (Optional[Type[Any]]): Optional schema class for validation. When provided, - validates that each record in the response matches the schema structure. + record_id (str): The unique identifier of the record to fetch. + expansion_level (Optional[int]): Depth of foreign-key expansion in + the response (``0`` means no expansion). Returns: - EntityRecordsBatchResponse: Response containing successful and failed record operations. - - success_records: List of successfully inserted EntityRecord objects - - failure_records: List of EntityRecord objects that failed to insert + EntityRecord: The record. Examples: - Insert records without schema:: + Basic usage:: - class Customer: - def __init__(self, name, email, age): - self.name = name - self.email = email + record = await entities_service.get_record_async("Customers", "rec-1") + print(record.id, record.name) + """ + return await self._data.get_record_async( + entity_key, record_id, expansion_level=expansion_level + ) + + @traced(name="entity_update_record", run_type="uipath") + def update_record( + self, + entity_key: str, + record_id: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Update a single record by id and return the updated row. + + Note: + Unlike :meth:`update_records` (batch), this single-record endpoint + fires Data Fabric trigger events. Use this method when triggers + attached to the entity must run. + + Args: + entity_key (str): The unique key/identifier of the entity. + record_id (str): The unique identifier of the record to update. + data (Any): Fields to update — a dict, a Pydantic model, or any + object exposing ``__dict__``. Fields explicitly set to + ``None`` are sent through; unset fields are omitted. + expansion_level (Optional[int]): Depth of foreign-key expansion in + the response (``0`` means no expansion). + + Returns: + EntityRecord: The updated record. + + Examples: + Partial update from a dict:: + + record = entities_service.update_record( + "Customers", + "rec-1", + {"email": "alice.new@example.com"}, + ) + + Clear a field by passing an explicit ``None``:: + + # Note: unset fields are omitted; explicit None values are sent. + record = entities_service.update_record( + "Customers", + "rec-1", + {"middle_name": None}, + ) + """ + return self._data.update_record( + entity_key, record_id, data, expansion_level=expansion_level + ) + + @traced(name="entity_update_record", run_type="uipath") + async def update_record_async( + self, + entity_key: str, + record_id: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Asynchronously update a single record by id. + + Note: + Unlike :meth:`update_records_async` (batch), this single-record + endpoint fires Data Fabric trigger events. + + Args: + entity_key (str): The unique key/identifier of the entity. + record_id (str): The unique identifier of the record to update. + data (Any): Fields to update — a dict, a Pydantic model, or any + object exposing ``__dict__``. + expansion_level (Optional[int]): Depth of foreign-key expansion. + + Returns: + EntityRecord: The updated record. + + Examples: + Partial update:: + + record = await entities_service.update_record_async( + "Customers", + "rec-1", + {"email": "alice.new@example.com"}, + ) + """ + return await self._data.update_record_async( + entity_key, record_id, data, expansion_level=expansion_level + ) + + @traced(name="entity_delete_record", run_type="uipath") + def delete_record(self, entity_key: str, record_id: str) -> None: + """Delete a single record by id. + + Note: + Unlike :meth:`delete_records` (batch), this single-record endpoint + fires Data Fabric trigger events. Use this method when triggers + attached to the entity must run on delete. + + Args: + entity_key (str): The unique key/identifier of the entity. + record_id (str): The unique identifier of the record to delete. + + Examples: + Delete by id:: + + entities_service.delete_record("Customers", "rec-1") + """ + self._data.delete_record(entity_key, record_id) + + @traced(name="entity_delete_record", run_type="uipath") + async def delete_record_async(self, entity_key: str, record_id: str) -> None: + """Asynchronously delete a single record by id. + + Note: + Unlike :meth:`delete_records_async` (batch), this single-record + endpoint fires Data Fabric trigger events. + + Args: + entity_key (str): The unique key/identifier of the entity. + record_id (str): The unique identifier of the record to delete. + + Examples: + Delete by id:: + + await entities_service.delete_record_async("Customers", "rec-1") + """ + await self._data.delete_record_async(entity_key, record_id) + + @traced(name="entity_record_insert_batch", run_type="uipath") + def insert_records( + self, + entity_key: str, + records: List[Any], + schema: Optional[Type[Any]] = None, + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> EntityRecordsBatchResponse: + """Insert multiple records into an entity in a single batch operation. + + Args: + entity_key (str): The unique key/identifier of the entity. + records (List[Any]): List of records to insert. Each record may be + a dict, a Pydantic model, an :class:`EntityRecord`, or any + object exposing ``__dict__``. + schema (Optional[Type[Any]]): Optional schema class for validation. When provided, + validates that each record in the response matches the schema structure. + expansion_level (Optional[int]): Depth of foreign-key expansion in + the response (``0`` means no expansion). + fail_on_first (Optional[bool]): When ``True``, stop the batch on + the first per-record failure. When ``False`` (default), all + records are attempted and the response lists both + ``success_records`` and ``failure_records``. + + Returns: + EntityRecordsBatchResponse: Response containing successful and failed record operations. + - success_records: List of successfully inserted :class:`EntityRecord` objects + - failure_records: List of :class:`FailureRecord` describing per-record errors + + Examples: + Insert records without schema:: + + class Customer: + def __init__(self, name, email, age): + self.name = name + self.email = email self.age = age customers = [ @@ -766,6 +1152,15 @@ def __init__(self, name, email, age): print(f"Inserted: {len(response.success_records)}") print(f"Failed: {len(response.failure_records)}") + Insert with FK expansion and fail-fast:: + + response = entities_service.insert_records( + "Orders", + [{"product_id": "p-1", "qty": 3}, {"product_id": "p-2", "qty": 1}], + expansion_level=1, # inline the related Product on each response record + fail_on_first=True, # abort the batch at the first error + ) + Insert with schema validation:: class CustomerSchema: @@ -791,10 +1186,13 @@ def __init__(self, name, email, age): for record in response.success_records: print(f"Inserted: {record.name} (ID: {record.id})") """ - spec = self._insert_batch_spec(entity_key, records) - response = self.request(spec.method, spec.endpoint, json=spec.json) - - return self.validate_entity_batch(response, schema) + return self._data.insert_records( + entity_key, + records, + schema=schema, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + ) @traced(name="entity_record_insert_batch", run_type="uipath") async def insert_records_async( @@ -802,20 +1200,29 @@ async def insert_records_async( entity_key: str, records: List[Any], schema: Optional[Type[Any]] = None, + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, ) -> EntityRecordsBatchResponse: """Asynchronously insert multiple records into an entity in a single batch operation. Args: entity_key (str): The unique key/identifier of the entity. - records (List[Any]): List of records to insert. Each record should be an object - with attributes matching the entity's field names. + records (List[Any]): List of records to insert. Each record may be + a dict, a Pydantic model, an :class:`EntityRecord`, or any + object exposing ``__dict__``. schema (Optional[Type[Any]]): Optional schema class for validation. When provided, validates that each record in the response matches the schema structure. + expansion_level (Optional[int]): Depth of foreign-key expansion in + the response (``0`` means no expansion). + fail_on_first (Optional[bool]): When ``True``, stop the batch on + the first per-record failure. When ``False`` (default), all + records are attempted and the response lists both + ``success_records`` and ``failure_records``. Returns: EntityRecordsBatchResponse: Response containing successful and failed record operations. - - success_records: List of successfully inserted EntityRecord objects - - failure_records: List of EntityRecord objects that failed to insert + - success_records: List of successfully inserted :class:`EntityRecord` objects + - failure_records: List of :class:`FailureRecord` describing per-record errors Examples: Insert records without schema:: @@ -864,10 +1271,13 @@ def __init__(self, name, email, age): for record in response.success_records: print(f"Inserted: {record.name} (ID: {record.id})") """ - spec = self._insert_batch_spec(entity_key, records) - response = await self.request_async(spec.method, spec.endpoint, json=spec.json) - - return self.validate_entity_batch(response, schema) + return await self._data.insert_records_async( + entity_key, + records, + schema=schema, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + ) @traced(name="entity_record_update_batch", run_type="uipath") def update_records( @@ -875,20 +1285,30 @@ def update_records( entity_key: str, records: List[Any], schema: Optional[Type[Any]] = None, + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, ) -> EntityRecordsBatchResponse: """Update multiple records in an entity in a single batch operation. Args: entity_key (str): The unique key/identifier of the entity. - records (List[Any]): List of records to update. Each record must have an 'Id' field - and should be a Pydantic model with `model_dump()` method or similar object. + records (List[Any]): List of records to update. Each record must + include its ``Id`` field. A record may be a dict, a Pydantic + model, an :class:`EntityRecord`, or any object exposing + ``__dict__``. schema (Optional[Type[Any]]): Optional schema class for validation. When provided, validates that each record in the request and response matches the schema structure. + expansion_level (Optional[int]): Depth of foreign-key expansion in + the response (``0`` means no expansion). + fail_on_first (Optional[bool]): When ``True``, stop the batch on + the first per-record failure. When ``False`` (default), all + records are attempted and the response lists both + ``success_records`` and ``failure_records``. Returns: EntityRecordsBatchResponse: Response containing successful and failed record operations. - - success_records: List of successfully updated EntityRecord objects - - failure_records: List of EntityRecord objects that failed to update + - success_records: List of successfully updated :class:`EntityRecord` objects + - failure_records: List of :class:`FailureRecord` describing per-record errors Examples: Update records:: @@ -937,15 +1357,13 @@ class CustomerSchema: for record in response.success_records: print(f"Updated: {record.name}") """ - valid_records = [ - EntityRecord.from_data(data=record.model_dump(by_alias=True), model=schema) - for record in records - ] - - spec = self._update_batch_spec(entity_key, valid_records) - response = self.request(spec.method, spec.endpoint, json=spec.json) - - return self.validate_entity_batch(response, schema) + return self._data.update_records( + entity_key, + records, + schema=schema, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + ) @traced(name="entity_record_update_batch", run_type="uipath") async def update_records_async( @@ -953,20 +1371,30 @@ async def update_records_async( entity_key: str, records: List[Any], schema: Optional[Type[Any]] = None, + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, ) -> EntityRecordsBatchResponse: """Asynchronously update multiple records in an entity in a single batch operation. Args: entity_key (str): The unique key/identifier of the entity. - records (List[Any]): List of records to update. Each record must have an 'Id' field - and should be a Pydantic model with `model_dump()` method or similar object. + records (List[Any]): List of records to update. Each record must + include its ``Id`` field. A record may be a dict, a Pydantic + model, an :class:`EntityRecord`, or any object exposing + ``__dict__``. schema (Optional[Type[Any]]): Optional schema class for validation. When provided, validates that each record in the request and response matches the schema structure. + expansion_level (Optional[int]): Depth of foreign-key expansion in + the response (``0`` means no expansion). + fail_on_first (Optional[bool]): When ``True``, stop the batch on + the first per-record failure. When ``False`` (default), all + records are attempted and the response lists both + ``success_records`` and ``failure_records``. Returns: EntityRecordsBatchResponse: Response containing successful and failed record operations. - - success_records: List of successfully updated EntityRecord objects - - failure_records: List of EntityRecord objects that failed to update + - success_records: List of successfully updated :class:`EntityRecord` objects + - failure_records: List of :class:`FailureRecord` describing per-record errors Examples: Update records:: @@ -1015,32 +1443,35 @@ class CustomerSchema: for record in response.success_records: print(f"Updated: {record.name}") """ - valid_records = [ - EntityRecord.from_data(data=record.model_dump(by_alias=True), model=schema) - for record in records - ] - - spec = self._update_batch_spec(entity_key, valid_records) - response = await self.request_async(spec.method, spec.endpoint, json=spec.json) - - return self.validate_entity_batch(response, schema) + return await self._data.update_records_async( + entity_key, + records, + schema=schema, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + ) @traced(name="entity_record_delete_batch", run_type="uipath") def delete_records( self, entity_key: str, record_ids: List[str], + fail_on_first: Optional[bool] = None, ) -> EntityRecordsBatchResponse: """Delete multiple records from an entity in a single batch operation. Args: entity_key (str): The unique key/identifier of the entity. record_ids (List[str]): List of record IDs (GUIDs) to delete. + fail_on_first (Optional[bool]): When ``True``, stop the batch on + the first per-record failure. When ``False`` (default), all + records are attempted and the response lists both + ``success_records`` and ``failure_records``. Returns: EntityRecordsBatchResponse: Response containing successful and failed record operations. - - success_records: List of successfully deleted EntityRecord objects - - failure_records: List of EntityRecord objects that failed to delete + - success_records: List of successfully deleted :class:`EntityRecord` objects + - failure_records: List of :class:`FailureRecord` describing per-record errors Examples: Delete specific records by ID:: @@ -1077,31 +1508,31 @@ def delete_records( ) print(f"Deleted {len(response.success_records)} inactive records") """ - spec = self._delete_batch_spec(entity_key, record_ids) - response = self.request(spec.method, spec.endpoint, json=spec.json) - - delete_records_response = EntityRecordsBatchResponse.model_validate( - response.json() + return self._data.delete_records( + entity_key, record_ids, fail_on_first=fail_on_first ) - return delete_records_response - @traced(name="entity_record_delete_batch", run_type="uipath") async def delete_records_async( self, entity_key: str, record_ids: List[str], + fail_on_first: Optional[bool] = None, ) -> EntityRecordsBatchResponse: """Asynchronously delete multiple records from an entity in a single batch operation. Args: entity_key (str): The unique key/identifier of the entity. record_ids (List[str]): List of record IDs (GUIDs) to delete. + fail_on_first (Optional[bool]): When ``True``, stop the batch on + the first per-record failure. When ``False`` (default), all + records are attempted and the response lists both + ``success_records`` and ``failure_records``. Returns: EntityRecordsBatchResponse: Response containing successful and failed record operations. - - success_records: List of successfully deleted EntityRecord objects - - failure_records: List of EntityRecord objects that failed to delete + - success_records: List of successfully deleted :class:`EntityRecord` objects + - failure_records: List of :class:`FailureRecord` describing per-record errors Examples: Delete specific records by ID:: @@ -1138,351 +1569,658 @@ async def delete_records_async( ) print(f"Deleted {len(response.success_records)} inactive records") """ - spec = self._delete_batch_spec(entity_key, record_ids) - response = await self.request_async(spec.method, spec.endpoint, json=spec.json) - - delete_records_response = EntityRecordsBatchResponse.model_validate( - response.json() + return await self._data.delete_records_async( + entity_key, record_ids, fail_on_first=fail_on_first ) - return delete_records_response - - def validate_entity_batch( + @traced(name="entity_retrieve_records", run_type="uipath") + def retrieve_records( self, - batch_response: Response, - schema: Optional[Type[Any]] = None, - ) -> EntityRecordsBatchResponse: - # Validate the response format - insert_records_response = EntityRecordsBatchResponse.model_validate( - batch_response.json() - ) + entity_key: str, + filter_group: Optional[EntityQueryFilterGroup] = None, + sort_options: Optional[List[EntityQuerySortOption]] = None, + selected_fields: Optional[List[str]] = None, + expansions: Optional[List[Any]] = None, + expansion_level: Optional[int] = None, + aggregates: Optional[List[EntityAggregate]] = None, + group_by: Optional[List[str]] = None, + joins: Optional[List[EntityJoin]] = None, + binnings: Optional[List[EntityBinning]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> RetrieveEntityRecordsResponse: + """Retrieve records with structured filters, sorting, expansion, joins, and aggregates. - # Validate individual records - validated_successful_records = [ - EntityRecord.from_data( - data=successful_record.model_dump(by_alias=True), model=schema - ) - for successful_record in insert_records_response.success_records - ] + Routes to the V2 endpoint when ``binnings`` is provided (numeric/date + binning is gated by the ``enable-binning-on-query`` feature flag on + the backend). - validated_failed_records = [ - EntityRecord.from_data( - data=failed_record.model_dump(by_alias=True), model=schema - ) - for failed_record in insert_records_response.failure_records - ] + Args: + entity_key (str): The unique key/identifier of the entity. + filter_group (Optional[EntityQueryFilterGroup]): Nested filter + conditions combined with AND/OR. + sort_options (Optional[List[EntityQuerySortOption]]): Sort fields + and direction. + selected_fields (Optional[List[str]]): Column projection — field + names to include; omit to return all fields. + expansions (Optional[List[Any]]): Foreign-key relationships to + expand inline on each result record. + expansion_level (Optional[int]): Depth of expansion (sent as a + URL query param). + aggregates (Optional[List[EntityAggregate]]): Aggregate + expressions (``COUNT`` / ``SUM`` / ``AVG`` / ``MIN`` / + ``MAX``). Maximum 5 per query. + group_by (Optional[List[str]]): Fields to group aggregate results + by. Maximum 5; required when both ``aggregates`` and + ``selected_fields`` are supplied. + joins (Optional[List[EntityJoin]]): Cross-entity joins. Maximum + 3, all of the same type. + binnings (Optional[List[EntityBinning]]): Bucket numeric or date + group-by fields. Each entry's field must also appear in + ``group_by``. + start (Optional[int]): Records to skip (pagination offset). + limit (Optional[int]): Maximum number of records to return. - return EntityRecordsBatchResponse( - success_records=validated_successful_records, - failure_records=validated_failed_records, - ) + Returns: + RetrieveEntityRecordsResponse: A response with ``items``, + ``total_count``, ``has_next_page``, and ``next_cursor``. + ``items`` is a list of :class:`EntityRecord` for plain + queries, or :class:`AggregateRow` when ``aggregates``, + ``group_by``, or ``binnings`` are used. ``next_cursor`` is + populated only when the backend returns one; otherwise + paginate by passing the next ``start``. - def _retrieve_spec( - self, - entity_key: str, - ) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint(f"datafabric_/api/Entity/{entity_key}"), - ) + Examples: + Filter + sort + projection:: + + from uipath.platform.entities import ( + EntityQueryFilter, + EntityQueryFilterGroup, + EntityQuerySortOption, + LogicalOperator, + QueryFilterOperator, + ) - def _retrieve_by_name_spec( - self, - entity_name: str, - ) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint(f"datafabric_/api/Entity/{entity_name}/metadata"), - ) + result = entities_service.retrieve_records( + "Customers", + filter_group=EntityQueryFilterGroup( + logical_operator=LogicalOperator.And, + query_filters=[ + EntityQueryFilter( + field_name="status", + operator=QueryFilterOperator.Equals, + value="active", + ) + ], + ), + sort_options=[ + EntityQuerySortOption(field_name="created_at", is_descending=True) + ], + selected_fields=["Id", "name", "email"], + start=0, + limit=50, + ) + print(f"Found {result.total_count} customers") + + Aggregates and group-by (counts per status):: - @staticmethod - def _folder_key_headers(folder_key: Optional[str]) -> dict[str, str]: - if folder_key: - return {HEADER_FOLDER_KEY: folder_key} - return {} + from uipath.platform.entities import ( + EntityAggregate, + EntityAggregateFunction, + ) - def _list_entities_spec(self) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint("datafabric_/api/Entity"), + result = entities_service.retrieve_records( + "Customers", + selected_fields=["status"], + group_by=["status"], + aggregates=[ + EntityAggregate( + function=EntityAggregateFunction.Count, + field="Id", + alias="total", + ) + ], + ) + for row in result.items: + print(row.status, row.total) + """ + return self._data.retrieve_records( + entity_key, + filter_group=filter_group, + sort_options=sort_options, + selected_fields=selected_fields, + expansions=expansions, + expansion_level=expansion_level, + aggregates=aggregates, + group_by=group_by, + joins=joins, + binnings=binnings, + start=start, + limit=limit, ) - def _list_records_spec( + @traced(name="entity_retrieve_records", run_type="uipath") + async def retrieve_records_async( self, entity_key: str, + filter_group: Optional[EntityQueryFilterGroup] = None, + sort_options: Optional[List[EntityQuerySortOption]] = None, + selected_fields: Optional[List[str]] = None, + expansions: Optional[List[Any]] = None, + expansion_level: Optional[int] = None, + aggregates: Optional[List[EntityAggregate]] = None, + group_by: Optional[List[str]] = None, + joins: Optional[List[EntityJoin]] = None, + binnings: Optional[List[EntityBinning]] = None, start: Optional[int] = None, limit: Optional[int] = None, - ) -> RequestSpec: - params: dict[str, Any] = {} - if start is not None: - params["start"] = start - if limit is not None: - params["limit"] = limit - return RequestSpec( - method="GET", - endpoint=Endpoint( - f"datafabric_/api/EntityService/entity/{entity_key}/read" - ), - params=params, + ) -> RetrieveEntityRecordsResponse: + """Asynchronously retrieve records with structured filters, sorting, expansion, joins, and aggregates. + + Routes to the V2 endpoint when ``binnings`` is provided (numeric/date + binning is gated by the ``enable-binning-on-query`` feature flag on + the backend). + + Args: + entity_key (str): The unique key/identifier of the entity. + filter_group (Optional[EntityQueryFilterGroup]): Nested filter + conditions combined with AND/OR. + sort_options (Optional[List[EntityQuerySortOption]]): Sort fields + and direction. + selected_fields (Optional[List[str]]): Column projection — field + names to include; omit to return all fields. + expansions (Optional[List[Any]]): Foreign-key relationships to + expand inline on each result record. + expansion_level (Optional[int]): Depth of expansion. + aggregates (Optional[List[EntityAggregate]]): Aggregate + expressions. Maximum 5 per query. + group_by (Optional[List[str]]): Fields to group aggregate results + by. Maximum 5; required when both ``aggregates`` and + ``selected_fields`` are supplied. + joins (Optional[List[EntityJoin]]): Cross-entity joins. Maximum + 3, all of the same type. + binnings (Optional[List[EntityBinning]]): Bucket numeric or date + group-by fields. + start (Optional[int]): Records to skip (pagination offset). + limit (Optional[int]): Maximum number of records to return. + + Returns: + RetrieveEntityRecordsResponse: A response with ``items``, + ``total_count``, ``has_next_page``, and ``next_cursor``. + + Examples: + Filter + sort + pagination:: + + from uipath.platform.entities import ( + EntityQueryFilter, + EntityQueryFilterGroup, + QueryFilterOperator, + ) + + result = await entities_service.retrieve_records_async( + "Customers", + filter_group=EntityQueryFilterGroup( + query_filters=[ + EntityQueryFilter( + field_name="status", + operator=QueryFilterOperator.Equals, + value="active", + ) + ], + ), + start=0, + limit=25, + ) + print(f"{len(result.items)} of {result.total_count} customers") + """ + return await self._data.retrieve_records_async( + entity_key, + filter_group=filter_group, + sort_options=sort_options, + selected_fields=selected_fields, + expansions=expansions, + expansion_level=expansion_level, + aggregates=aggregates, + group_by=group_by, + joins=joins, + binnings=binnings, + start=start, + limit=limit, ) - def _query_entity_records_spec( + @traced(name="entity_query_records", run_type="uipath") + def query_entity_records(self, sql_query: str) -> List[Dict[str, Any]]: + """Query entity records using a validated SQL query. + + PREVIEW: This method is in preview and may change in future releases. + + Args: + sql_query (str): A SQL SELECT query to execute against Data Service entities. + Only SELECT statements are allowed. Queries without WHERE must include + a LIMIT clause. Subqueries and multi-statement queries are not permitted. + + Notes: + A routing context is always derived from the configured ``folders_map`` + when present and included in the request body. + + Returns: + List[Dict[str, Any]]: A list of result records as dictionaries. + + Raises: + ValueError: If the SQL query fails validation (e.g., non-SELECT, missing + WHERE/LIMIT, forbidden keywords, subqueries). + """ + return self._data.query_entity_records(sql_query) + + @traced(name="entity_query_records", run_type="uipath") + async def query_entity_records_async(self, sql_query: str) -> List[Dict[str, Any]]: + """Asynchronously query entity records using a validated SQL query. + + PREVIEW: This method is in preview and may change in future releases. + + Args: + sql_query (str): A SQL SELECT query to execute against Data Service entities. + Only SELECT statements are allowed. Queries without WHERE must include + a LIMIT clause. Subqueries and multi-statement queries are not permitted. + + Notes: + A routing context is always derived from the configured ``folders_map`` + when present and included in the request body. + + Returns: + List[Dict[str, Any]]: A list of result records as dictionaries. + + Raises: + ValueError: If the SQL query fails validation (e.g., non-SELECT, missing + WHERE/LIMIT, forbidden keywords, subqueries). + """ + return await self._data.query_entity_records_async(sql_query) + + @traced(name="entity_upload_attachment", run_type="uipath") + def upload_attachment( self, - sql_query: str, - routing_context: Optional[QueryRoutingOverrideContext] = None, - ) -> RequestSpec: - body: Dict[str, Any] = {"query": sql_query} - if routing_context: - body["routingContext"] = routing_context.model_dump( - by_alias=True, exclude_none=True - ) - return RequestSpec( - method="POST", - endpoint=Endpoint("datafabric_/api/v1/query/execute"), - json=body, - ) + entity_id: str, + record_id: str, + field_name: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """Upload a file attachment to a File-type field on a record. + + Provide exactly one of ``file`` (raw bytes) or ``file_path`` (path on + disk). - def _insert_batch_spec(self, entity_key: str, records: List[Any]) -> RequestSpec: - return RequestSpec( - method="POST", - endpoint=Endpoint( - f"datafabric_/api/EntityService/entity/{entity_key}/insert-batch" - ), - json=[record.__dict__ for record in records], - ) + Args: + entity_id (str): The unique identifier of the entity. + record_id (str): The unique identifier of the record whose + attachment field is being set. + field_name (str): Name of the File-type field on the entity. + file (Optional[FileContent]): Raw bytes (``bytes`` / + ``bytearray`` / ``memoryview``) of the file to upload. + Mutually exclusive with ``file_path``. + file_path (Optional[str]): Path to a local file to upload. + Mutually exclusive with ``file``. + expansion_level (Optional[int]): Optional FK expansion depth in + the response (``0`` means no expansion). - def _update_batch_spec( - self, entity_key: str, records: List[EntityRecord] - ) -> RequestSpec: - return RequestSpec( - method="POST", - endpoint=Endpoint( - f"datafabric_/api/EntityService/entity/{entity_key}/update-batch" - ), - json=[record.model_dump(by_alias=True) for record in records], + Returns: + Dict[str, Any]: The decoded JSON response (typically the updated + record), or an empty dict when the response has no body. + + Examples: + Upload from raw bytes:: + + with open("contract.pdf", "rb") as f: + data = f.read() + entities_service.upload_attachment( + "Customers", "rec-1", "Contract", file=data + ) + + Upload from a path on disk:: + + entities_service.upload_attachment( + "Customers", "rec-1", "Contract", file_path="./contract.pdf" + ) + """ + return self._data.upload_attachment( + entity_id, + record_id, + field_name, + file=file, + file_path=file_path, + expansion_level=expansion_level, ) - def _delete_batch_spec(self, entity_key: str, record_ids: List[str]) -> RequestSpec: - return RequestSpec( - method="POST", - endpoint=Endpoint( - f"datafabric_/api/EntityService/entity/{entity_key}/delete-batch" - ), - json=record_ids, + @traced(name="entity_upload_attachment", run_type="uipath") + async def upload_attachment_async( + self, + entity_id: str, + record_id: str, + field_name: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """Asynchronously upload a file attachment to a File-type field on a record. + + Provide exactly one of ``file`` (raw bytes) or ``file_path`` (path on + disk). + + Args: + entity_id (str): The unique identifier of the entity. + record_id (str): The unique identifier of the record whose + attachment field is being set. + field_name (str): Name of the File-type field on the entity. + file (Optional[FileContent]): Raw bytes of the file to upload. + Mutually exclusive with ``file_path``. + file_path (Optional[str]): Path to a local file to upload. + Mutually exclusive with ``file``. + expansion_level (Optional[int]): Optional FK expansion depth in + the response. + + Returns: + Dict[str, Any]: The decoded JSON response. + + Examples: + Upload from a path on disk:: + + await entities_service.upload_attachment_async( + "Customers", "rec-1", "Contract", file_path="./contract.pdf" + ) + """ + return await self._data.upload_attachment_async( + entity_id, + record_id, + field_name, + file=file, + file_path=file_path, + expansion_level=expansion_level, ) - def _list_choicesets_spec(self) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint("datafabric_/api/Entity/choiceset"), + @traced(name="entity_download_attachment", run_type="uipath") + def download_attachment( + self, entity_id: str, record_id: str, field_name: str + ) -> bytes: + """Download a file attached to a record and return its raw bytes. + + Args: + entity_id (str): The unique identifier of the entity. + record_id (str): The unique identifier of the record containing + the attachment. + field_name (str): Name of the File-type field on the entity. + + Returns: + bytes: The raw file content. + + Examples: + Save the downloaded bytes to disk:: + + content = entities_service.download_attachment( + "Customers", "rec-1", "Contract" + ) + with open("downloaded.pdf", "wb") as f: + f.write(content) + """ + return self._data.download_attachment(entity_id, record_id, field_name) + + @traced(name="entity_download_attachment", run_type="uipath") + async def download_attachment_async( + self, entity_id: str, record_id: str, field_name: str + ) -> bytes: + """Asynchronously download a file attached to a record. + + Args: + entity_id (str): The unique identifier of the entity. + record_id (str): The unique identifier of the record containing + the attachment. + field_name (str): Name of the File-type field on the entity. + + Returns: + bytes: The raw file content. + + Examples: + Save the downloaded bytes to disk:: + + content = await entities_service.download_attachment_async( + "Customers", "rec-1", "Contract" + ) + with open("downloaded.pdf", "wb") as f: + f.write(content) + """ + return await self._data.download_attachment_async( + entity_id, record_id, field_name ) - def _get_choiceset_values_spec( + @traced(name="entity_delete_attachment", run_type="uipath") + def delete_attachment( self, - choiceset_id: str, - start: int | None = None, - limit: int | None = None, - ) -> RequestSpec: - params: dict[str, Any] = {} - if start is not None: - params["start"] = start - if limit is not None: - params["limit"] = limit - return RequestSpec( - method="POST", - endpoint=Endpoint( - f"datafabric_/api/EntityService/entity/{choiceset_id}/query_expansion" - ), - params=params, - json={}, + entity_id: str, + record_id: str, + field_name: str, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """Remove the file attached to a File-type field on a record. + + Args: + entity_id (str): The unique identifier of the entity. + record_id (str): The unique identifier of the record whose + attachment is being removed. + field_name (str): Name of the File-type field on the entity. + expansion_level (Optional[int]): Optional FK expansion depth in + the response (``0`` means no expansion). + + Returns: + Dict[str, Any]: The decoded JSON response (typically the updated + record), or an empty dict when the response has no body. + + Examples: + Clear an attachment:: + + entities_service.delete_attachment( + "Customers", "rec-1", "Contract" + ) + """ + return self._data.delete_attachment( + entity_id, record_id, field_name, expansion_level=expansion_level ) - def _validate_sql_query(self, sql_query: str) -> None: - query = sql_query.strip().rstrip(";").strip() - if not query: - raise ValueError("SQL query cannot be empty.") + @traced(name="entity_delete_attachment", run_type="uipath") + async def delete_attachment_async( + self, + entity_id: str, + record_id: str, + field_name: str, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """Asynchronously remove the file attached to a File-type field. - statements = sqlparse.parse(query) - if len(statements) != 1 or not statements[0].tokens: - raise ValueError("Only a single SELECT statement is allowed.") + Args: + entity_id (str): The unique identifier of the entity. + record_id (str): The unique identifier of the record whose + attachment is being removed. + field_name (str): Name of the File-type field on the entity. + expansion_level (Optional[int]): Optional FK expansion depth. - stmt = statements[0] - stmt_type = stmt.get_type() + Returns: + Dict[str, Any]: The decoded JSON response. - if stmt_type != "SELECT": - raise ValueError("Only SELECT statements are allowed.") + Examples: + Clear an attachment:: + + await entities_service.delete_attachment_async( + "Customers", "rec-1", "Contract" + ) + """ + return await self._data.delete_attachment_async( + entity_id, record_id, field_name, expansion_level=expansion_level + ) - keywords = set() - for token in stmt.flatten(): - if token.ttype in Keyword: - keywords.add(token.normalized) + @traced(name="entity_import_records", run_type="uipath") + def import_records( + self, + entity_id: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + ) -> EntityImportRecordsResponse: + """Bulk-import records into an entity from a CSV file. + + Provide exactly one of ``file`` (raw bytes) or ``file_path`` (path on + disk). - for kw in _FORBIDDEN_DML: - if kw in keywords: - raise ValueError(f"SQL keyword '{kw}' is not allowed.") + Args: + entity_id (str): The unique identifier of the entity. + file (Optional[FileContent]): Raw bytes of a CSV file. Mutually + exclusive with ``file_path``. + file_path (Optional[str]): Path to a local CSV file. Mutually + exclusive with ``file``. + + Returns: + EntityImportRecordsResponse: Reports the total rows in the file, + the number successfully inserted, and an optional + ``error_file_link`` pointing to a CSV listing rows that + failed validation. - for kw in _FORBIDDEN_DDL: - if kw in keywords: - raise ValueError(f"SQL keyword '{kw}' is not allowed.") + Examples: + Import from a path on disk:: - for kw in _DISALLOWED_KEYWORDS: - if kw in keywords: - raise ValueError( - f"SQL construct '{kw}' is not allowed in entity queries." + result = entities_service.import_records( + "Customers", file_path="./customers.csv" + ) + print( + f"Inserted {result.inserted_records} of " + f"{result.total_records} rows" ) + if result.error_file_link: + print(f"Errors: {result.error_file_link}") + """ + return self._data.import_records(entity_id, file=file, file_path=file_path) - if self._has_subquery(stmt): - raise ValueError("Subqueries are not allowed.") + @traced(name="entity_import_records", run_type="uipath") + async def import_records_async( + self, + entity_id: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + ) -> EntityImportRecordsResponse: + """Asynchronously bulk-import records into an entity from a CSV file. - has_where = any(isinstance(t, Where) for t in stmt.tokens) - has_limit = "LIMIT" in keywords - has_from = "FROM" in keywords + Provide exactly one of ``file`` (raw bytes) or ``file_path`` (path on + disk). - if not has_from: - raise ValueError("Queries must include a FROM clause.") + Args: + entity_id (str): The unique identifier of the entity. + file (Optional[FileContent]): Raw bytes of a CSV file. + file_path (Optional[str]): Path to a local CSV file. - projection = self._projection_tokens(stmt) + Returns: + EntityImportRecordsResponse: Reports the total, inserted, and + ``error_file_link`` for failed rows. - if self._projection_has_count_star(projection): - raise ValueError( - "COUNT(*) is not supported. Use COUNT(column_name) instead." - ) + Examples: + Import from a path on disk:: + + result = await entities_service.import_records_async( + "Customers", file_path="./customers.csv" + ) + print( + f"Inserted {result.inserted_records} of " + f"{result.total_records} rows" + ) + """ + return await self._data.import_records_async( + entity_id, file=file, file_path=file_path + ) + + # ------------------------------------------------------------------ + # Public helper retained for backward compatibility — tests call this + # ------------------------------------------------------------------ + + def validate_entity_batch( + self, + batch_response: Response, + schema: Optional[Type[Any]] = None, + ) -> EntityRecordsBatchResponse: + """Parse a batch response, optionally validating success records against ``schema``. + + Failure records are returned as :class:`FailureRecord` instances and + are not validated against the user schema. + """ + return self._data.validate_entity_batch(batch_response, schema=schema) - has_aggregate = self._projection_has_aggregate(projection) + # ------------------------------------------------------------------ + # Cross-cutting — entity-set resolution for agent overrides + # ------------------------------------------------------------------ + + @traced(name="resolve_entity_set", run_type="uipath") + def resolve_entity_set( + self, + items: List[DataFabricEntityItem], + ) -> EntitySetResolution: + """Resolve an agent entity set, applying resource overwrites.""" + plan = create_resolution_plan( + items, + _resource_overwrites.get() or {}, + lambda folder_path: ( + self._folders_service.retrieve_key(folder_path=folder_path) + if self._folders_service is not None + else None + ), + ) + entities = fetch_resolved_entities( + plan, + self.retrieve, + self.retrieve_by_name, + logger, + ) + resolution_service: EntitiesService = build_resolution_service( # type: ignore[assignment] + config=self._config, + execution_context=self._execution_context, + folders_service=self._folders_service, + plan=plan, + service_factory=EntitiesService, + ) + return EntitySetResolution( + entities=entities, + entities_service=resolution_service, + ) - if not has_where and not has_limit and not has_aggregate: - raise ValueError("Queries without WHERE must include a LIMIT clause.") + @traced(name="resolve_entity_set", run_type="uipath") + async def resolve_entity_set_async( + self, + items: List[DataFabricEntityItem], + ) -> EntitySetResolution: + """Resolve an agent entity set, applying resource overwrites.""" - has_bare_wildcard = self._projection_has_bare_wildcard(projection) - if has_bare_wildcard: - raise ValueError("SELECT * is not allowed. Specify column names instead.") - if not has_where and self._projection_column_count(projection) > 4: - raise ValueError( - "Selecting more than 4 columns without filtering is not allowed." + async def _resolve_folder_path(folder_path: str) -> Optional[str]: + if self._folders_service is None: + return None + return await self._folders_service.retrieve_key_async( + folder_path=folder_path ) - @staticmethod - def _projection_has_aggregate( - projection: list[sqlparse.sql.Token], - ) -> bool: - """Check whether the projection contains an aggregate function call.""" - - def _has_agg(token: sqlparse.sql.Token) -> bool: - if isinstance(token, Function): - return token.get_name().upper() in _AGGREGATE_FUNCTIONS - if isinstance(token, Identifier): - return any(_has_agg(child) for child in token.tokens) - return False - - for node in projection: - if _has_agg(node): - return True - if isinstance(node, IdentifierList): - if any(_has_agg(child) for child in node.tokens): - return True - return False - - @staticmethod - def _projection_has_count_star( - projection: list[sqlparse.sql.Token], - ) -> bool: - """Check whether projection contains COUNT(*).""" - - def _is_count_star(func: Function) -> bool: - if func.get_name().upper() != "COUNT": - return False - return any(t.ttype is Wildcard for t in func.flatten()) - - def _has_count_star(token: sqlparse.sql.Token) -> bool: - if isinstance(token, Function): - return _is_count_star(token) - if isinstance(token, Identifier): - return any(_has_count_star(child) for child in token.tokens) - return False - - for node in projection: - if _has_count_star(node): - return True - if isinstance(node, IdentifierList): - if any(_has_count_star(child) for child in node.tokens): - return True - return False - - @staticmethod - def _projection_has_bare_wildcard( - projection: list[sqlparse.sql.Token], - ) -> bool: - """Check for a bare ``*`` or qualified ``table.*`` outside a function.""" - - def _identifier_has_wildcard(ident: Identifier) -> bool: - return any(t.ttype is Wildcard for t in ident.tokens) - - for node in projection: - if node.ttype is Wildcard: - return True - if isinstance(node, Identifier) and _identifier_has_wildcard(node): - return True - if isinstance(node, IdentifierList): - for child in node.tokens: - if child.ttype is Wildcard: - return True - if isinstance(child, Identifier) and _identifier_has_wildcard( - child - ): - return True - return False - - @staticmethod - def _has_subquery(stmt: sqlparse.sql.Statement) -> bool: - """Recursively walk the AST looking for SELECT inside parentheses.""" - - def _walk(token: sqlparse.sql.Token) -> bool: - if isinstance(token, Parenthesis): - for child in token.flatten(): - if child.ttype is DML and child.normalized == "SELECT": - return True - if hasattr(token, "tokens"): - for child in token.tokens: - if _walk(child): - return True - return False - - for token in stmt.tokens: - if _walk(token): - return True - return False - - @staticmethod - def _projection_tokens( - stmt: sqlparse.sql.Statement, - ) -> list[sqlparse.sql.Token]: - """Extract non-flattened AST nodes between the first SELECT and FROM.""" - tokens: list[sqlparse.sql.Token] = [] - collecting = False - for token in stmt.tokens: - if token.ttype is DML and token.normalized == "SELECT": - collecting = True - continue - if token.ttype is Keyword and token.normalized in ("FROM", "INTO"): - break - if token.ttype is Keyword and token.normalized == "DISTINCT": - continue - if collecting and token.ttype is not Whitespace: - tokens.append(token) - return tokens - - @staticmethod - def _projection_column_count( - projection: list[sqlparse.sql.Token], - ) -> int: - for node in projection: - if isinstance(node, IdentifierList): - return len(list(node.get_identifiers())) - if isinstance(node, (Identifier, Function)): - return 1 - if node.ttype is Wildcard: - return 1 - return 0 + plan = await create_resolution_plan_async( + items, + _resource_overwrites.get() or {}, + _resolve_folder_path, + ) + entities = await fetch_resolved_entities_async( + plan, + self.retrieve_async, + self.retrieve_by_name_async, + logger, + ) + resolution_service: EntitiesService = build_resolution_service( # type: ignore[assignment] + config=self._config, + execution_context=self._execution_context, + folders_service=self._folders_service, + plan=plan, + service_factory=EntitiesService, + ) + return EntitySetResolution( + entities=entities, + entities_service=resolution_service, + ) # Resolve the forward reference to EntitiesService in EntitySetResolution. diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py new file mode 100644 index 000000000..60cfb99b4 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py @@ -0,0 +1,1403 @@ +"""Data-side operations for the Data Fabric entities surface. + +Handles record CRUD (single and batch), structured queries, attachments, +choice-set value lookup, bulk import, and federated SQL queries. Schema +definitions are managed by :class:`EntitySchemaService` and exposed alongside +data operations through :class:`EntitiesService`. +""" + +import json as json_module +import logging +from contextlib import nullcontext +from pathlib import Path +from typing import Any, Dict, List, Optional, Type + +import sqlparse +from httpx import HTTPStatusError, Response +from pydantic import BaseModel +from sqlparse.sql import Function, Identifier, IdentifierList, Parenthesis, Where +from sqlparse.tokens import DML, Keyword, Whitespace, Wildcard + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._models import Endpoint, RequestSpec +from ..errors._enriched_exception import EnrichedException +from ..orchestrator._folder_service import FolderService +from ._entity_resolution import RoutingStrategy, create_routing_strategy +from .entities import ( + AggregateRow, + ChoiceSetValue, + EntityAggregate, + EntityBinning, + EntityImportRecordsResponse, + EntityJoin, + EntityQueryFilterGroup, + EntityQuerySortOption, + EntityRecord, + EntityRecordsBatchResponse, + EntityRecordsListResponse, + QueryRoutingOverrideContext, + RetrieveEntityRecordsResponse, +) + +logger = logging.getLogger(__name__) + +FileContent = bytes | bytearray | memoryview +"""Acceptable raw bytes types for attachment and CSV uploads.""" + +_FORBIDDEN_DML = {"INSERT", "UPDATE", "DELETE", "MERGE", "REPLACE"} +_FORBIDDEN_DDL = {"DROP", "ALTER", "CREATE", "TRUNCATE"} +_DISALLOWED_KEYWORDS = [ + "WITH", + "UNION", + "INTERSECT", + "EXCEPT", + "OVER", + "ROLLUP", + "CUBE", + "GROUPING", + "PARTITION", +] +_AGGREGATE_FUNCTIONS = ("COUNT", "SUM", "AVG", "MIN", "MAX") + + +class EntityDataService(BaseService): + """HTTP service for entity-record and attachment operations. + + Backend target: ``datafabric_/api/EntityService/...`` plus + ``datafabric_/api/Attachment/...`` for file attachments, and + ``datafabric_/api/v1/query/execute`` for federated SQL queries. + + !!! warning "Preview Feature" + This service is currently experimental. Behavior and parameters are + subject to change in future versions. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Optional[FolderService] = None, + routing_strategy: Optional[RoutingStrategy] = None, + folders_map: Optional[Dict[str, str]] = None, + entity_name_overrides: Optional[Dict[str, str]] = None, + routing_context: Optional[QueryRoutingOverrideContext] = None, + ) -> None: + """Initialise the data service. + + Either pass a pre-built ``routing_strategy`` (the facade does this so + both services share one) or supply the inputs and let this service + construct its own. + """ + super().__init__(config=config, execution_context=execution_context) + self._folders_service = folders_service + self._routing_strategy: RoutingStrategy = ( + routing_strategy + if routing_strategy is not None + else create_routing_strategy( + folders_map=folders_map, + effective_entity_names=entity_name_overrides, + routing_context=routing_context, + folders_service=folders_service, + ) + ) + + # ------------------------------------------------------------------ + # Choice-set value lookup + # ------------------------------------------------------------------ + + def get_choiceset_values( + self, + choiceset_id: str, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> List[ChoiceSetValue]: + """Internal implementation; see :meth:`EntitiesService.get_choiceset_values`.""" + spec = self._get_choiceset_values_spec(choiceset_id, start=start, limit=limit) + response = self.request( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return self._parse_choiceset_values(response) + + async def get_choiceset_values_async( + self, + choiceset_id: str, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> List[ChoiceSetValue]: + """Async variant of :meth:`get_choiceset_values`.""" + spec = self._get_choiceset_values_spec(choiceset_id, start=start, limit=limit) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return self._parse_choiceset_values(response) + + # ------------------------------------------------------------------ + # List records (multi-record read with OData filters) + # ------------------------------------------------------------------ + + def list_records( + self, + entity_key: str, + schema: Optional[Type[Any]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + expansion_level: Optional[int] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + select: Optional[List[str]] = None, + expand: Optional[List[str]] = None, + ) -> EntityRecordsListResponse: + """Internal implementation; see :meth:`EntitiesService.list_records`.""" + spec = self._list_records_spec( + entity_key, + start=start, + limit=limit, + expansion_level=expansion_level, + filter=filter, + orderby=orderby, + select=select, + expand=expand, + ) + response = self.request(spec.method, spec.endpoint, params=spec.params) + return self._build_records_list_response(response, schema, start, limit) + + async def list_records_async( + self, + entity_key: str, + schema: Optional[Type[Any]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + expansion_level: Optional[int] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + select: Optional[List[str]] = None, + expand: Optional[List[str]] = None, + ) -> EntityRecordsListResponse: + """Async variant of :meth:`list_records`.""" + spec = self._list_records_spec( + entity_key, + start=start, + limit=limit, + expansion_level=expansion_level, + filter=filter, + orderby=orderby, + select=select, + expand=expand, + ) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params + ) + return self._build_records_list_response(response, schema, start, limit) + + # ------------------------------------------------------------------ + # Single-record operations (fire trigger events; batch versions don't) + # ------------------------------------------------------------------ + + def insert_record( + self, + entity_key: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Internal implementation; see :meth:`EntitiesService.insert_record`.""" + spec = self._insert_record_spec(entity_key, data, expansion_level) + response = self.request( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return EntityRecord.model_validate(response.json()) + + async def insert_record_async( + self, + entity_key: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Async variant of :meth:`insert_record`.""" + spec = self._insert_record_spec(entity_key, data, expansion_level) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return EntityRecord.model_validate(response.json()) + + def get_record( + self, + entity_key: str, + record_id: str, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Fetch a single record by its id.""" + spec = self._get_record_spec(entity_key, record_id, expansion_level) + response = self.request(spec.method, spec.endpoint, params=spec.params) + return EntityRecord.model_validate(response.json()) + + async def get_record_async( + self, + entity_key: str, + record_id: str, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Async variant of :meth:`get_record`.""" + spec = self._get_record_spec(entity_key, record_id, expansion_level) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params + ) + return EntityRecord.model_validate(response.json()) + + def update_record( + self, + entity_key: str, + record_id: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Internal implementation; see :meth:`EntitiesService.update_record`.""" + spec = self._update_record_spec(entity_key, record_id, data, expansion_level) + response = self.request( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return EntityRecord.model_validate(response.json()) + + async def update_record_async( + self, + entity_key: str, + record_id: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Async variant of :meth:`update_record`.""" + spec = self._update_record_spec(entity_key, record_id, data, expansion_level) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return EntityRecord.model_validate(response.json()) + + def delete_record(self, entity_key: str, record_id: str) -> None: + """Delete a single record by id.""" + spec = self._delete_record_spec(entity_key, record_id) + self.request(spec.method, spec.endpoint) + + async def delete_record_async(self, entity_key: str, record_id: str) -> None: + """Async variant of :meth:`delete_record`.""" + spec = self._delete_record_spec(entity_key, record_id) + await self.request_async(spec.method, spec.endpoint) + + # ------------------------------------------------------------------ + # Batch record operations + # ------------------------------------------------------------------ + + def insert_records( + self, + entity_key: str, + records: List[Any], + schema: Optional[Type[Any]] = None, + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> EntityRecordsBatchResponse: + """Internal implementation; see :meth:`EntitiesService.insert_records`.""" + spec = self._insert_batch_spec( + entity_key, + records, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + ) + response = self._request_or_extract_batch( + sync_call=lambda: self.request( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + ) + if isinstance(response, EntityRecordsBatchResponse): + return response + return self.validate_entity_batch(response, schema) + + async def insert_records_async( + self, + entity_key: str, + records: List[Any], + schema: Optional[Type[Any]] = None, + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> EntityRecordsBatchResponse: + """Async variant of :meth:`insert_records`.""" + spec = self._insert_batch_spec( + entity_key, + records, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + ) + + async def _send_batch() -> Response: + return await self.request_async( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + + result = await self._request_or_extract_batch_async(_send_batch) + if isinstance(result, EntityRecordsBatchResponse): + return result + return self.validate_entity_batch(result, schema) + + def update_records( + self, + entity_key: str, + records: List[Any], + schema: Optional[Type[Any]] = None, + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> EntityRecordsBatchResponse: + """Internal implementation; see :meth:`EntitiesService.update_records`.""" + normalized = [self._record_to_dict(record) for record in records] + if schema is not None: + for record in normalized: + EntityRecord.from_data(data=record, model=schema) + + spec = self._update_batch_spec( + entity_key, + normalized, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + ) + response = self._request_or_extract_batch( + sync_call=lambda: self.request( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + ) + if isinstance(response, EntityRecordsBatchResponse): + return response + return self.validate_entity_batch(response, schema) + + async def update_records_async( + self, + entity_key: str, + records: List[Any], + schema: Optional[Type[Any]] = None, + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> EntityRecordsBatchResponse: + """Async variant of :meth:`update_records`.""" + normalized = [self._record_to_dict(record) for record in records] + if schema is not None: + for record in normalized: + EntityRecord.from_data(data=record, model=schema) + + spec = self._update_batch_spec( + entity_key, + normalized, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + ) + + async def _send_batch() -> Response: + return await self.request_async( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + + result = await self._request_or_extract_batch_async(_send_batch) + if isinstance(result, EntityRecordsBatchResponse): + return result + return self.validate_entity_batch(result, schema) + + def delete_records( + self, + entity_key: str, + record_ids: List[str], + fail_on_first: Optional[bool] = None, + ) -> EntityRecordsBatchResponse: + """Delete multiple records by id in a single batch.""" + spec = self._delete_batch_spec( + entity_key, record_ids, fail_on_first=fail_on_first + ) + result = self._request_or_extract_batch( + sync_call=lambda: self.request( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + ) + if isinstance(result, EntityRecordsBatchResponse): + return result + return EntityRecordsBatchResponse.model_validate(result.json()) + + async def delete_records_async( + self, + entity_key: str, + record_ids: List[str], + fail_on_first: Optional[bool] = None, + ) -> EntityRecordsBatchResponse: + """Async variant of :meth:`delete_records`.""" + spec = self._delete_batch_spec( + entity_key, record_ids, fail_on_first=fail_on_first + ) + + async def _send_batch() -> Response: + return await self.request_async( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + + result = await self._request_or_extract_batch_async(_send_batch) + if isinstance(result, EntityRecordsBatchResponse): + return result + return EntityRecordsBatchResponse.model_validate(result.json()) + + # ------------------------------------------------------------------ + # Structured query (POST /entity/{id}/query) + # ------------------------------------------------------------------ + + def retrieve_records( + self, + entity_key: str, + filter_group: Optional[EntityQueryFilterGroup] = None, + sort_options: Optional[List[EntityQuerySortOption]] = None, + selected_fields: Optional[List[str]] = None, + expansions: Optional[List[Any]] = None, + expansion_level: Optional[int] = None, + aggregates: Optional[List[EntityAggregate]] = None, + group_by: Optional[List[str]] = None, + joins: Optional[List[EntityJoin]] = None, + binnings: Optional[List[EntityBinning]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> RetrieveEntityRecordsResponse: + """Internal implementation; see :meth:`EntitiesService.retrieve_records`.""" + spec = self._retrieve_records_spec( + entity_key, + filter_group=filter_group, + sort_options=sort_options, + selected_fields=selected_fields, + expansions=expansions, + expansion_level=expansion_level, + aggregates=aggregates, + group_by=group_by, + joins=joins, + binnings=binnings, + start=start, + limit=limit, + ) + response = self.request( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return self._parse_query_response(response, start=start, limit=limit) + + async def retrieve_records_async( + self, + entity_key: str, + filter_group: Optional[EntityQueryFilterGroup] = None, + sort_options: Optional[List[EntityQuerySortOption]] = None, + selected_fields: Optional[List[str]] = None, + expansions: Optional[List[Any]] = None, + expansion_level: Optional[int] = None, + aggregates: Optional[List[EntityAggregate]] = None, + group_by: Optional[List[str]] = None, + joins: Optional[List[EntityJoin]] = None, + binnings: Optional[List[EntityBinning]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> RetrieveEntityRecordsResponse: + """Async variant of :meth:`retrieve_records`.""" + spec = self._retrieve_records_spec( + entity_key, + filter_group=filter_group, + sort_options=sort_options, + selected_fields=selected_fields, + expansions=expansions, + expansion_level=expansion_level, + aggregates=aggregates, + group_by=group_by, + joins=joins, + binnings=binnings, + start=start, + limit=limit, + ) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return self._parse_query_response(response, start=start, limit=limit) + + # ------------------------------------------------------------------ + # Federated SQL query + # ------------------------------------------------------------------ + + def query_entity_records( + self, + sql_query: str, + ) -> List[Dict[str, Any]]: + """Internal implementation; see :meth:`EntitiesService.query_entity_records`.""" + return self._query_entities_for_records(sql_query) + + async def query_entity_records_async( + self, + sql_query: str, + ) -> List[Dict[str, Any]]: + """Async variant of :meth:`query_entity_records`.""" + return await self._query_entities_for_records_async(sql_query) + + # ------------------------------------------------------------------ + # Attachments + # ------------------------------------------------------------------ + + def upload_attachment( + self, + entity_id: str, + record_id: str, + field_name: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """Internal implementation; see :meth:`EntitiesService.upload_attachment`.""" + spec = self._attachment_endpoint( + entity_id, record_id, field_name, expansion_level + ) + with self._open_file(file, file_path) as handle: + response = self.request( + "POST", + spec.endpoint, + params=spec.params, + files={"file": handle}, + ) + return response.json() if response.content else {} + + async def upload_attachment_async( + self, + entity_id: str, + record_id: str, + field_name: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """Async variant of :meth:`upload_attachment`.""" + spec = self._attachment_endpoint( + entity_id, record_id, field_name, expansion_level + ) + with self._open_file(file, file_path) as handle: + response = await self.request_async( + "POST", + spec.endpoint, + params=spec.params, + files={"file": handle}, + ) + return response.json() if response.content else {} + + def download_attachment( + self, entity_id: str, record_id: str, field_name: str + ) -> bytes: + """Internal implementation; see :meth:`EntitiesService.download_attachment`.""" + spec = self._attachment_endpoint(entity_id, record_id, field_name) + response = self.request("GET", spec.endpoint) + return response.content + + async def download_attachment_async( + self, entity_id: str, record_id: str, field_name: str + ) -> bytes: + """Async variant of :meth:`download_attachment`.""" + spec = self._attachment_endpoint(entity_id, record_id, field_name) + response = await self.request_async("GET", spec.endpoint) + return response.content + + def delete_attachment( + self, + entity_id: str, + record_id: str, + field_name: str, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """Internal implementation; see :meth:`EntitiesService.delete_attachment`.""" + spec = self._attachment_endpoint( + entity_id, record_id, field_name, expansion_level + ) + response = self.request("DELETE", spec.endpoint, params=spec.params) + return response.json() if response.content else {} + + async def delete_attachment_async( + self, + entity_id: str, + record_id: str, + field_name: str, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """Async variant of :meth:`delete_attachment`.""" + spec = self._attachment_endpoint( + entity_id, record_id, field_name, expansion_level + ) + response = await self.request_async("DELETE", spec.endpoint, params=spec.params) + return response.json() if response.content else {} + + # ------------------------------------------------------------------ + # Bulk import + # ------------------------------------------------------------------ + + def import_records( + self, + entity_id: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + ) -> EntityImportRecordsResponse: + """Internal implementation; see :meth:`EntitiesService.import_records`.""" + spec = self._import_records_spec(entity_id) + with self._open_file(file, file_path) as handle: + response = self.request(spec.method, spec.endpoint, files={"file": handle}) + return EntityImportRecordsResponse.model_validate(response.json() or {}) + + async def import_records_async( + self, + entity_id: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + ) -> EntityImportRecordsResponse: + """Async variant of :meth:`import_records`.""" + spec = self._import_records_spec(entity_id) + with self._open_file(file, file_path) as handle: + response = await self.request_async( + spec.method, spec.endpoint, files={"file": handle} + ) + return EntityImportRecordsResponse.model_validate(response.json() or {}) + + # ------------------------------------------------------------------ + # Public helper for batch response validation + # ------------------------------------------------------------------ + + def validate_entity_batch( + self, + batch_response: Response, + schema: Optional[Type[Any]] = None, + ) -> EntityRecordsBatchResponse: + """Internal implementation; see :meth:`EntitiesService.validate_entity_batch`.""" + parsed = EntityRecordsBatchResponse.model_validate(batch_response.json()) + + validated_successful_records = [] + for successful_record in parsed.success_records: + data = successful_record.model_dump(by_alias=True) + if data.get("Id") is not None: + validated_successful_records.append( + EntityRecord.from_data(data=data, model=schema) + ) + + return EntityRecordsBatchResponse( + success_records=validated_successful_records, + failure_records=parsed.failure_records, + ) + + # ------------------------------------------------------------------ + # Internal helpers — request specs + # ------------------------------------------------------------------ + + def _query_entities_for_records(self, sql_query: str) -> List[Dict[str, Any]]: + """Synchronously run a validated SQL query through the federated query engine.""" + self._validate_sql_query(sql_query) + routing_context = self._routing_strategy.resolve() + spec = self._query_entity_records_spec(sql_query, routing_context) + response = self.request(spec.method, spec.endpoint, json=spec.json) + return response.json().get("results", []) + + async def _query_entities_for_records_async( + self, sql_query: str + ) -> List[Dict[str, Any]]: + """Asynchronously run a validated SQL query through the federated query engine.""" + self._validate_sql_query(sql_query) + routing_context = await self._routing_strategy.resolve_async() + spec = self._query_entity_records_spec(sql_query, routing_context) + response = await self.request_async(spec.method, spec.endpoint, json=spec.json) + return response.json().get("results", []) + + @staticmethod + def _list_records_spec( + entity_key: str, + start: Optional[int] = None, + limit: Optional[int] = None, + expansion_level: Optional[int] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + select: Optional[List[str]] = None, + expand: Optional[List[str]] = None, + ) -> RequestSpec: + """Build the GET spec for the multi-record read endpoint.""" + params: Dict[str, Any] = {} + if start is not None: + params["start"] = start + if limit is not None: + params["limit"] = limit + if expansion_level is not None: + params["expansionLevel"] = expansion_level + if filter is not None: + params["$filter"] = filter + if orderby is not None: + params["$orderby"] = orderby + if select: + params["$select"] = ",".join(select) + if expand: + params["$expand"] = ",".join(expand) + return RequestSpec( + method="GET", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{entity_key}/read" + ), + params=params, + ) + + @staticmethod + def _insert_record_spec( + entity_key: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> RequestSpec: + """Build the POST spec for inserting a single record.""" + params: Dict[str, Any] = {} + if expansion_level is not None: + params["expansionLevel"] = expansion_level + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{entity_key}/insert" + ), + params=params, + json=EntityDataService._record_to_dict(data), + ) + + @staticmethod + def _get_record_spec( + entity_key: str, + record_id: str, + expansion_level: Optional[int] = None, + ) -> RequestSpec: + """Build the GET spec for fetching a single record by id.""" + params: Dict[str, Any] = {} + if expansion_level is not None: + params["expansionLevel"] = expansion_level + return RequestSpec( + method="GET", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{entity_key}/read/{record_id}" + ), + params=params, + ) + + @staticmethod + def _update_record_spec( + entity_key: str, + record_id: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> RequestSpec: + """Build the POST spec for updating a single record by id.""" + params: Dict[str, Any] = {} + if expansion_level is not None: + params["expansionLevel"] = expansion_level + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{entity_key}/update/{record_id}" + ), + params=params, + json=EntityDataService._record_to_dict(data), + ) + + @staticmethod + def _delete_record_spec(entity_key: str, record_id: str) -> RequestSpec: + """Build the DELETE spec for removing a single record by id.""" + return RequestSpec( + method="DELETE", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{entity_key}/delete/{record_id}" + ), + ) + + @staticmethod + def _insert_batch_spec( + entity_key: str, + records: List[Any], + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> RequestSpec: + """Build the POST spec for the batch-insert endpoint.""" + params = EntityDataService._batch_params( + expansion_level=expansion_level, fail_on_first=fail_on_first + ) + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{entity_key}/insert-batch" + ), + params=params, + json=[EntityDataService._record_to_dict(record) for record in records], + ) + + @staticmethod + def _update_batch_spec( + entity_key: str, + records: List[Dict[str, Any]], + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> RequestSpec: + """Build the POST spec for the batch-update endpoint.""" + params = EntityDataService._batch_params( + expansion_level=expansion_level, fail_on_first=fail_on_first + ) + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{entity_key}/update-batch" + ), + params=params, + json=records, + ) + + @staticmethod + def _delete_batch_spec( + entity_key: str, + record_ids: List[str], + fail_on_first: Optional[bool] = None, + ) -> RequestSpec: + """Build the POST spec for the batch-delete endpoint.""" + params = EntityDataService._batch_params(fail_on_first=fail_on_first) + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{entity_key}/delete-batch" + ), + params=params, + json=record_ids, + ) + + @staticmethod + def _batch_params( + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> Dict[str, Any]: + """Build the optional URL params common to all batch endpoints.""" + params: Dict[str, Any] = {} + if expansion_level is not None: + params["expansionLevel"] = expansion_level + if fail_on_first is not None: + params["failOnFirst"] = "true" if fail_on_first else "false" + return params + + @staticmethod + def _retrieve_records_spec( + entity_key: str, + filter_group: Optional[EntityQueryFilterGroup] = None, + sort_options: Optional[List[EntityQuerySortOption]] = None, + selected_fields: Optional[List[str]] = None, + expansions: Optional[List[Any]] = None, + expansion_level: Optional[int] = None, + aggregates: Optional[List[Any]] = None, + group_by: Optional[List[str]] = None, + joins: Optional[List[EntityJoin]] = None, + binnings: Optional[List[EntityBinning]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> RequestSpec: + """Build the request spec for the structured-query endpoint. + + Filters, sorting, projection, expansions, aggregates, group-by, joins, + binnings, ``start``, and ``limit`` are placed in the JSON body; + ``expansionLevel`` is a URL query parameter. The V2 endpoint is used + only when ``binnings`` are supplied. + """ + body: Dict[str, Any] = {} + if filter_group is not None: + body["filterGroup"] = filter_group.model_dump( + by_alias=True, exclude_none=True + ) + if sort_options: + body["sortOptions"] = [ + opt.model_dump(by_alias=True, exclude_none=True) for opt in sort_options + ] + if selected_fields: + body["selectedFields"] = list(selected_fields) + if expansions: + body["expansions"] = [ + e.model_dump(by_alias=True, exclude_none=True) + if isinstance(e, BaseModel) + else e + for e in expansions + ] + if aggregates: + body["aggregates"] = [ + a.model_dump(by_alias=True, exclude_none=True) + if isinstance(a, BaseModel) + else a + for a in aggregates + ] + if group_by: + body["groupBy"] = list(group_by) + if joins: + body["joins"] = [ + j.model_dump(by_alias=True, exclude_none=True) for j in joins + ] + if binnings: + body["binnings"] = [ + b.model_dump(by_alias=True, exclude_none=True) for b in binnings + ] + if start is not None: + body["start"] = start + if limit is not None: + body["limit"] = limit + + params: Dict[str, Any] = {} + if expansion_level is not None: + params["expansionLevel"] = expansion_level + + if binnings: + endpoint = Endpoint( + f"datafabric_/api/v2/EntityService/entity/{entity_key}/query" + ) + else: + endpoint = Endpoint( + f"datafabric_/api/EntityService/entity/{entity_key}/query" + ) + + return RequestSpec( + method="POST", + endpoint=endpoint, + params=params, + json=body, + ) + + @staticmethod + def _query_entity_records_spec( + sql_query: str, + routing_context: Optional[QueryRoutingOverrideContext] = None, + ) -> RequestSpec: + """Build the POST spec for the federated SQL query endpoint.""" + body: Dict[str, Any] = {"query": sql_query} + if routing_context: + body["routingContext"] = routing_context.model_dump( + by_alias=True, exclude_none=True + ) + return RequestSpec( + method="POST", + endpoint=Endpoint("datafabric_/api/v1/query/execute"), + json=body, + ) + + @staticmethod + def _get_choiceset_values_spec( + choiceset_id: str, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> RequestSpec: + """Build the POST spec for the choice-set values endpoint.""" + params: Dict[str, Any] = {} + if start is not None: + params["start"] = start + if limit is not None: + params["limit"] = limit + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{choiceset_id}/query_expansion" + ), + params=params, + json={}, + ) + + @staticmethod + def _attachment_endpoint( + entity_id: str, + record_id: str, + field_name: str, + expansion_level: Optional[int] = None, + ) -> RequestSpec: + """Return the attachment endpoint plus any ``expansionLevel`` query param. + + The HTTP verb is supplied by the caller; only the URL and query + parameters depend on these arguments. + """ + params: Dict[str, Any] = {} + if expansion_level is not None: + params["expansionLevel"] = expansion_level + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"datafabric_/api/Attachment/entity/{entity_id}/{record_id}/{field_name}" + ), + params=params, + ) + + @staticmethod + def _import_records_spec(entity_id: str) -> RequestSpec: + """Build the POST spec for the bulk-upload (CSV import) endpoint.""" + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{entity_id}/bulk-upload" + ), + ) + + @staticmethod + def _open_file(file: Optional[FileContent], file_path: Optional[str]) -> Any: + """Yield a file-like object from raw bytes or a path on disk. + + Exactly one of ``file`` and ``file_path`` must be supplied. + """ + if (file is None) == (file_path is None): + raise ValueError( + "Provide exactly one of `file` (bytes) or `file_path` (str path on disk)." + ) + if file_path is not None: + return open(Path(file_path), "rb") + return nullcontext(file) + + # ------------------------------------------------------------------ + # Internal helpers — response parsing and record normalisation + # ------------------------------------------------------------------ + + @staticmethod + def _record_to_dict(record: Any) -> Dict[str, Any]: + """Normalize an input record to a plain dict. + + Accepts dicts, Pydantic ``BaseModel`` (including :class:`EntityRecord`), + or any object exposing ``__dict__``. Explicit ``None`` values are + preserved so callers can clear fields by setting them to ``None`` on a + model instance — only unset fields (whose Pydantic default applies) are + dropped via ``exclude_unset=True``. + """ + if isinstance(record, dict): + return dict(record) + if isinstance(record, BaseModel): + return record.model_dump(by_alias=True, exclude_unset=True) + if hasattr(record, "__dict__"): + return {k: v for k, v in record.__dict__.items() if not k.startswith("_")} + raise TypeError( + f"Cannot convert record of type {type(record).__name__} to dict — " + "pass a dict, an EntityRecord, a Pydantic BaseModel, or an object with __dict__." + ) + + @staticmethod + def _build_records_list_response( + response: Response, + schema: Optional[Type[Any]], + start: Optional[int], + limit: Optional[int], + ) -> EntityRecordsListResponse: + """Build an :class:`EntityRecordsListResponse` from a list-records body.""" + body = response.json() or {} + records_data = body.get("value", []) + total_count = int( + body.get("totalRecordCount", body.get("totalCount", len(records_data))) or 0 + ) + records = [ + EntityRecord.from_data(data=record, model=schema) for record in records_data + ] + + next_cursor = body.get("nextCursor") + if limit is not None and limit > 0: + consumed = (start or 0) + len(records) + has_next_page = consumed < total_count + else: + has_next_page = bool(body.get("hasNextPage", False)) + + return EntityRecordsListResponse( + items=records, + total_count=total_count, + has_next_page=has_next_page, + next_cursor=next_cursor, + ) + + @staticmethod + def _parse_query_response( + response: Response, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> RetrieveEntityRecordsResponse: + """Parse a query response into :class:`RetrieveEntityRecordsResponse`. + + Rows that include an ``Id`` field are parsed as :class:`EntityRecord`; + rows that don't (aggregate / group-by / binning results) are parsed as + :class:`AggregateRow`. ``has_next_page`` is derived from + ``start + len(items) < total_count`` whenever ``limit`` is supplied; + ``next_cursor`` is populated only when the backend returns one, + otherwise the caller paginates by passing the next ``start``. + """ + body = response.json() or {} + items_raw = body.get("value", []) or [] + items: List[EntityRecord | AggregateRow] = [] + for raw in items_raw: + if isinstance(raw, dict) and isinstance(raw.get("Id"), str): + items.append(EntityRecord.from_data(data=raw)) + else: + items.append(AggregateRow.model_validate(raw)) + + total_count = int(body.get("totalRecordCount", body.get("totalCount", 0)) or 0) + + next_cursor: Optional[str] = body.get("nextCursor") + has_next_page = bool(body.get("hasNextPage", False)) + if next_cursor is None and limit is not None and limit > 0: + consumed = (start or 0) + len(items) + has_next_page = consumed < total_count + + return RetrieveEntityRecordsResponse( + items=items, + total_count=total_count, + has_next_page=has_next_page, + next_cursor=next_cursor, + ) + + @staticmethod + def _parse_choiceset_values(response: Response) -> List[ChoiceSetValue]: + """Decode and return the choice-set values from a query-expansion response.""" + data = response.json() + raw_values = data.get("jsonValue", "[]") + items = ( + json_module.loads(raw_values) if isinstance(raw_values, str) else raw_values + ) + return [ChoiceSetValue.model_validate(item) for item in items] + + # ------------------------------------------------------------------ + # Internal helpers — batch error recovery + # ------------------------------------------------------------------ + + def _request_or_extract_batch( + self, + sync_call: Any, + ) -> Response | EntityRecordsBatchResponse: + """Run a batch request and recover per-record failures from a 400 body. + + On HTTP 400 with a body that lists both ``successRecords`` and + ``failureRecords``, returns the parsed batch response instead of + raising. All other errors propagate. + """ + try: + return sync_call() + except EnrichedException as exc: + extracted = self._extract_batch_response_from_error(exc) + if extracted is not None: + return extracted + raise + + async def _request_or_extract_batch_async( + self, + async_call: Any, + ) -> Response | EntityRecordsBatchResponse: + """Async variant of :meth:`_request_or_extract_batch`.""" + try: + return await async_call() + except EnrichedException as exc: + extracted = self._extract_batch_response_from_error(exc) + if extracted is not None: + return extracted + raise + + @staticmethod + def _extract_batch_response_from_error( + exc: EnrichedException, + ) -> Optional[EntityRecordsBatchResponse]: + """Return a parsed batch response when the error body matches the per-record-failure shape. + + Recovery is intentionally narrow: only HTTP 400 with a JSON object + containing list-typed ``successRecords`` and ``failureRecords`` keys. + Returns ``None`` for any other status, body shape, or parse failure + so that the original error propagates. + """ + cause = exc.__cause__ + if not isinstance(cause, HTTPStatusError): + return None + if cause.response.status_code != 400: + return None + try: + data = cause.response.json() + except Exception: + return None + if not isinstance(data, dict): + return None + if not ( + isinstance(data.get("successRecords"), list) + and isinstance(data.get("failureRecords"), list) + ): + return None + try: + return EntityRecordsBatchResponse.model_validate(data) + except Exception: + return None + + # ------------------------------------------------------------------ + # Internal helpers — SQL validation (federated query path) + # ------------------------------------------------------------------ + + def _validate_sql_query(self, sql_query: str) -> None: + """Validate a SQL string for the federated query endpoint client-side.""" + query = sql_query.strip().rstrip(";").strip() + if not query: + raise ValueError("SQL query cannot be empty.") + + statements = sqlparse.parse(query) + if len(statements) != 1 or not statements[0].tokens: + raise ValueError("Only a single SELECT statement is allowed.") + + stmt = statements[0] + stmt_type = stmt.get_type() + + if stmt_type != "SELECT": + raise ValueError("Only SELECT statements are allowed.") + + keywords = set() + for token in stmt.flatten(): + if token.ttype in Keyword: + keywords.add(token.normalized) + + for kw in _FORBIDDEN_DML: + if kw in keywords: + raise ValueError(f"SQL keyword '{kw}' is not allowed.") + + for kw in _FORBIDDEN_DDL: + if kw in keywords: + raise ValueError(f"SQL keyword '{kw}' is not allowed.") + + for kw in _DISALLOWED_KEYWORDS: + if kw in keywords: + raise ValueError( + f"SQL construct '{kw}' is not allowed in entity queries." + ) + + if self._has_subquery(stmt): + raise ValueError("Subqueries are not allowed.") + + has_where = any(isinstance(t, Where) for t in stmt.tokens) + has_limit = "LIMIT" in keywords + has_from = "FROM" in keywords + + if not has_from: + raise ValueError("Queries must include a FROM clause.") + + projection = self._projection_tokens(stmt) + + if self._projection_has_count_star(projection): + raise ValueError( + "COUNT(*) is not supported. Use COUNT(column_name) instead." + ) + + has_aggregate = self._projection_has_aggregate(projection) + + if not has_where and not has_limit and not has_aggregate: + raise ValueError("Queries without WHERE must include a LIMIT clause.") + + has_bare_wildcard = self._projection_has_bare_wildcard(projection) + if has_bare_wildcard: + raise ValueError("SELECT * is not allowed. Specify column names instead.") + if not has_where and self._projection_column_count(projection) > 4: + raise ValueError( + "Selecting more than 4 columns without filtering is not allowed." + ) + + @staticmethod + def _projection_has_aggregate( + projection: List[sqlparse.sql.Token], + ) -> bool: + """Return ``True`` when the projection contains an aggregate function call.""" + + def _has_agg(token: sqlparse.sql.Token) -> bool: + if isinstance(token, Function): + return token.get_name().upper() in _AGGREGATE_FUNCTIONS + if isinstance(token, Identifier): + return any(_has_agg(child) for child in token.tokens) + return False + + for node in projection: + if _has_agg(node): + return True + if isinstance(node, IdentifierList): + if any(_has_agg(child) for child in node.tokens): + return True + return False + + @staticmethod + def _projection_has_count_star( + projection: List[sqlparse.sql.Token], + ) -> bool: + """Return ``True`` when the projection contains ``COUNT(*)``.""" + + def _is_count_star(func: Function) -> bool: + if func.get_name().upper() != "COUNT": + return False + return any(t.ttype is Wildcard for t in func.flatten()) + + def _has_count_star(token: sqlparse.sql.Token) -> bool: + if isinstance(token, Function): + return _is_count_star(token) + if isinstance(token, Identifier): + return any(_has_count_star(child) for child in token.tokens) + return False + + for node in projection: + if _has_count_star(node): + return True + if isinstance(node, IdentifierList): + if any(_has_count_star(child) for child in node.tokens): + return True + return False + + @staticmethod + def _projection_has_bare_wildcard( + projection: List[sqlparse.sql.Token], + ) -> bool: + """Return ``True`` for a bare ``*`` or qualified ``table.*`` outside a function.""" + + def _identifier_has_wildcard(ident: Identifier) -> bool: + return any(t.ttype is Wildcard for t in ident.tokens) + + for node in projection: + if node.ttype is Wildcard: + return True + if isinstance(node, Identifier) and _identifier_has_wildcard(node): + return True + if isinstance(node, IdentifierList): + for child in node.tokens: + if child.ttype is Wildcard: + return True + if isinstance(child, Identifier) and _identifier_has_wildcard( + child + ): + return True + return False + + @staticmethod + def _has_subquery(stmt: sqlparse.sql.Statement) -> bool: + """Recursively walk the AST looking for a SELECT inside parentheses.""" + + def _walk(token: sqlparse.sql.Token) -> bool: + if isinstance(token, Parenthesis): + for child in token.flatten(): + if child.ttype is DML and child.normalized == "SELECT": + return True + if hasattr(token, "tokens"): + for child in token.tokens: + if _walk(child): + return True + return False + + for token in stmt.tokens: + if _walk(token): + return True + return False + + @staticmethod + def _projection_tokens( + stmt: sqlparse.sql.Statement, + ) -> List[sqlparse.sql.Token]: + """Return the non-flattened AST nodes between the first SELECT and FROM.""" + tokens: List[sqlparse.sql.Token] = [] + collecting = False + for token in stmt.tokens: + if token.ttype is DML and token.normalized == "SELECT": + collecting = True + continue + if token.ttype is Keyword and token.normalized in ("FROM", "INTO"): + break + if token.ttype is Keyword and token.normalized == "DISTINCT": + continue + if collecting and token.ttype is not Whitespace: + tokens.append(token) + return tokens + + @staticmethod + def _projection_column_count( + projection: List[sqlparse.sql.Token], + ) -> int: + """Return the number of columns referenced by the projection.""" + for node in projection: + if isinstance(node, IdentifierList): + return len(list(node.get_identifiers())) + if isinstance(node, (Identifier, Function)): + return 1 + if node.ttype is Wildcard: + return 1 + return 0 diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entity_schema_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entity_schema_service.py new file mode 100644 index 000000000..9872969d6 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/entities/_entity_schema_service.py @@ -0,0 +1,522 @@ +"""Schema-side operations for the Data Fabric entities surface. + +Handles entity definitions, choice set listings, and the create / delete / +update-metadata lifecycle that targets the backend ``EntityController``. +Record CRUD, queries, attachments, and bulk import live on +:class:`EntityDataService` and are mediated by :class:`EntitiesService`. +""" + +import re +from typing import Any, Dict, List, Optional + +from httpx import Response + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._models import Endpoint, RequestSpec +from ..common.constants import HEADER_FOLDER_KEY +from ..orchestrator._folder_service import FolderService +from .entities import ( + ENTITY_FIELD_CONSTRAINT_DEFAULTS, + ENTITY_FIELD_CONSTRAINT_SPEC, + ENTITY_SCHEMA_FIELD_TYPE_MAP, + RESERVED_FIELD_NAMES, + Entity, + EntityCreateFieldOptions, + EntityCreateOptions, + EntityFieldDataType, + EntityMetadataUpdateOptions, +) + +DATA_FABRIC_TENANT_FOLDER_ID = "00000000-0000-0000-0000-000000000000" + +_NAME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9]*$") +"""Entity and field name pattern: must start with a letter, then letters and digits only. + +Matches the UI's create-entity / create-field form validators so any name accepted +here can later be displayed or edited through the Data Service UI. +""" + +_ENTITY_NAME_MIN_LENGTH = 1 +_ENTITY_NAME_MAX_LENGTH = 30 +_FIELD_NAME_MIN_LENGTH = 3 +_FIELD_NAME_MAX_LENGTH = 100 + + +class EntitySchemaService(BaseService): + """HTTP service for entity-schema operations. + + Provides retrieval and lifecycle management for entities and choice sets. + Backend target: ``datafabric_/api/Entity``. + + See Also: + https://docs.uipath.com/data-service/automation-cloud/latest/user-guide/introduction + + !!! warning "Preview Feature" + This service is currently experimental. Behavior and parameters are + subject to change in future versions. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Optional[FolderService] = None, + ) -> None: + """Initialise the schema service.""" + super().__init__(config=config, execution_context=execution_context) + self._folders_service = folders_service + + def retrieve(self, entity_key: str) -> Entity: + """Internal implementation; see :meth:`EntitiesService.retrieve`.""" + spec = self._retrieve_spec(entity_key) + response = self.request(spec.method, spec.endpoint) + return Entity.model_validate(response.json()) + + async def retrieve_async(self, entity_key: str) -> Entity: + """Async variant of :meth:`retrieve`.""" + spec = self._retrieve_spec(entity_key) + response = await self.request_async(spec.method, spec.endpoint) + return Entity.model_validate(response.json()) + + def retrieve_by_name( + self, entity_name: str, folder_key: Optional[str] = None + ) -> Entity: + """Internal implementation; see :meth:`EntitiesService.retrieve_by_name`.""" + spec = self._retrieve_by_name_spec(entity_name) + headers = self._folder_key_headers(folder_key) + response = self.request(spec.method, spec.endpoint, headers=headers) + return Entity.model_validate(response.json()) + + async def retrieve_by_name_async( + self, entity_name: str, folder_key: Optional[str] = None + ) -> Entity: + """Async variant of :meth:`retrieve_by_name`.""" + spec = self._retrieve_by_name_spec(entity_name) + headers = self._folder_key_headers(folder_key) + response = await self.request_async(spec.method, spec.endpoint, headers=headers) + return Entity.model_validate(response.json()) + + def list_entities(self) -> List[Entity]: + """Internal implementation; see :meth:`EntitiesService.list_entities`.""" + spec = self._list_entities_spec() + response = self.request(spec.method, spec.endpoint) + entities_data = response.json() + return [Entity.model_validate(entity) for entity in entities_data] + + async def list_entities_async(self) -> List[Entity]: + """Async variant of :meth:`list_entities`.""" + spec = self._list_entities_spec() + response = await self.request_async(spec.method, spec.endpoint) + entities_data = response.json() + return [Entity.model_validate(entity) for entity in entities_data] + + def list_choicesets(self) -> List[Entity]: + """Internal implementation; see :meth:`EntitiesService.list_choicesets`.""" + spec = self._list_choicesets_spec() + response = self.request(spec.method, spec.endpoint) + return [Entity.model_validate(item) for item in response.json()] + + async def list_choicesets_async(self) -> List[Entity]: + """Async variant of :meth:`list_choicesets`.""" + spec = self._list_choicesets_spec() + response = await self.request_async(spec.method, spec.endpoint) + return [Entity.model_validate(item) for item in response.json()] + + def create_entity( + self, + name: str, + fields: List[EntityCreateFieldOptions], + options: Optional[EntityCreateOptions] = None, + ) -> str: + """Internal implementation; see :meth:`EntitiesService.create_entity`.""" + spec = self._create_entity_spec(name, fields, options) + response = self.request(spec.method, spec.endpoint, json=spec.json) + return self._extract_entity_id(response) + + async def create_entity_async( + self, + name: str, + fields: List[EntityCreateFieldOptions], + options: Optional[EntityCreateOptions] = None, + ) -> str: + """Async variant of :meth:`create_entity`.""" + spec = self._create_entity_spec(name, fields, options) + response = await self.request_async(spec.method, spec.endpoint, json=spec.json) + return self._extract_entity_id(response) + + def delete_entity(self, entity_id: str) -> None: + """Delete an entity and all of its records.""" + spec = self._delete_entity_spec(entity_id) + self.request(spec.method, spec.endpoint) + + async def delete_entity_async(self, entity_id: str) -> None: + """Async variant of :meth:`delete_entity`.""" + spec = self._delete_entity_spec(entity_id) + await self.request_async(spec.method, spec.endpoint) + + def update_entity_metadata( + self, + entity_id: str, + metadata: EntityMetadataUpdateOptions | Dict[str, Any], + ) -> None: + """Internal implementation; see :meth:`EntitiesService.update_entity_metadata`.""" + spec = self._update_entity_metadata_spec(entity_id, metadata) + self.request(spec.method, spec.endpoint, json=spec.json) + + async def update_entity_metadata_async( + self, + entity_id: str, + metadata: EntityMetadataUpdateOptions | Dict[str, Any], + ) -> None: + """Async variant of :meth:`update_entity_metadata`.""" + spec = self._update_entity_metadata_spec(entity_id, metadata) + await self.request_async(spec.method, spec.endpoint, json=spec.json) + + # ------------------------------------------------------------------ + # Request-spec builders + # ------------------------------------------------------------------ + + @staticmethod + def _retrieve_spec(entity_key: str) -> RequestSpec: + """Build the GET spec for fetching an entity by key.""" + return RequestSpec( + method="GET", + endpoint=Endpoint(f"datafabric_/api/Entity/{entity_key}"), + ) + + @staticmethod + def _retrieve_by_name_spec(entity_name: str) -> RequestSpec: + """Build the GET spec for fetching an entity by name.""" + return RequestSpec( + method="GET", + endpoint=Endpoint(f"datafabric_/api/Entity/{entity_name}/metadata"), + ) + + @staticmethod + def _folder_key_headers(folder_key: Optional[str]) -> Dict[str, str]: + """Return the folder-key header dict, empty when no key is supplied.""" + if folder_key: + return {HEADER_FOLDER_KEY: folder_key} + return {} + + @staticmethod + def _list_entities_spec() -> RequestSpec: + """Build the GET spec for listing all entities (non-choice-sets).""" + return RequestSpec( + method="GET", + endpoint=Endpoint("datafabric_/api/Entity"), + ) + + @staticmethod + def _list_choicesets_spec() -> RequestSpec: + """Build the GET spec for listing all choice sets.""" + return RequestSpec( + method="GET", + endpoint=Endpoint("datafabric_/api/Entity/choiceset"), + ) + + @classmethod + def _create_entity_spec( + cls, + name: str, + fields: List[EntityCreateFieldOptions], + options: Optional[EntityCreateOptions] = None, + ) -> RequestSpec: + """Build the POST spec for creating an entity with its field schema.""" + cls._validate_name(name, "entity") + for field in fields: + cls._validate_name(field.field_name, "field") + opts = options or EntityCreateOptions() + # The user-facing option ``is_analytics_enabled`` maps to the legacy + # backend field name ``isInsightsEnabled`` — the wire name predates + # the "Analytics" UI rename. + payload: Dict[str, Any] = { + "displayName": opts.display_name or name, + "entityDefinition": { + "name": name, + "fields": [cls._build_schema_field_payload(f) for f in fields], + "folderId": opts.folder_key or DATA_FABRIC_TENANT_FOLDER_ID, + "isRbacEnabled": bool(opts.is_rbac_enabled or False), + "isInsightsEnabled": bool(opts.is_analytics_enabled or False), + "externalFields": opts.external_fields or [], + }, + } + if opts.description is not None: + payload["description"] = opts.description + return RequestSpec( + method="POST", + endpoint=Endpoint("datafabric_/api/Entity"), + json=payload, + ) + + @staticmethod + def _delete_entity_spec(entity_id: str) -> RequestSpec: + """Build the DELETE spec for removing an entity.""" + return RequestSpec( + method="DELETE", + endpoint=Endpoint(f"datafabric_/api/Entity/{entity_id}"), + ) + + @staticmethod + def _update_entity_metadata_spec( + entity_id: str, + metadata: EntityMetadataUpdateOptions | Dict[str, Any], + ) -> RequestSpec: + """Build the PATCH spec for updating entity metadata. + + Dict inputs are validated through :class:`EntityMetadataUpdateOptions` + so snake_case keys (``display_name``) and camelCase keys + (``displayName``) both serialise to the API field names the backend + expects. + """ + if not isinstance(metadata, EntityMetadataUpdateOptions): + metadata = EntityMetadataUpdateOptions.model_validate(metadata) + body = metadata.model_dump(by_alias=True, exclude_none=True) + return RequestSpec( + method="PATCH", + endpoint=Endpoint(f"datafabric_/api/Entity/{entity_id}/metadata"), + json=body, + ) + + @classmethod + def _build_schema_field_payload( + cls, field: EntityCreateFieldOptions + ) -> Dict[str, Any]: + """Build the API field payload for a single field on create-entity. + + Maps :class:`EntityFieldDataType` to the backend's ``sqlType.name`` and + ``fieldDisplayType`` (e.g. ``STRING`` becomes ``NVARCHAR`` / ``Basic``). + Caller-supplied constraints are validated against + :data:`ENTITY_FIELD_CONSTRAINT_SPEC`; unsupplied per-type constraints + fall back to :data:`ENTITY_FIELD_CONSTRAINT_DEFAULTS` so the field is + persisted fully and remains editable later. + """ + ftype = field.type or EntityFieldDataType.STRING + cls._validate_name(field.field_name, "field") + cls._validate_field_constraints(ftype, field) + + sql_type_name, field_display_type = ENTITY_SCHEMA_FIELD_TYPE_MAP[ftype] + sql_type: Dict[str, Any] = {"name": sql_type_name} + sql_type.update(cls._build_sql_type_constraints(ftype, field)) + + payload: Dict[str, Any] = { + "name": field.field_name, + "displayName": field.display_name or field.field_name, + "sqlType": sql_type, + "fieldDisplayType": field_display_type, + "description": field.description or "", + "isRequired": bool(field.is_required or False), + "isUnique": bool(field.is_unique or False), + "isRbacEnabled": bool(field.is_rbac_enabled or False), + "isEncrypted": bool(field.is_encrypted or False), + } + if field.default_value is not None: + payload["defaultValue"] = field.default_value + if field.choice_set_id is not None: + payload["choiceSetId"] = field.choice_set_id + if field.reference_entity_name is not None: + payload["referenceEntityName"] = field.reference_entity_name + if field.reference_field_name is not None: + payload["referenceFieldName"] = field.reference_field_name + return payload + + @staticmethod + def _build_sql_type_constraints( + ftype: EntityFieldDataType, field: EntityCreateFieldOptions + ) -> Dict[str, Any]: + """Return the ``sqlType`` constraint fields required for ``ftype``. + + Caller-supplied values override defaults where the type accepts them; + types that take no constraints (UUID, DATETIME, CHOICE_SET_SINGLE, + AUTO_NUMBER) return an empty dict. + """ + d = ENTITY_FIELD_CONSTRAINT_DEFAULTS + if ftype is EntityFieldDataType.STRING: + return {"lengthLimit": field.length_limit or d["STRING_LENGTH_LIMIT"]} + if ftype is EntityFieldDataType.MULTILINE_TEXT: + return { + "lengthLimit": field.length_limit or d["MULTILINE_TEXT_LENGTH_LIMIT"] + } + if ftype is EntityFieldDataType.DECIMAL: + return { + "lengthLimit": d["DECIMAL_LENGTH_LIMIT"], + "decimalPrecision": ( + field.decimal_precision + if field.decimal_precision is not None + else d["DECIMAL_PRECISION"] + ), + "maxValue": ( + field.max_value + if field.max_value is not None + else d["NUMERIC_MAX_VALUE"] + ), + "minValue": ( + field.min_value + if field.min_value is not None + else d["NUMERIC_MIN_VALUE"] + ), + } + if ftype is EntityFieldDataType.BOOLEAN: + return {"lengthLimit": d["BOOLEAN_LENGTH_LIMIT"]} + if ftype in ( + EntityFieldDataType.DATE, + EntityFieldDataType.DATETIME_WITH_TZ, + ): + return {"lengthLimit": d["DATE_LENGTH_LIMIT"]} + if ftype in (EntityFieldDataType.INTEGER, EntityFieldDataType.BIG_INTEGER): + return { + "maxValue": ( + field.max_value + if field.max_value is not None + else d["NUMERIC_MAX_VALUE"] + ), + "minValue": ( + field.min_value + if field.min_value is not None + else d["NUMERIC_MIN_VALUE"] + ), + } + if ftype in (EntityFieldDataType.FLOAT, EntityFieldDataType.DOUBLE): + return { + "decimalPrecision": ( + field.decimal_precision + if field.decimal_precision is not None + else d["DECIMAL_PRECISION"] + ), + "maxValue": ( + field.max_value + if field.max_value is not None + else d["NUMERIC_MAX_VALUE"] + ), + "minValue": ( + field.min_value + if field.min_value is not None + else d["NUMERIC_MIN_VALUE"] + ), + } + if ftype in (EntityFieldDataType.FILE, EntityFieldDataType.RELATIONSHIP): + return {"lengthLimit": d["UNIQUEIDENTIFIER_LENGTH_LIMIT"]} + if ftype is EntityFieldDataType.CHOICE_SET_MULTIPLE: + return {"lengthLimit": d["CHOICE_SET_MULTIPLE_LENGTH_LIMIT"]} + # UUID, DATETIME, CHOICE_SET_SINGLE, AUTO_NUMBER — no constraints + return {} + + @staticmethod + def _validate_name(name: str, context: str) -> None: + r"""Validate an entity or field name against the UI's create-form rules. + + Entity names must be 1-30 characters; field names must be 3-100 + characters. Both must match ``^[a-zA-Z][a-zA-Z0-9]*$`` — start with a + letter, then letters or digits only (underscores are not permitted, to + stay consistent with the UI's entity / field creation forms). + + Field names additionally cannot collide with the system-reserved field + names in :data:`RESERVED_FIELD_NAMES`; the reserved-name check runs + first so that short reserved names produce a more informative error. + """ + if context == "field": + if name in RESERVED_FIELD_NAMES: + reserved = ", ".join(sorted(RESERVED_FIELD_NAMES)) + raise ValueError( + f"Field name {name!r} is reserved. Reserved names: {reserved}." + ) + min_len, max_len = _FIELD_NAME_MIN_LENGTH, _FIELD_NAME_MAX_LENGTH + else: + min_len, max_len = _ENTITY_NAME_MIN_LENGTH, _ENTITY_NAME_MAX_LENGTH + + if not (min_len <= len(name) <= max_len) or not _NAME_RE.match(name): + raise ValueError( + f"Invalid {context} name {name!r}. Must start with a letter, " + f"contain only letters and digits, and be {min_len}-{max_len} " + "characters." + ) + + @staticmethod + def _validate_field_constraints( + ftype: EntityFieldDataType, field: EntityCreateFieldOptions + ) -> None: + """Validate caller-supplied per-field constraints. + + Rejects constraints that ``ftype`` does not accept (e.g. + ``decimal_precision`` on ``STRING``), values outside the inclusive + range declared in :data:`ENTITY_FIELD_CONSTRAINT_SPEC`, and + ``min_value`` greater than or equal to ``max_value`` when both are + supplied. Also enforces type-dependent required references: + ``CHOICE_SET_SINGLE`` and ``CHOICE_SET_MULTIPLE`` need + ``choice_set_id``; ``RELATIONSHIP`` needs ``reference_entity_name``. + """ + if ( + ftype + in ( + EntityFieldDataType.CHOICE_SET_SINGLE, + EntityFieldDataType.CHOICE_SET_MULTIPLE, + ) + and not field.choice_set_id + ): + raise ValueError( + f"Field {field.field_name!r} of type {ftype.value} requires " + "choice_set_id." + ) + if ( + ftype is EntityFieldDataType.RELATIONSHIP + and not field.reference_entity_name + ): + raise ValueError( + f"Field {field.field_name!r} of type {ftype.value} requires " + "reference_entity_name." + ) + + spec = ENTITY_FIELD_CONSTRAINT_SPEC.get(ftype, {}) + provided: Dict[str, Any] = {} + for attr in ("length_limit", "max_value", "min_value", "decimal_precision"): + value = getattr(field, attr) + if value is not None: + provided[attr] = value + + unsupported = [name for name in provided if name not in spec] + if unsupported: + allowed = ", ".join(sorted(spec.keys())) or "none" + raise ValueError( + f"Field {field.field_name!r} of type {ftype.value} does not accept " + f"{', '.join(sorted(unsupported))}. Allowed constraints: {allowed}." + ) + + for name, value in provided.items(): + low, high = spec[name] + if not (low <= value <= high): + raise ValueError( + f"Field {field.field_name!r} of type {ftype.value}: " + f"{name}={value} is out of range [{low}, {high}]." + ) + + if ( + field.min_value is not None + and field.max_value is not None + and field.min_value >= field.max_value + ): + raise ValueError( + f"Field {field.field_name!r}: min_value ({field.min_value}) must be " + f"strictly less than max_value ({field.max_value})." + ) + + @staticmethod + def _extract_entity_id(response: Response) -> str: + """Return the new entity id from a create-entity response. + + Accepts both a bare JSON string id and a JSON object containing + ``id`` or ``entityId``. + """ + try: + body = response.json() + except Exception: + return response.text.strip().strip('"') + if isinstance(body, str): + return body + if isinstance(body, dict): + for key in ("id", "Id", "entityId", "EntityId"): + value = body.get(key) + if isinstance(value, str): + return value + return response.text.strip().strip('"') diff --git a/packages/uipath-platform/src/uipath/platform/entities/entities.py b/packages/uipath-platform/src/uipath/platform/entities/entities.py index 48c8dce07..51eea2d1b 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/entities.py +++ b/packages/uipath-platform/src/uipath/platform/entities/entities.py @@ -2,21 +2,30 @@ from __future__ import annotations -from enum import Enum +from enum import Enum, IntEnum from types import EllipsisType from typing import ( TYPE_CHECKING, Any, Dict, + Iterator, List, Optional, Type, Union, get_args, get_origin, + overload, ) -from pydantic import AliasChoices, BaseModel, ConfigDict, Field, create_model +from pydantic import ( + AliasChoices, + BaseModel, + ConfigDict, + Field, + create_model, + model_validator, +) if TYPE_CHECKING: from ._entities_service import EntitiesService @@ -82,8 +91,8 @@ class ExternalConnection(BaseModel): id: str connection_id: str = Field(alias="connectionId") element_instance_id: str = Field(alias="elementInstanceId") - folder_id: str = Field(alias="folderKey") # named folderKey in TS SDK - connector_id: str = Field(alias="connectorKey") # named connectorKey in TS SDK + folder_id: str = Field(alias="folderKey") + connector_id: str = Field(alias="connectorKey") connector_name: str = Field(alias="connectorName") connection_name: str = Field(alias="connectionName") @@ -257,7 +266,7 @@ class EntityRecord(BaseModel): "extra": "allow", } - id: str = Field(alias="Id") # Mandatory field validated by Pydantic + id: str = Field(alias="Id") @classmethod def from_data( @@ -356,6 +365,25 @@ class Entity(BaseModel): id: str +class FailureRecord(BaseModel): + """A record that failed to insert/update/delete in a batch operation. + + Backend error responses for failed records do not always include a valid + ``Id`` field — this model accepts arbitrary shapes so the caller can + inspect ``error`` text and the original ``record`` payload. + """ + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + extra="allow", + ) + + id: Optional[str] = Field(default=None, alias="Id") + error: Optional[str] = Field(default=None) + record: Optional[Dict[str, Any]] = Field(default=None) + + class EntityRecordsBatchResponse(BaseModel): """Model representing a batch response of entity records.""" @@ -364,8 +392,421 @@ class EntityRecordsBatchResponse(BaseModel): validate_by_alias=True, ) - success_records: List[EntityRecord] = Field(alias="successRecords") - failure_records: List[EntityRecord] = Field(alias="failureRecords") + success_records: List[EntityRecord] = Field( + default_factory=list, alias="successRecords" + ) + failure_records: List[FailureRecord] = Field( + default_factory=list, alias="failureRecords" + ) + + +class EntityRecordsListResponse(List[EntityRecord]): + """List of EntityRecord with pagination metadata. + + Subclasses ``list`` so existing call sites that iterate, index, or call + ``len()`` continue to work; new fields ``total_count``, ``has_next_page``, + and ``next_cursor`` expose pagination information returned by the backend. + """ + + def __init__( + self, + items: Optional[List[EntityRecord]] = None, + total_count: int = 0, + has_next_page: bool = False, + next_cursor: Optional[str] = None, + ) -> None: + """Construct from a list of records plus pagination metadata.""" + super().__init__(items or []) + self.total_count = total_count + self.has_next_page = has_next_page + self.next_cursor = next_cursor + + +class LogicalOperator(IntEnum): + """Logical operator for combining query filter groups.""" + + And = 0 + Or = 1 + + +class QueryFilterOperator(str, Enum): + """Comparison operators supported by the structured query API.""" + + Equals = "=" + NotEquals = "!=" + GreaterThan = ">" + LessThan = "<" + GreaterThanOrEqual = ">=" + LessThanOrEqual = "<=" + Contains = "contains" + NotContains = "not contains" + StartsWith = "startswith" + EndsWith = "endswith" + In = "in" + NotIn = "not in" + + +class EntityQueryFilter(BaseModel): + """A single filter condition for querying entity records. + + Backend operator/operand rules: + + * ``in`` / ``not in`` — require a non-empty ``value_list`` and reject + ``value``. + * ``=`` / ``!=`` — allow a null ``value`` (becomes ``IS NULL`` / ``IS + NOT NULL``) and reject ``value_list``. + * All other operators (``>``, ``<``, ``>=``, ``<=``, ``contains``, + ``not contains``, ``startswith``, ``endswith``) — require a non-null + ``value`` and reject ``value_list``. + """ + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + field_name: str = Field(alias="fieldName") + operator: QueryFilterOperator + value: Optional[str] = None + value_list: Optional[List[str]] = Field(default=None, alias="valueList") + + @model_validator(mode="after") + def _check_operator_operands(self) -> "EntityQueryFilter": + """Reject operator/operand combinations the backend rejects. + + Implements the same rules the Data Service ``SelectQueryBuilder`` + enforces server-side, so callers see a clear local error instead of + an opaque HTTP 400. + """ + op = self.operator + if op in (QueryFilterOperator.In, QueryFilterOperator.NotIn): + if not self.value_list: + raise ValueError( + f"Operator {op.value!r} requires a non-empty value_list." + ) + if self.value is not None: + raise ValueError( + f"Operator {op.value!r} uses value_list; value must be omitted." + ) + return self + + if self.value_list is not None: + raise ValueError( + f"Operator {op.value!r} uses value; value_list must be omitted." + ) + if ( + op not in (QueryFilterOperator.Equals, QueryFilterOperator.NotEquals) + and self.value is None + ): + raise ValueError(f"Operator {op.value!r} requires a non-null value.") + return self + + +class EntityQueryFilterGroup(BaseModel): + """A group of query filters combined with a logical operator.""" + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + logical_operator: Optional[LogicalOperator] = Field( + default=None, alias="logicalOperator" + ) + continue_logical_operator: Optional[LogicalOperator] = Field( + default=None, alias="continueLogicalOperator" + ) + query_filters: Optional[List[EntityQueryFilter]] = Field( + default=None, alias="queryFilters" + ) + filter_groups: Optional[List["EntityQueryFilterGroup"]] = Field( + default=None, alias="filterGroups" + ) + + +class EntityQuerySortOption(BaseModel): + """Sort option for query results.""" + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + field_name: str = Field(alias="fieldName") + is_descending: Optional[bool] = Field(default=None, alias="isDescending") + + +class EntityAggregateFunction(str, Enum): + """Aggregate functions supported by the Data Fabric query API.""" + + Count = "COUNT" + Sum = "SUM" + Avg = "AVG" + Min = "MIN" + Max = "MAX" + + +class EntityAggregate(BaseModel): + """A single aggregate expression to apply during a query.""" + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + function: EntityAggregateFunction + field: str + alias: Optional[str] = None + + +class EntityJoin(BaseModel): + """Multi-entity JOIN definition for cross-entity queries.""" + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + entity_name: Optional[str] = Field(default=None, alias="entityName") + join_type: Optional[str] = Field(default=None, alias="joinType") + join_field_name: Optional[str] = Field(default=None, alias="joinFieldName") + related_entity_name: Optional[str] = Field(default=None, alias="relatedEntityName") + related_field_name: Optional[str] = Field(default=None, alias="relatedFieldName") + + +class EntityBinning(BaseModel): + """A binning (GROUP BY/aggregation) clause for V2 query endpoint.""" + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + field_name: Optional[str] = Field(default=None, alias="fieldName") + aggregate_function: Optional[EntityAggregateFunction] = Field( + default=None, alias="aggregateFunction" + ) + alias: Optional[str] = None + + +class AggregateRow(BaseModel): + """A row returned by aggregate / group-by / binning queries. + + Aggregate rows do not have an ``Id`` field; columns vary by query + (``selected_fields``, ``aggregates`` aliases, binning aliases) and are + accessible as attributes via ``extra="allow"``. + """ + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + +class RetrieveEntityRecordsResponse(BaseModel): + """Response from :meth:`EntitiesService.retrieve_records`. + + For plain queries, ``items`` is a list of :class:`EntityRecord`. When the + query uses ``aggregates``, ``group_by``, or ``binnings``, the backend + returns rows without an ``Id`` field; those rows are parsed as + :class:`AggregateRow` instances. + """ + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + items: List[EntityRecord | AggregateRow] = Field(default_factory=list) + total_count: int = Field(default=0, alias="totalCount") + has_next_page: bool = Field(default=False, alias="hasNextPage") + next_cursor: Optional[str] = Field(default=None, alias="nextCursor") + + def __iter__(self) -> Iterator[EntityRecord | AggregateRow]: # type: ignore[override] + """Iterate over records (delegates to ``self.items``).""" + return iter(self.items) + + def __len__(self) -> int: + """Return the number of records (delegates to ``self.items``).""" + return len(self.items) + + @overload + def __getitem__(self, index: int) -> EntityRecord | AggregateRow: ... + + @overload + def __getitem__(self, index: slice) -> List[EntityRecord | AggregateRow]: ... + + def __getitem__( + self, index: int | slice + ) -> EntityRecord | AggregateRow | List[EntityRecord | AggregateRow]: + """Index or slice records (delegates to ``self.items``).""" + return self.items[index] + + +class EntityFieldDataType(str, Enum): + """User-facing entity field data type names accepted by ``create_entity``.""" + + UUID = "UUID" + STRING = "STRING" + INTEGER = "INTEGER" + DATETIME = "DATETIME" + DATETIME_WITH_TZ = "DATETIME_WITH_TZ" + DECIMAL = "DECIMAL" + FLOAT = "FLOAT" + DOUBLE = "DOUBLE" + DATE = "DATE" + BOOLEAN = "BOOLEAN" + BIG_INTEGER = "BIG_INTEGER" + MULTILINE_TEXT = "MULTILINE_TEXT" + FILE = "FILE" + CHOICE_SET_SINGLE = "CHOICE_SET_SINGLE" + CHOICE_SET_MULTIPLE = "CHOICE_SET_MULTIPLE" + AUTO_NUMBER = "AUTO_NUMBER" + RELATIONSHIP = "RELATIONSHIP" + + +# Maps the user-facing EntityFieldDataType to the ``(sqlType.name, fieldDisplayType)`` +# tuple expected by the backend when creating an entity. ``sqlType.name`` is +# the raw SQL Server type the backend persists; ``fieldDisplayType`` controls +# how the field renders in the UI. +ENTITY_SCHEMA_FIELD_TYPE_MAP: Dict[EntityFieldDataType, "tuple[str, str]"] = { + EntityFieldDataType.UUID: ("UNIQUEIDENTIFIER", "Basic"), + EntityFieldDataType.STRING: ("NVARCHAR", "Basic"), + EntityFieldDataType.INTEGER: ("INT", "Basic"), + EntityFieldDataType.DATETIME: ("DATETIME2", "Basic"), + EntityFieldDataType.DATETIME_WITH_TZ: ("DATETIMEOFFSET", "Basic"), + EntityFieldDataType.DECIMAL: ("DECIMAL", "Basic"), + EntityFieldDataType.FLOAT: ("FLOAT", "Basic"), + EntityFieldDataType.DOUBLE: ("REAL", "Basic"), + EntityFieldDataType.DATE: ("DATE", "Basic"), + EntityFieldDataType.BOOLEAN: ("BIT", "Basic"), + EntityFieldDataType.BIG_INTEGER: ("BIGINT", "Basic"), + EntityFieldDataType.MULTILINE_TEXT: ("MULTILINE", "Basic"), + EntityFieldDataType.FILE: ("UNIQUEIDENTIFIER", "File"), + EntityFieldDataType.CHOICE_SET_SINGLE: ("INT", "ChoiceSetSingle"), + EntityFieldDataType.CHOICE_SET_MULTIPLE: ("NVARCHAR", "ChoiceSetMultiple"), + EntityFieldDataType.AUTO_NUMBER: ("DECIMAL", "AutoNumber"), + EntityFieldDataType.RELATIONSHIP: ("UNIQUEIDENTIFIER", "Relationship"), +} + +# Default and fixed sqlType constraint values applied when the caller does +# not supply them. The backend requires these on field creation — without +# them the field is stored in an incomplete state and the UI later fails +# with "Field type cannot be changed" when editing advanced options. +ENTITY_FIELD_CONSTRAINT_DEFAULTS: Dict[str, int] = { + "STRING_LENGTH_LIMIT": 200, + "MULTILINE_TEXT_LENGTH_LIMIT": 200, + "DECIMAL_LENGTH_LIMIT": 1000, + "DECIMAL_PRECISION": 2, + "BOOLEAN_LENGTH_LIMIT": 100, + "DATE_LENGTH_LIMIT": 1000, + "UNIQUEIDENTIFIER_LENGTH_LIMIT": 300, + "CHOICE_SET_MULTIPLE_LENGTH_LIMIT": 4000, + "NUMERIC_MAX_VALUE": 1_000_000_000_000, + "NUMERIC_MIN_VALUE": -1_000_000_000_000, +} + +# Per-field-type spec describing which user-supplied constraints are valid +# and their inclusive ranges. Field types absent from this map (BOOLEAN, +# DATE, DATETIME, DATETIME_WITH_TZ, FILE, RELATIONSHIP, UUID, CHOICE_SET_*, +# AUTO_NUMBER) accept no user-supplied constraints — passing one raises +# ``ValueError`` so the caller gets a clear local error before any HTTP call. +_MAX_SAFE_INTEGER = 9_007_199_254_740_991 + +ENTITY_FIELD_CONSTRAINT_SPEC: Dict[ + EntityFieldDataType, Dict[str, "tuple[int, int]"] +] = { + EntityFieldDataType.STRING: { + "length_limit": (1, 4000), + }, + EntityFieldDataType.MULTILINE_TEXT: { + "length_limit": (1, 10000), + }, + EntityFieldDataType.INTEGER: { + "max_value": (-_MAX_SAFE_INTEGER, _MAX_SAFE_INTEGER), + "min_value": (-_MAX_SAFE_INTEGER, _MAX_SAFE_INTEGER), + }, + EntityFieldDataType.BIG_INTEGER: { + "max_value": (-_MAX_SAFE_INTEGER, _MAX_SAFE_INTEGER), + "min_value": (-_MAX_SAFE_INTEGER, _MAX_SAFE_INTEGER), + }, + EntityFieldDataType.DECIMAL: { + "max_value": (-_MAX_SAFE_INTEGER, _MAX_SAFE_INTEGER), + "min_value": (-_MAX_SAFE_INTEGER, _MAX_SAFE_INTEGER), + "decimal_precision": (0, 10), + }, + EntityFieldDataType.FLOAT: { + "max_value": (-_MAX_SAFE_INTEGER, _MAX_SAFE_INTEGER), + "min_value": (-_MAX_SAFE_INTEGER, _MAX_SAFE_INTEGER), + "decimal_precision": (0, 10), + }, + EntityFieldDataType.DOUBLE: { + "max_value": (-_MAX_SAFE_INTEGER, _MAX_SAFE_INTEGER), + "min_value": (-_MAX_SAFE_INTEGER, _MAX_SAFE_INTEGER), + "decimal_precision": (0, 10), + }, +} + +RESERVED_FIELD_NAMES = frozenset( + ["Id", "CreatedBy", "CreateTime", "UpdatedBy", "UpdateTime"] +) +"""Field names reserved by the backend — using one as a user field name is rejected.""" + + +class EntityCreateFieldOptions(BaseModel): + """User-facing field definition for creating or updating entity schemas.""" + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + field_name: str = Field(alias="fieldName") + type: Optional[EntityFieldDataType] = Field( + default=EntityFieldDataType.STRING, alias="type" + ) + display_name: Optional[str] = Field(default=None, alias="displayName") + description: Optional[str] = None + is_required: Optional[bool] = Field(default=None, alias="isRequired") + is_unique: Optional[bool] = Field(default=None, alias="isUnique") + is_rbac_enabled: Optional[bool] = Field(default=None, alias="isRbacEnabled") + is_encrypted: Optional[bool] = Field(default=None, alias="isEncrypted") + default_value: Optional[str] = Field(default=None, alias="defaultValue") + length_limit: Optional[int] = Field(default=None, alias="lengthLimit") + max_value: Optional[int] = Field(default=None, alias="maxValue") + min_value: Optional[int] = Field(default=None, alias="minValue") + decimal_precision: Optional[int] = Field(default=None, alias="decimalPrecision") + choice_set_id: Optional[str] = Field(default=None, alias="choiceSetId") + reference_entity_name: Optional[str] = Field( + default=None, alias="referenceEntityName" + ) + reference_field_name: Optional[str] = Field( + default=None, alias="referenceFieldName" + ) + + +class EntityCreateOptions(BaseModel): + """Options for creating a new Data Fabric entity.""" + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + display_name: Optional[str] = Field(default=None, alias="displayName") + description: Optional[str] = None + folder_key: Optional[str] = Field(default=None, alias="folderKey") + is_rbac_enabled: Optional[bool] = Field(default=None, alias="isRbacEnabled") + is_analytics_enabled: Optional[bool] = Field( + default=None, alias="isAnalyticsEnabled" + ) + external_fields: Optional[List[Dict[str, Any]]] = Field( + default=None, alias="externalFields" + ) + + +class EntityMetadataUpdateOptions(BaseModel): + """Options for updating an entity's metadata via PATCH /metadata.""" + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + display_name: Optional[str] = Field(default=None, alias="displayName") + description: Optional[str] = None + is_rbac_enabled: Optional[bool] = Field(default=None, alias="isRbacEnabled") + + +class EntityImportRecordsResponse(BaseModel): + """Response from a bulk import operation.""" + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + total_records: int = Field(default=0, alias="totalRecords") + inserted_records: int = Field(default=0, alias="insertedRecords") + error_file_link: Optional[str] = Field(default=None, alias="errorFileLink") class EntityRouting(BaseModel): @@ -412,3 +853,4 @@ class EntitySetResolution(BaseModel): Entity.model_rebuild() +EntityQueryFilterGroup.model_rebuild() diff --git a/packages/uipath-platform/tests/services/test_entities_service.py b/packages/uipath-platform/tests/services/test_entities_service.py index 29ce6fb79..258c3a9d8 100644 --- a/packages/uipath-platform/tests/services/test_entities_service.py +++ b/packages/uipath-platform/tests/services/test_entities_service.py @@ -15,6 +15,7 @@ ) from uipath.platform.entities import ChoiceSetValue, DataFabricEntityItem, Entity from uipath.platform.entities._entities_service import EntitiesService +from uipath.platform.entities._entity_data_service import EntityDataService @pytest.fixture @@ -325,7 +326,7 @@ def test_retrieve_records_without_start_and_limit( def test_validate_sql_query_allows_supported_select_queries( self, sql_query: str, service: EntitiesService ) -> None: - service._validate_sql_query(sql_query) + service._data._validate_sql_query(sql_query) @pytest.mark.parametrize( "sql_query,error_message", @@ -415,20 +416,20 @@ def test_validate_sql_query_rejects_disallowed_queries( self, sql_query: str, error_message: str, service: EntitiesService ) -> None: with pytest.raises(ValueError, match=re.escape(error_message)): - service._validate_sql_query(sql_query) + service._data._validate_sql_query(sql_query) def test_query_entity_records_rejects_invalid_sql_before_network_call( self, service: EntitiesService, ) -> None: - service.request = MagicMock() # type: ignore[method-assign] + service._data.request = MagicMock() # type: ignore[method-assign] with pytest.raises( ValueError, match=re.escape("Only SELECT statements are allowed.") ): service.query_entity_records("UPDATE Customers SET name = 'X'") - service.request.assert_not_called() + service._data.request.assert_not_called() def test_query_entity_records_calls_request_for_valid_sql( self, @@ -437,26 +438,26 @@ def test_query_entity_records_calls_request_for_valid_sql( response = MagicMock() response.json.return_value = {"results": [{"id": 1}, {"id": 2}]} - service.request = MagicMock(return_value=response) # type: ignore[method-assign] + service._data.request = MagicMock(return_value=response) # type: ignore[method-assign] result = service.query_entity_records("SELECT id FROM Customers WHERE id > 0") assert result == [{"id": 1}, {"id": 2}] - service.request.assert_called_once() + service._data.request.assert_called_once() @pytest.mark.anyio async def test_query_entity_records_async_rejects_invalid_sql_before_network_call( self, service: EntitiesService, ) -> None: - service.request_async = AsyncMock() # type: ignore[method-assign] + service._data.request_async = AsyncMock() # type: ignore[method-assign] with pytest.raises(ValueError, match=re.escape("Subqueries are not allowed.")): await service.query_entity_records_async( "SELECT id FROM Customers WHERE id IN (SELECT id FROM Orders)" ) - service.request_async.assert_not_called() + service._data.request_async.assert_not_called() @pytest.mark.anyio async def test_query_entity_records_async_calls_request_for_valid_sql( @@ -466,14 +467,14 @@ async def test_query_entity_records_async_calls_request_for_valid_sql( response = MagicMock() response.json.return_value = {"results": [{"id": "c1"}]} - service.request_async = AsyncMock(return_value=response) # type: ignore[method-assign] + service._data.request_async = AsyncMock(return_value=response) # type: ignore[method-assign] result = await service.query_entity_records_async( "SELECT id FROM Customers WHERE id = 'c1'" ) assert result == [{"id": "c1"}] - service.request_async.assert_called_once() + service._data.request_async.assert_called_once() def test_query_entity_records_builds_routing_context_from_folders_map( self, @@ -487,12 +488,12 @@ def test_query_entity_records_builds_routing_context_from_folders_map( ) response = MagicMock() response.json.return_value = {"results": [{"id": 1}]} - service.request = MagicMock(return_value=response) # type: ignore[method-assign] + service._data.request = MagicMock(return_value=response) # type: ignore[method-assign] result = service.query_entity_records("SELECT id FROM Customers LIMIT 10") assert result == [{"id": 1}] - call_kwargs = service.request.call_args + call_kwargs = service._data.request.call_args body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") assert body["query"] == "SELECT id FROM Customers LIMIT 10" assert body["routingContext"] == { @@ -515,14 +516,14 @@ async def test_query_entity_records_async_builds_routing_context_from_folders_ma ) response = MagicMock() response.json.return_value = {"results": [{"id": "c1"}]} - service.request_async = AsyncMock(return_value=response) # type: ignore[method-assign] + service._data.request_async = AsyncMock(return_value=response) # type: ignore[method-assign] result = await service.query_entity_records_async( "SELECT id FROM Customers WHERE id = 'c1'" ) assert result == [{"id": "c1"}] - call_kwargs = service.request_async.call_args + call_kwargs = service._data.request_async.call_args body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") assert body["routingContext"] == { "entityRoutings": [ @@ -536,11 +537,11 @@ def test_query_entity_records_without_routing_context_omits_key( ) -> None: response = MagicMock() response.json.return_value = {"results": []} - service.request = MagicMock(return_value=response) # type: ignore[method-assign] + service._data.request = MagicMock(return_value=response) # type: ignore[method-assign] service.query_entity_records("SELECT id FROM Customers WHERE id > 0") - call_kwargs = service.request.call_args + call_kwargs = service._data.request.call_args body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") assert "routingContext" not in body @@ -560,7 +561,7 @@ def test_query_entity_records_picks_up_entity_overwrites_from_context( ) response = MagicMock() response.json.return_value = {"results": [{"id": 1}]} - service.request = MagicMock(return_value=response) # type: ignore[method-assign] + service._data.request = MagicMock(return_value=response) # type: ignore[method-assign] overwrite = EntityResourceOverwrite( resource_type="entity", @@ -573,7 +574,7 @@ def test_query_entity_records_picks_up_entity_overwrites_from_context( finally: _resource_overwrites.reset(token) - call_kwargs = service.request.call_args + call_kwargs = service._data.request.call_args body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") assert body["routingContext"] == { "entityRoutings": [ @@ -601,11 +602,11 @@ def test_query_entity_records_merges_folders_map_with_entity_name_overrides( ) response = MagicMock() response.json.return_value = {"results": []} - service.request = MagicMock(return_value=response) # type: ignore[method-assign] + service._data.request = MagicMock(return_value=response) # type: ignore[method-assign] service.query_entity_records("SELECT id FROM Customers LIMIT 10") - call_kwargs = service.request.call_args + call_kwargs = service._data.request.call_args body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") routings = body["routingContext"]["entityRoutings"] assert { @@ -740,7 +741,7 @@ def test_query_entity_records_context_overwrite_same_name_no_override_field( ) response = MagicMock() response.json.return_value = {"results": []} - service.request = MagicMock(return_value=response) # type: ignore[method-assign] + service._data.request = MagicMock(return_value=response) # type: ignore[method-assign] overwrite = EntityResourceOverwrite( resource_type="entity", @@ -753,7 +754,7 @@ def test_query_entity_records_context_overwrite_same_name_no_override_field( finally: _resource_overwrites.reset(token) - call_kwargs = service.request.call_args + call_kwargs = service._data.request.call_args body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") assert body["routingContext"] == { "entityRoutings": [ @@ -784,7 +785,7 @@ def test_query_entity_records_resolves_overwrite_folder_path_to_folder_key( ) response = MagicMock() response.json.return_value = {"results": []} - service.request = MagicMock(return_value=response) # type: ignore[method-assign] + service._data.request = MagicMock(return_value=response) # type: ignore[method-assign] overwrite = EntityResourceOverwrite( resource_type="entity", @@ -797,7 +798,7 @@ def test_query_entity_records_resolves_overwrite_folder_path_to_folder_key( finally: _resource_overwrites.reset(token) - call_kwargs = service.request.call_args + call_kwargs = service._data.request.call_args body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") assert body["routingContext"] == { "entityRoutings": [ @@ -829,7 +830,7 @@ def test_query_entity_records_uses_folder_id_directly_without_resolution( ) response = MagicMock() response.json.return_value = {"results": []} - service.request = MagicMock(return_value=response) # type: ignore[method-assign] + service._data.request = MagicMock(return_value=response) # type: ignore[method-assign] overwrite = EntityResourceOverwrite( resource_type="entity", @@ -845,7 +846,7 @@ def test_query_entity_records_uses_folder_id_directly_without_resolution( # folder_id is a key — should NOT be sent through FolderService folders_service.retrieve_key.assert_not_called() - call_kwargs = service.request.call_args + call_kwargs = service._data.request.call_args body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") assert body["routingContext"] == { "entityRoutings": [ @@ -1096,3 +1097,1553 @@ def test_get_choiceset_values_empty( values = service.get_choiceset_values(choiceset_id) assert values == [] + + +class TestEntitiesServiceNewMethods: + """Single-record, structured-query, attachment, schema and bulk-import tests.""" + + def test_insert_record_fires_post_with_expansion_level( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + from uipath.platform.entities import EntityRecord + + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/insert?expansionLevel=2", + status_code=200, + json={"Id": "rec-1", "name": "alice"}, + ) + + record = service.insert_record( + entity_key=str(entity_key), + data={"name": "alice"}, + expansion_level=2, + ) + + assert isinstance(record, EntityRecord) + assert record.id == "rec-1" + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.method == "POST" + assert json.loads(sent.content) == {"name": "alice"} + + async def test_insert_record_async( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/insert", + status_code=200, + json={"Id": "rec-1"}, + ) + + record = await service.insert_record_async( + entity_key=str(entity_key), data={"name": "bob"} + ) + assert record.id == "rec-1" + + def test_get_record( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + record_id = "12345" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/read/{record_id}?expansionLevel=1", + status_code=200, + json={"Id": record_id, "name": "found"}, + ) + + record = service.get_record( + entity_key=str(entity_key), record_id=record_id, expansion_level=1 + ) + + assert record.id == record_id + + def test_update_record_accepts_dict( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + record_id = "rec-9" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/update/{record_id}", + status_code=200, + json={"Id": record_id, "name": "updated"}, + ) + + record = service.update_record( + entity_key=str(entity_key), + record_id=record_id, + data={"name": "updated"}, + ) + + assert record.id == record_id + sent = httpx_mock.get_request() + assert sent is not None + assert json.loads(sent.content) == {"name": "updated"} + + def test_delete_record_uses_http_delete( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + record_id = "rec-9" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/delete/{record_id}", + method="DELETE", + status_code=200, + ) + + service.delete_record(entity_key=str(entity_key), record_id=record_id) + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.method == "DELETE" + + def test_query_v1_with_filter_and_pagination( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + from uipath.platform.entities import ( + EntityQueryFilter, + EntityQueryFilterGroup, + EntityQuerySortOption, + LogicalOperator, + QueryFilterOperator, + ) + + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/query.*" + ), + status_code=200, + json={ + "value": [{"Id": "1", "name": "alice"}, {"Id": "2", "name": "bob"}], + "totalRecordCount": 5, + }, + ) + + result = service.retrieve_records( + entity_key=str(entity_key), + filter_group=EntityQueryFilterGroup( + logical_operator=LogicalOperator.And, + query_filters=[ + EntityQueryFilter( + field_name="status", + operator=QueryFilterOperator.Equals, + value="active", + ) + ], + ), + sort_options=[EntityQuerySortOption(field_name="name", is_descending=True)], + selected_fields=["Id", "name"], + start=0, + limit=2, + expansion_level=1, + ) + + assert result.total_count == 5 + assert len(result.items) == 2 + assert result.has_next_page is True + # Backend doesn't return next_cursor on this endpoint — caller paginates + # by passing the next ``start`` themselves. + assert result.next_cursor is None + + sent = httpx_mock.get_request() + assert sent is not None + assert "/query" in str(sent.url) and "/v2/" not in str(sent.url) + # expansionLevel is a URL query param, not body + assert sent.url.params.get("expansionLevel") == "1" + body = json.loads(sent.content) + assert body["filterGroup"]["logicalOperator"] == 0 # And + assert body["filterGroup"]["queryFilters"][0]["fieldName"] == "status" + assert body["sortOptions"][0]["fieldName"] == "name" + assert body["selectedFields"] == ["Id", "name"] + # start/limit go in BODY, not as $top/$skip query params + assert body["start"] == 0 + assert body["limit"] == 2 + + def test_query_aggregate_response_handles_id_less_rows( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Aggregate / GROUP BY rows lack ``Id`` — parsed as :class:`AggregateRow`.""" + from uipath.platform.entities import ( + AggregateRow, + EntityAggregate, + EntityAggregateFunction, + ) + + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/query.*" + ), + status_code=200, + json={ + "value": [ + {"status": "active", "total": 12}, + {"status": "inactive", "total": 7}, + ], + "totalRecordCount": 2, + }, + ) + + result = service.retrieve_records( + entity_key=str(entity_key), + selected_fields=["status"], + group_by=["status"], + aggregates=[ + EntityAggregate( + function=EntityAggregateFunction.Count, + field="Id", + alias="total", + ) + ], + ) + + assert result.total_count == 2 + assert len(result.items) == 2 + # Aggregate rows lack ``Id`` and are exposed as :class:`AggregateRow`. + for row in result.items: + assert isinstance(row, AggregateRow) + assert result.items[0].status == "active" + assert result.items[0].total == 12 + sent = httpx_mock.get_request() + assert sent is not None + body = json.loads(sent.content) + assert body["aggregates"][0]["function"] == "COUNT" + assert body["aggregates"][0]["alias"] == "total" + assert body["groupBy"] == ["status"] + + def test_query_v2_when_binnings_provided( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + from uipath.platform.entities import EntityAggregateFunction, EntityBinning + + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/v2/EntityService/entity/{entity_key}/query.*" + ), + status_code=200, + json={"value": [], "totalCount": 0}, + ) + + service.retrieve_records( + entity_key=str(entity_key), + binnings=[ + EntityBinning( + field_name="status", + aggregate_function=EntityAggregateFunction.Count, + alias="total", + ) + ], + ) + + sent = httpx_mock.get_request() + assert sent is not None + assert "/v2/EntityService/" in str(sent.url) + + def test_upload_attachment_sends_multipart( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_id = "ent-1" + record_id = "rec-1" + field_name = "doc" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Attachment/entity/{entity_id}/{record_id}/{field_name}?expansionLevel=1", + method="POST", + status_code=200, + json={"Id": record_id, "doc": "uploaded"}, + ) + + result = service.upload_attachment( + entity_id=entity_id, + record_id=record_id, + field_name=field_name, + file=b"hello world", + expansion_level=1, + ) + + assert result.get("doc") == "uploaded" + + sent = httpx_mock.get_request() + assert sent is not None + assert b"hello world" in sent.content + + def test_download_attachment_returns_bytes( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_id = "ent-1" + record_id = "rec-1" + field_name = "doc" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Attachment/entity/{entity_id}/{record_id}/{field_name}", + method="GET", + status_code=200, + content=b"file-content", + ) + + content = service.download_attachment( + entity_id=entity_id, record_id=record_id, field_name=field_name + ) + assert content == b"file-content" + + def test_delete_attachment( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_id = "ent-1" + record_id = "rec-1" + field_name = "doc" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Attachment/entity/{entity_id}/{record_id}/{field_name}", + method="DELETE", + status_code=200, + json={}, + ) + + result = service.delete_attachment( + entity_id=entity_id, record_id=record_id, field_name=field_name + ) + assert result == {} + + def test_create_entity_returns_id( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityCreateOptions, + EntityFieldDataType, + ) + + new_entity_id = str(uuid.uuid4()) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity", + method="POST", + status_code=200, + json=new_entity_id, + ) + + created_id = service.create_entity( + name="productCatalog", + fields=[ + EntityCreateFieldOptions( + field_name="productName", + type=EntityFieldDataType.STRING, + is_required=True, + length_limit=200, + ), + ], + options=EntityCreateOptions( + display_name="Product Catalog", + description="Catalog of products", + is_rbac_enabled=True, + ), + ) + + assert created_id == new_entity_id + sent = httpx_mock.get_request() + assert sent is not None + body = json.loads(sent.content) + assert body["displayName"] == "Product Catalog" + assert body["entityDefinition"]["name"] == "productCatalog" + assert body["entityDefinition"]["fields"][0]["name"] == "productName" + assert body["entityDefinition"]["isRbacEnabled"] is True + + def test_delete_entity( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_id = "ent-doomed" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/{entity_id}", + method="DELETE", + status_code=200, + ) + + service.delete_entity(entity_id=entity_id) + sent = httpx_mock.get_request() + assert sent is not None + assert sent.method == "DELETE" + + def test_update_entity_metadata( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + from uipath.platform.entities import EntityMetadataUpdateOptions + + entity_id = "ent-meta" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/{entity_id}/metadata", + method="PATCH", + status_code=200, + json={}, + ) + + service.update_entity_metadata( + entity_id=entity_id, + metadata=EntityMetadataUpdateOptions( + display_name="New Name", is_rbac_enabled=False + ), + ) + + sent = httpx_mock.get_request() + assert sent is not None + body = json.loads(sent.content) + assert body == {"displayName": "New Name", "isRbacEnabled": False} + + def test_import_records( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_id = "ent-imp" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_id}/bulk-upload", + method="POST", + status_code=200, + json={ + "totalRecords": 10, + "insertedRecords": 9, + "errorFileLink": "https://example.com/errors.csv", + }, + ) + + result = service.import_records(entity_id=entity_id, file=b"a,b,c\n1,2,3\n") + assert result.total_records == 10 + assert result.inserted_records == 9 + assert result.error_file_link == "https://example.com/errors.csv" + + def test_list_records_returns_paginated_metadata( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/read.*" + ), + status_code=200, + json={ + "totalCount": 7, + "value": [{"Id": "1"}, {"Id": "2"}, {"Id": "3"}], + }, + ) + + records = service.list_records( + entity_key=str(entity_key), + start=0, + limit=3, + expansion_level=2, + filter="status eq 'active'", + orderby="name asc", + select=["Id", "name"], + expand=["Company"], + ) + + # New pagination metadata: backend totalCount surfaced verbatim. + assert records.total_count == 7 + assert records.has_next_page is True + # Backend does not currently emit next_cursor; caller paginates with start. + assert records.next_cursor is None + + # Backward-compat: behaves as a list. + assert isinstance(records, list) + assert len(records) == 3 + assert records[0].id == "1" + + sent = httpx_mock.get_request() + assert sent is not None + params = sent.url.params + assert params.get("expansionLevel") == "2" + assert params.get("$filter") == "status eq 'active'" + assert params.get("$orderby") == "name asc" + assert params.get("$select") == "Id,name" + assert params.get("$expand") == "Company" + + def test_insert_records_passes_expansion_level_and_fail_on_first( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/insert-batch.*" + ), + status_code=200, + json={"successRecords": [{"Id": "1"}], "failureRecords": []}, + ) + + service.insert_records( + entity_key=str(entity_key), + records=[{"name": "alice"}], + expansion_level=1, + fail_on_first=True, + ) + + sent = httpx_mock.get_request() + assert sent is not None + params = sent.url.params + assert params.get("expansionLevel") == "1" + assert params.get("failOnFirst") == "true" + # Records are normalized to dicts before being sent. + assert json.loads(sent.content) == [{"name": "alice"}] + + def test_update_records_recovers_failure_records_from_4xx( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """A 400 response that lists per-record failures should parse into the response. + + The caller receives an ``EntityRecordsBatchResponse`` with the failed + records populated rather than an exception, so unknown record ids on + update can be handled the same way as any other batch failure. + """ + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/update-batch", + method="POST", + status_code=400, + json={ + "successRecords": [], + "failureRecords": [ + {"error": "Record not found", "record": {"Id": "missing"}} + ], + }, + ) + + result = service.update_records( + entity_key=str(entity_key), + records=[{"Id": "missing", "name": "x"}], + ) + + assert len(result.failure_records) == 1 + assert result.failure_records[0].error == "Record not found" + + def test_delete_records_recovers_failure_records_from_4xx( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/delete-batch", + method="POST", + status_code=400, + json={ + "successRecords": [], + "failureRecords": [{"error": "not found"}], + }, + ) + + result = service.delete_records( + entity_key=str(entity_key), record_ids=["missing"] + ) + + assert result.failure_records[0].error == "not found" + + def test_record_to_dict_accepts_dict_pydantic_and_object(self) -> None: + from uipath.platform.entities import EntityCreateFieldOptions + + # dict + assert EntityDataService._record_to_dict({"a": 1}) == {"a": 1} + # Pydantic model — uses model_dump + result = EntityDataService._record_to_dict( + EntityCreateFieldOptions(field_name="x") + ) + assert result["fieldName"] == "x" + # Object with __dict__ + from dataclasses import dataclass + + @dataclass + class Rec: + name: str + + assert EntityDataService._record_to_dict(Rec(name="bob")) == {"name": "bob"} + + +class TestEntitiesServiceCreateEntitySqlTypeMapping: + """Verify ``create_entity`` produces the SQL types and constraint defaults the backend expects.""" + + def _captured_field( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + field_options, + ): + from uipath.platform.entities import EntityCreateOptions + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity", + method="POST", + status_code=200, + json="00000000-0000-0000-0000-000000000001", + ) + service.create_entity( + name="myEntity", + fields=[field_options], + options=EntityCreateOptions(display_name="My Entity"), + ) + sent = httpx_mock.get_request() + assert sent is not None + body = json.loads(sent.content) + return body["entityDefinition"]["fields"][0] + + def test_string_field_maps_to_nvarchar_with_default_length( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + f = self._captured_field( + httpx_mock, + service, + base_url, + org, + tenant, + EntityCreateFieldOptions( + field_name="productName", type=EntityFieldDataType.STRING + ), + ) + assert f["sqlType"]["name"] == "NVARCHAR" + assert f["sqlType"]["lengthLimit"] == 200 # default + assert f["fieldDisplayType"] == "Basic" + + def test_decimal_field_includes_precision_and_value_bounds( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + f = self._captured_field( + httpx_mock, + service, + base_url, + org, + tenant, + EntityCreateFieldOptions( + field_name="price", + type=EntityFieldDataType.DECIMAL, + decimal_precision=4, + ), + ) + assert f["sqlType"]["name"] == "DECIMAL" + assert f["sqlType"]["decimalPrecision"] == 4 + assert f["sqlType"]["lengthLimit"] == 1000 + assert f["sqlType"]["maxValue"] == 1_000_000_000_000 + assert f["sqlType"]["minValue"] == -1_000_000_000_000 + + def test_boolean_field_maps_to_bit( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + f = self._captured_field( + httpx_mock, + service, + base_url, + org, + tenant, + EntityCreateFieldOptions( + field_name="isActive", type=EntityFieldDataType.BOOLEAN + ), + ) + assert f["sqlType"]["name"] == "BIT" + assert f["sqlType"]["lengthLimit"] == 100 + + def test_file_field_maps_to_uniqueidentifier_with_file_display_type( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + f = self._captured_field( + httpx_mock, + service, + base_url, + org, + tenant, + EntityCreateFieldOptions( + field_name="document", type=EntityFieldDataType.FILE + ), + ) + assert f["sqlType"]["name"] == "UNIQUEIDENTIFIER" + assert f["fieldDisplayType"] == "File" + assert f["sqlType"]["lengthLimit"] == 300 + + +class TestEntitiesServiceValidation: + """Client-side validation rejects bad entity / field definitions before any HTTP call.""" + + def test_create_entity_rejects_invalid_entity_name(self, service) -> None: + + with pytest.raises(ValueError, match="Invalid entity name"): + service.create_entity(name="1bad", fields=[]) + + def test_create_entity_rejects_invalid_field_name(self, service) -> None: + from uipath.platform.entities import EntityCreateFieldOptions + + with pytest.raises(ValueError, match="Invalid field name"): + service.create_entity( + name="goodEntity", + fields=[EntityCreateFieldOptions(field_name="9bad")], + ) + + def test_create_entity_rejects_reserved_field_name(self, service) -> None: + from uipath.platform.entities import EntityCreateFieldOptions + + with pytest.raises(ValueError, match="reserved"): + service.create_entity( + name="goodEntity", + fields=[EntityCreateFieldOptions(field_name="Id")], + ) + + def test_create_entity_rejects_unsupported_constraint_for_type( + self, service + ) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + with pytest.raises(ValueError, match="does not accept"): + service.create_entity( + name="goodEntity", + fields=[ + EntityCreateFieldOptions( + field_name="myField", + type=EntityFieldDataType.STRING, + decimal_precision=2, # not allowed on STRING + ) + ], + ) + + def test_create_entity_rejects_out_of_range_constraint(self, service) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + with pytest.raises(ValueError, match="out of range"): + service.create_entity( + name="goodEntity", + fields=[ + EntityCreateFieldOptions( + field_name="myField", + type=EntityFieldDataType.STRING, + length_limit=99999, # > 4000 + ) + ], + ) + + def test_create_entity_rejects_min_ge_max(self, service) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + with pytest.raises(ValueError, match="strictly less than"): + service.create_entity( + name="goodEntity", + fields=[ + EntityCreateFieldOptions( + field_name="myField", + type=EntityFieldDataType.INTEGER, + min_value=100, + max_value=10, + ) + ], + ) + + def test_create_entity_rejects_choiceset_without_choice_set_id( + self, service + ) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + with pytest.raises(ValueError, match="choice_set_id"): + service.create_entity( + name="goodEntity", + fields=[ + EntityCreateFieldOptions( + field_name="myField", + type=EntityFieldDataType.CHOICE_SET_SINGLE, + ) + ], + ) + + def test_create_entity_rejects_choice_set_multiple_without_choice_set_id( + self, service + ) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + with pytest.raises(ValueError, match="choice_set_id"): + service.create_entity( + name="goodEntity", + fields=[ + EntityCreateFieldOptions( + field_name="myField", + type=EntityFieldDataType.CHOICE_SET_MULTIPLE, + ) + ], + ) + + def test_create_entity_rejects_relationship_without_reference_entity( + self, service + ) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + with pytest.raises(ValueError, match="reference_entity_name"): + service.create_entity( + name="goodEntity", + fields=[ + EntityCreateFieldOptions( + field_name="myField", + type=EntityFieldDataType.RELATIONSHIP, + ) + ], + ) + + def test_entity_query_filter_rejects_in_without_value_list(self) -> None: + from uipath.platform.entities import EntityQueryFilter, QueryFilterOperator + + with pytest.raises(ValueError, match="non-empty value_list"): + EntityQueryFilter( + field_name="status", + operator=QueryFilterOperator.In, + ) + + def test_entity_query_filter_rejects_in_with_scalar_value(self) -> None: + from uipath.platform.entities import EntityQueryFilter, QueryFilterOperator + + with pytest.raises(ValueError, match="value must be omitted"): + EntityQueryFilter( + field_name="status", + operator=QueryFilterOperator.In, + value="active", + value_list=["active", "pending"], + ) + + def test_entity_query_filter_rejects_scalar_op_with_value_list(self) -> None: + from uipath.platform.entities import EntityQueryFilter, QueryFilterOperator + + with pytest.raises(ValueError, match="value_list must be omitted"): + EntityQueryFilter( + field_name="amount", + operator=QueryFilterOperator.GreaterThan, + value_list=["10"], + ) + + def test_entity_query_filter_rejects_strict_op_with_null_value(self) -> None: + from uipath.platform.entities import EntityQueryFilter, QueryFilterOperator + + with pytest.raises(ValueError, match="non-null value"): + EntityQueryFilter( + field_name="amount", + operator=QueryFilterOperator.GreaterThan, + ) + + def test_entity_query_filter_allows_equals_with_null_value(self) -> None: + from uipath.platform.entities import EntityQueryFilter, QueryFilterOperator + + f = EntityQueryFilter( + field_name="middle_name", + operator=QueryFilterOperator.Equals, + ) + assert f.value is None and f.value_list is None + + def test_entity_query_filter_allows_in_with_value_list(self) -> None: + from uipath.platform.entities import EntityQueryFilter, QueryFilterOperator + + f = EntityQueryFilter( + field_name="status", + operator=QueryFilterOperator.In, + value_list=["a", "b"], + ) + assert f.value_list == ["a", "b"] + + +class TestEntitiesServiceAsyncAndEdgeCases: + async def test_get_record_async( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + entity_key = uuid.uuid4() + record_id = "rec-1" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/read/{record_id}", + status_code=200, + json={"Id": record_id, "name": "found"}, + ) + record = await service.get_record_async( + entity_key=str(entity_key), record_id=record_id + ) + assert record.id == record_id + + async def test_query_async_v1( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/query" + ), + status_code=200, + json={"value": [{"Id": "1"}], "totalRecordCount": 1}, + ) + result = await service.retrieve_records_async(entity_key=str(entity_key)) + assert result.total_count == 1 + + async def test_delete_record_async( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + entity_key = uuid.uuid4() + record_id = "rec-1" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/delete/{record_id}", + method="DELETE", + status_code=200, + ) + await service.delete_record_async( + entity_key=str(entity_key), record_id=record_id + ) + + async def test_create_entity_async( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + new_id = "00000000-0000-0000-0000-000000000123" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity", + method="POST", + status_code=200, + json=new_id, + ) + result = await service.create_entity_async( + name="goodEntity", + fields=[ + EntityCreateFieldOptions( + field_name="myField", type=EntityFieldDataType.STRING + ) + ], + ) + assert result == new_id + + async def test_delete_entity_async( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/ent-1", + method="DELETE", + status_code=200, + ) + await service.delete_entity_async(entity_id="ent-1") + + async def test_update_entity_metadata_async_with_dict( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/ent-1/metadata", + method="PATCH", + status_code=200, + json={}, + ) + # Accepts a plain dict too + await service.update_entity_metadata_async( + entity_id="ent-1", metadata={"displayName": "X", "description": "Y"} + ) + sent = httpx_mock.get_request() + assert sent is not None + assert json.loads(sent.content) == {"displayName": "X", "description": "Y"} + + def test_update_entity_metadata_normalizes_snake_case_dict_keys( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + """Snake_case dict keys must be sent to the backend as camelCase.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/ent-1/metadata", + method="PATCH", + status_code=200, + json={}, + ) + service.update_entity_metadata( + entity_id="ent-1", + metadata={ + "display_name": "New Name", + "description": "Updated", + "is_rbac_enabled": True, + }, + ) + sent = httpx_mock.get_request() + assert sent is not None + assert json.loads(sent.content) == { + "displayName": "New Name", + "description": "Updated", + "isRbacEnabled": True, + } + + async def test_upload_attachment_async_via_file_path( + self, httpx_mock, service, base_url, org, tenant, version, tmp_path + ) -> None: + path = tmp_path / "data.bin" + path.write_bytes(b"file-on-disk") + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Attachment/entity/ent/rec/doc", + method="POST", + status_code=200, + json={"Id": "rec", "doc": "ok"}, + ) + result = await service.upload_attachment_async( + entity_id="ent", + record_id="rec", + field_name="doc", + file_path=str(path), + ) + assert result["doc"] == "ok" + + sent = httpx_mock.get_request() + assert sent is not None + assert b"file-on-disk" in sent.content + + async def test_download_and_delete_attachment_async( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + url = f"{base_url}{org}{tenant}/datafabric_/api/Attachment/entity/e/r/f" + httpx_mock.add_response( + url=url, method="GET", status_code=200, content=b"bytes" + ) + httpx_mock.add_response(url=url, method="DELETE", status_code=200, json={}) + + content = await service.download_attachment_async( + entity_id="e", record_id="r", field_name="f" + ) + assert content == b"bytes" + assert ( + await service.delete_attachment_async( + entity_id="e", record_id="r", field_name="f" + ) + == {} + ) + + def test_open_file_rejects_both_file_and_path(self) -> None: + with pytest.raises(ValueError, match="exactly one of"): + EntityDataService._open_file(file=b"x", file_path="some/path") + + def test_open_file_rejects_neither_file_nor_path(self) -> None: + with pytest.raises(ValueError, match="exactly one of"): + EntityDataService._open_file(file=None, file_path=None) + + def test_4xx_recovery_only_400_with_strict_shape( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + """5xx and 4xx other than 400 must propagate; 400 with valid shape recovers.""" + entity_key = uuid.uuid4() + # 500 with the shape — must propagate, not be silently treated as success. + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/update-batch", + method="POST", + status_code=500, + json={"successRecords": [], "failureRecords": []}, + ) + from uipath.platform.errors._enriched_exception import EnrichedException + + with pytest.raises(EnrichedException): + service.update_records( + entity_key=str(entity_key), records=[{"Id": "x", "name": "y"}] + ) + + def test_4xx_recovery_404_propagates( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + entity_key = uuid.uuid4() + # 404 with valid shape — still propagates because not a 400. + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/update-batch", + method="POST", + status_code=404, + json={"successRecords": [], "failureRecords": []}, + ) + from uipath.platform.errors._enriched_exception import EnrichedException + + with pytest.raises(EnrichedException): + service.update_records( + entity_key=str(entity_key), records=[{"Id": "x", "name": "y"}] + ) + + def test_4xx_recovery_400_unrelated_body_propagates( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + """A 400 with an error body that lacks ``successRecords``/``failureRecords`` + must surface as an exception (so generic validation errors aren't masked).""" + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/update-batch", + method="POST", + status_code=400, + json={"error": "Validation failed", "code": "InvalidArg"}, + ) + from uipath.platform.errors._enriched_exception import EnrichedException + + with pytest.raises(EnrichedException): + service.update_records( + entity_key=str(entity_key), records=[{"Id": "x", "name": "y"}] + ) + + +class TestEntitiesServiceAsyncCoverage: + """Async-variant tests for previously uncovered paths on schema / data services.""" + + async def test_retrieve_async( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/{entity_key}", + status_code=200, + json={ + "name": "Customers", + "displayName": "Customers", + "entityType": "Entity", + "fields": [], + "isRbacEnabled": False, + "id": str(entity_key), + }, + ) + entity = await service.retrieve_async(entity_key=str(entity_key)) + assert entity.id == str(entity_key) + + def test_retrieve_by_name( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/Customers/metadata", + status_code=200, + json={ + "name": "Customers", + "displayName": "Customers", + "entityType": "Entity", + "fields": [], + "isRbacEnabled": False, + "id": "ent-1", + }, + ) + entity = service.retrieve_by_name("Customers", folder_key="folder-1") + assert entity.name == "Customers" + sent = httpx_mock.get_request() + assert sent is not None + assert sent.headers.get("X-UIPATH-FolderKey") == "folder-1" + + async def test_retrieve_by_name_async( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/Orders/metadata", + status_code=200, + json={ + "name": "Orders", + "displayName": "Orders", + "entityType": "Entity", + "fields": [], + "isRbacEnabled": False, + "id": "ent-2", + }, + ) + entity = await service.retrieve_by_name_async("Orders") + assert entity.name == "Orders" + + def test_list_entities_basic( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity", + status_code=200, + json=[ + { + "name": "Customers", + "displayName": "Customers", + "entityType": "Entity", + "fields": [], + "isRbacEnabled": False, + "id": "ent-1", + }, + { + "name": "Orders", + "displayName": "Orders", + "entityType": "Entity", + "fields": [], + "isRbacEnabled": False, + "id": "ent-2", + }, + ], + ) + entities = service.list_entities() + assert len(entities) == 2 + + async def test_list_entities_async( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity", + status_code=200, + json=[ + { + "name": "Customers", + "displayName": "Customers", + "entityType": "Entity", + "fields": [], + "isRbacEnabled": False, + "id": "ent-1", + } + ], + ) + entities = await service.list_entities_async() + assert len(entities) == 1 + + async def test_list_records_async( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/read.*" + ), + status_code=200, + json={ + "totalRecordCount": 2, + "value": [{"Id": "1"}, {"Id": "2"}], + }, + ) + records = await service.list_records_async( + entity_key=str(entity_key), start=0, limit=10 + ) + assert records.total_count == 2 + + async def test_update_record_async( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/update/rec-1", + method="POST", + status_code=200, + json={"Id": "rec-1", "name": "renamed"}, + ) + rec = await service.update_record_async( + entity_key=str(entity_key), + record_id="rec-1", + data={"name": "renamed"}, + ) + assert rec.id == "rec-1" + + async def test_insert_records_async_batch( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/insert-batch.*" + ), + status_code=200, + json={ + "successRecords": [{"Id": "1", "name": "a"}], + "failureRecords": [], + }, + ) + result = await service.insert_records_async( + entity_key=str(entity_key), + records=[{"name": "a"}], + expansion_level=1, + fail_on_first=True, + ) + assert len(result.success_records) == 1 + + async def test_update_records_async_recovers_400_failures( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/update-batch", + method="POST", + status_code=400, + json={ + "successRecords": [], + "failureRecords": [{"error": "not found"}], + }, + ) + result = await service.update_records_async( + entity_key=str(entity_key), + records=[{"Id": "missing", "name": "x"}], + ) + assert result.failure_records[0].error == "not found" + + async def test_delete_records_async_batch( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/delete-batch.*" + ), + status_code=200, + json={ + "successRecords": [{"Id": "rec-1"}], + "failureRecords": [], + }, + ) + result = await service.delete_records_async( + entity_key=str(entity_key), + record_ids=["rec-1"], + fail_on_first=False, + ) + assert len(result.success_records) == 1 + + async def test_import_records_async( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/ent-1/bulk-upload", + method="POST", + status_code=200, + json={ + "totalRecords": 3, + "insertedRecords": 3, + "errorFileLink": None, + }, + ) + result = await service.import_records_async( + entity_id="ent-1", file=b"a,b\n1,2\n" + ) + assert result.inserted_records == 3 + + def test_validate_entity_batch_handles_success_and_failure_records( + self, + service: EntitiesService, + ) -> None: + response = MagicMock() + response.json.return_value = { + "successRecords": [{"Id": "ok-1", "name": "first"}], + "failureRecords": [{"error": "duplicate", "record": {"name": "dup"}}], + } + result = service.validate_entity_batch(response) + assert len(result.success_records) == 1 + assert result.success_records[0].id == "ok-1" + assert result.failure_records[0].error == "duplicate" + + def test_5xx_with_batch_shape_still_propagates( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """500 with successRecords/failureRecords shape must NOT be recovered.""" + from uipath.platform.errors._enriched_exception import EnrichedException + + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/insert-batch", + method="POST", + status_code=500, + json={"successRecords": [], "failureRecords": []}, + ) + with pytest.raises(EnrichedException): + service.insert_records( + entity_key=str(entity_key), + records=[{"name": "x"}], + ) diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index c3b60638d..84372918d 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.56" +version = "0.1.57" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 449f50872..e725d6e39 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.56" +version = "0.1.57" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From cb77eb2c61df44f206b5b5785df614a3d645116b Mon Sep 17 00:00:00 2001 From: ctiliescuuipath Date: Mon, 25 May 2026 13:35:58 +0300 Subject: [PATCH 074/191] fix(platform): resolve correct span in build_trace_context_headers (#1679) Co-authored-by: Claude Opus 4.6 (1M context) --- .../uipath/platform/chat/llm_trace_context.py | 16 ++- .../tests/services/test_llm_trace_context.py | 119 ++++++++++++++---- 2 files changed, 103 insertions(+), 32 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py b/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py index 6efbf08dc..fc97da511 100644 --- a/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py +++ b/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py @@ -5,12 +5,17 @@ from uipath.core.tracing.span_utils import UiPathSpanUtils from ..common._config import UiPathConfig +from ..common._span_utils import _SpanUtils def build_trace_context_headers( extra_baggage: list[str] | None = None, ) -> dict[str, str]: - """Build W3C-style trace context headers from the current OpenTelemetry span. + """Build W3C-style trace context headers for LLM Gateway requests. + + Resolves the current span via ``UiPathSpanUtils.get_external_current_span()`` + (which returns the deepest active span from the LLMOps hierarchy) with a + fallback to ``trace.get_current_span()``. Args: extra_baggage: Additional baggage entries (e.g. ``["source=agents"]``) @@ -25,16 +30,17 @@ def build_trace_context_headers( headers: dict[str, str] = {} llmops_span = UiPathSpanUtils.get_external_current_span() span = llmops_span or trace.get_current_span() + config_trace_id = UiPathConfig.trace_id ctx = span.get_span_context() - if ctx and ctx.trace_id and ctx.span_id: - trace_id = format(ctx.trace_id, "032x") - span_id = format(ctx.span_id, "016x") + if config_trace_id and ctx and ctx.span_id: + trace_id = _SpanUtils.normalize_trace_id(config_trace_id) + span_id = format(ctx.span_id, "032x") headers["x-uipath-traceparent-id"] = f"00-{trace_id}-{span_id}" baggage_parts: list[str] = list(extra_baggage) if extra_baggage else [] if folder_key := UiPathConfig.folder_key: baggage_parts.append(f"folderKey={folder_key}") - if agent_id := UiPathConfig.process_uuid: + if agent_id := UiPathConfig.agent_id: baggage_parts.append(f"agentId={agent_id}") if process_key := UiPathConfig.process_key: baggage_parts.append(f"processKey={process_key}") diff --git a/packages/uipath-platform/tests/services/test_llm_trace_context.py b/packages/uipath-platform/tests/services/test_llm_trace_context.py index 83bde3957..8db61200b 100644 --- a/packages/uipath-platform/tests/services/test_llm_trace_context.py +++ b/packages/uipath-platform/tests/services/test_llm_trace_context.py @@ -3,7 +3,6 @@ import os from unittest.mock import patch -from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags from uipath.core.feature_flags import FeatureFlags @@ -13,6 +12,13 @@ FEATURE_FLAG = "EnableTraceContextHeaders" +def _make_span(): + """Create a real OTEL span for testing.""" + provider = TracerProvider() + tracer = provider.get_tracer("test") + return tracer.start_span("test-span") + + class TestFeatureFlagDisabled: """When the feature flag is off, no headers are returned.""" @@ -28,48 +34,74 @@ def test_returns_empty_dict_when_explicitly_disabled(self) -> None: class TestTraceparentHeader: - """When enabled, x-uipath-traceparent-id is populated from the active span.""" + """When enabled, x-uipath-traceparent-id is populated from config + span.""" def setup_method(self) -> None: FeatureFlags.reset_flags() FeatureFlags.configure_flags({FEATURE_FLAG: True}) - def test_traceparent_from_active_span(self) -> None: - provider = TracerProvider() - tracer = provider.get_tracer("test") - with tracer.start_as_current_span("test-span") as span: - ctx = span.get_span_context() - expected_trace_id = format(ctx.trace_id, "032x") - expected_span_id = format(ctx.span_id, "016x") - + def test_traceparent_from_config_and_span(self) -> None: + span = _make_span() + ctx = span.get_span_context() + expected_span_id = format(ctx.span_id, "032x") + config_trace = "abcdef1234567890abcdef1234567890" + env = {"UIPATH_TRACE_ID": config_trace} + with ( + patch.dict(os.environ, env), + patch( + "uipath.platform.chat.llm_trace_context.trace.get_current_span", + return_value=span, + ), + ): headers = build_trace_context_headers() assert "x-uipath-traceparent-id" in headers value = headers["x-uipath-traceparent-id"] - assert value == f"00-{expected_trace_id}-{expected_span_id}" - # Verify format: version (2) + dash + trace_id (32) + dash + span_id (16) + assert value == f"00-{config_trace}-{expected_span_id}" parts = value.split("-") assert len(parts) == 3 assert parts[0] == "00" assert len(parts[1]) == 32 - assert len(parts[2]) == 16 + assert len(parts[2]) == 32 + + def test_no_traceparent_without_config_trace_id(self) -> None: + headers = build_trace_context_headers() + assert "x-uipath-traceparent-id" not in headers + + def test_traceparent_strips_dashes_from_config_trace_id(self) -> None: + span = _make_span() + uuid_trace = "abcdef12-3456-7890-abcd-ef1234567890" + env = {"UIPATH_TRACE_ID": uuid_trace} + with ( + patch.dict(os.environ, env), + patch( + "uipath.platform.chat.llm_trace_context.trace.get_current_span", + return_value=span, + ), + ): + headers = build_trace_context_headers() - def test_no_traceparent_without_active_span(self) -> None: - # INVALID_SPAN has trace_id=0 and span_id=0 - from opentelemetry.context import attach, detach + value = headers["x-uipath-traceparent-id"] + parts = value.split("-") + assert parts[1] == "abcdef1234567890abcdef1234567890" + def test_no_traceparent_with_invalid_span(self) -> None: ctx = SpanContext( trace_id=0, span_id=0, is_remote=False, trace_flags=TraceFlags(0), ) - non_recording = NonRecordingSpan(ctx) - token = attach(trace.set_span_in_context(non_recording)) - try: + span = NonRecordingSpan(ctx) + env = {"UIPATH_TRACE_ID": "abcdef1234567890abcdef1234567890"} + with ( + patch.dict(os.environ, env), + patch( + "uipath.platform.chat.llm_trace_context.trace.get_current_span", + return_value=span, + ), + ): headers = build_trace_context_headers() - finally: - detach(token) assert "x-uipath-traceparent-id" not in headers @@ -84,7 +116,7 @@ def setup_method(self) -> None: def test_all_env_vars_present(self) -> None: env = { "UIPATH_FOLDER_KEY": "folder-abc", - "UIPATH_PROCESS_UUID": "agent-123", + "UIPATH_AGENT_ID": "agent-123", "UIPATH_PROCESS_KEY": "process-789", } with patch.dict(os.environ, env, clear=True): @@ -103,6 +135,31 @@ def test_partial_env_vars(self) -> None: baggage = headers["x-uipath-tracebaggage"] assert "folderKey=folder-only" in baggage + def test_agent_id_from_agent_id_env(self) -> None: + env = {"UIPATH_AGENT_ID": "real-agent-id"} + with patch.dict(os.environ, env, clear=True): + headers = build_trace_context_headers() + + baggage = headers["x-uipath-tracebaggage"] + assert "agentId=real-agent-id" in baggage + + def test_agent_id_falls_back_to_project_id(self) -> None: + env = {"UIPATH_PROJECT_ID": "project-123"} + with patch.dict(os.environ, env, clear=True): + headers = build_trace_context_headers() + + baggage = headers["x-uipath-tracebaggage"] + assert "agentId=project-123" in baggage + + def test_no_agent_id_without_env_vars(self) -> None: + env = {"UIPATH_FOLDER_KEY": "f1"} + with patch.dict(os.environ, env, clear=True): + headers = build_trace_context_headers() + + baggage = headers["x-uipath-tracebaggage"] + assert "agentId" not in baggage + assert "folderKey=f1" in baggage + def test_no_baggage_without_env_vars(self) -> None: with patch.dict(os.environ, {}, clear=True): headers = build_trace_context_headers() @@ -112,7 +169,7 @@ def test_no_baggage_without_env_vars(self) -> None: def test_baggage_comma_separated(self) -> None: env = { "UIPATH_FOLDER_KEY": "f1", - "UIPATH_PROCESS_UUID": "a1", + "UIPATH_AGENT_ID": "a1", } with patch.dict(os.environ, env, clear=True): headers = build_trace_context_headers() @@ -148,14 +205,22 @@ def setup_method(self) -> None: FeatureFlags.configure_flags({FEATURE_FLAG: True}) def test_both_headers_present(self) -> None: - provider = TracerProvider() - tracer = provider.get_tracer("test") - env = {"UIPATH_FOLDER_KEY": "folder-abc"} + span = _make_span() + env = { + "UIPATH_FOLDER_KEY": "folder-abc", + "UIPATH_TRACE_ID": "abcdef1234567890abcdef1234567890", + } with ( - tracer.start_as_current_span("test-span"), patch.dict(os.environ, env, clear=True), + patch( + "uipath.platform.chat.llm_trace_context.trace.get_current_span", + return_value=span, + ), ): headers = build_trace_context_headers() assert "x-uipath-traceparent-id" in headers + assert headers["x-uipath-traceparent-id"].startswith( + "00-abcdef1234567890abcdef1234567890-" + ) assert "x-uipath-tracebaggage" in headers From 837fe374f0547f1b7132e5522947d20c7983cb5f Mon Sep 17 00:00:00 2001 From: ctiliescuuipath Date: Mon, 25 May 2026 13:50:15 +0300 Subject: [PATCH 075/191] chore: bump package versions (#1680) Co-authored-by: Claude Opus 4.6 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index ba264c1ba..b7a89f319 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.57" +version = "0.1.58" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 84372918d..13e5c945d 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.57" +version = "0.1.58" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index e725d6e39..ad93a67ee 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.57" +version = "0.1.58" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 3ff10783aafaa7ab3c847fa7f201fe718fcacb5e Mon Sep 17 00:00:00 2001 From: Cristian Cotovanu <87022468+cotovanu-cristian@users.noreply.github.com> Date: Mon, 25 May 2026 18:26:59 +0300 Subject: [PATCH 076/191] feat(cli): log bindings.json and resource overwrites at INFO (#1681) Co-authored-by: Claude Opus 4.7 (1M context) --- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/_cli/_utils/_common.py | 16 +- .../src/uipath/_cli/_utils/_studio_project.py | 20 +- .../test_overwrites_logging.py | 308 ++++++++++++++++++ packages/uipath/uv.lock | 4 +- 5 files changed, 333 insertions(+), 17 deletions(-) create mode 100644 packages/uipath/tests/resource_overrides/test_overwrites_logging.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 8e9c9f581..e6c906102 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.70" +version = "2.10.71" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/_utils/_common.py b/packages/uipath/src/uipath/_cli/_utils/_common.py index 784192ab6..c24bccff0 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_common.py +++ b/packages/uipath/src/uipath/_cli/_utils/_common.py @@ -214,6 +214,14 @@ async def read_resource_overwrites_from_file( .get("internalArguments", {}) .get("resourceOverwrites", {}) ) + + logger.info( + "Resource overwrites read from %s (%d entries):\n%s", + file_path, + len(resource_overwrites), + json.dumps(resource_overwrites, indent=2, sort_keys=True), + ) + for key, value in resource_overwrites.items(): try: overwrites_dict[key] = ResourceOverwriteParser.parse(key, value) @@ -224,15 +232,9 @@ async def read_resource_overwrites_from_file( e, ) - logger.debug( - "Loaded %d resource overwrite(s) from file %s", - len(overwrites_dict), - file_path, - ) - # Return empty dict if file doesn't exist or invalid json except FileNotFoundError: - logger.debug("Resource overwrites config file not found: %s", file_path) + logger.info("Resource overwrites config file not found: %s", file_path) except json.JSONDecodeError as e: logger.warning("Failed to parse resource overwrites from %s: %s", file_path, e) diff --git a/packages/uipath/src/uipath/_cli/_utils/_studio_project.py b/packages/uipath/src/uipath/_cli/_utils/_studio_project.py index 1380d9119..f70c3e96a 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_studio_project.py +++ b/packages/uipath/src/uipath/_cli/_utils/_studio_project.py @@ -578,6 +578,12 @@ async def get_resource_overwrites(self) -> dict[str, ResourceOverwrite]: with open(UiPathConfig.bindings_file_path, "rb") as f: file_content = f.read() + logger.info( + "Resource bindings (%s):\n%s", + UiPathConfig.bindings_file_path, + file_content.decode(), + ) + solution_id = await self._get_solution_id() tenant_id = os.getenv(ENV_TENANT_ID, None) @@ -600,18 +606,18 @@ async def get_resource_overwrites(self) -> dict[str, ResourceOverwrite]: files=files, ) data = response.json() - overwrites = {} - - for key, value in data.items(): - overwrites[key] = ResourceOverwriteParser.parse(key, value) logger.info( - "Loaded %d resource overwrite(s) from Studio API for solution %s: %s", - len(overwrites), + "Resource overwrites received for solution %s (%d entries):\n%s", solution_id, - overwrites, + len(data), + json.dumps(data, indent=2), ) + overwrites = {} + for key, value in data.items(): + overwrites[key] = ResourceOverwriteParser.parse(key, value) + return overwrites async def create_virtual_resource( diff --git a/packages/uipath/tests/resource_overrides/test_overwrites_logging.py b/packages/uipath/tests/resource_overrides/test_overwrites_logging.py new file mode 100644 index 000000000..e05a7daed --- /dev/null +++ b/packages/uipath/tests/resource_overrides/test_overwrites_logging.py @@ -0,0 +1,308 @@ +# type: ignore +"""Tests for INFO-level diagnostic logging on the resource-overwrites read paths. + +Covers the recent change that surfaces bindings.json content and raw resource +overwrites (from both uipath.json and the Studio API) at INFO so binding/ +overwrite mismatches can be diagnosed from logs alone. +""" + +import json +import logging +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from uipath._cli._utils._common import read_resource_overwrites_from_file +from uipath._cli._utils._studio_project import StudioClient +from uipath.platform.common import GenericResourceOverwrite + +_VALID_OVERWRITES = { + "asset.asset_name": { + "name": "Overwritten Asset Name", + "folderPath": "Overwritten/Asset/Folder", + }, + "bucket.bucket_name": { + "name": "Overwritten Bucket Name", + "folderPath": "Overwritten/Bucket/Folder", + }, +} + + +_TARGET_LOGGERS = ( + "uipath._cli._utils._common", + "uipath._cli._utils._studio_project", +) + + +@pytest.fixture(autouse=True) +def _capture_uipath_loggers( + caplog: pytest.LogCaptureFixture, +) -> None: + """Attach caplog's handler directly to the target module loggers. + + Earlier tests in the suite — chiefly anything that invokes the Click CLI + — call ``setup_logging`` and leave the ``uipath`` logger with + ``propagate = False``. That breaks the usual caplog flow (handler on + root, records reach it via propagation). Some intermediate loggers can + also end up with ``propagate = False`` from other test setups. Attaching + the handler directly to each module logger we assert against, and + forcing the level to DEBUG for the duration of the test, side-steps the + propagation question entirely. + """ + snapshots: list[tuple[logging.Logger, int, bool]] = [] + for name in _TARGET_LOGGERS: + logger = logging.getLogger(name) + snapshots.append((logger, logger.level, logger.propagate)) + logger.setLevel(logging.DEBUG) + logger.propagate = True + logger.addHandler(caplog.handler) + try: + yield + finally: + for logger, level, propagate in snapshots: + logger.removeHandler(caplog.handler) + logger.setLevel(level) + logger.propagate = propagate + + +def _write_uipath_json(directory: Path, overwrites: dict) -> Path: + config_path = directory / "uipath.json" + config_path.write_text( + json.dumps( + { + "runtime": {"internalArguments": {"resourceOverwrites": overwrites}}, + } + ) + ) + return config_path + + +class TestReadResourceOverwritesFromFileLogging: + """Behavior: read_resource_overwrites_from_file logs diagnostic info at INFO.""" + + async def test_logs_raw_overwrites_at_info_when_file_present( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + config_path = _write_uipath_json(tmp_path, _VALID_OVERWRITES) + + with caplog.at_level(logging.INFO, logger="uipath._cli._utils._common"): + result = await read_resource_overwrites_from_file(str(tmp_path)) + + assert set(result.keys()) == set(_VALID_OVERWRITES.keys()) + + info_records = [r for r in caplog.records if r.levelno == logging.INFO] + assert any( + "Resource overwrites read from" in r.getMessage() + and str(config_path) in r.getMessage() + and f"({len(_VALID_OVERWRITES)} entries)" in r.getMessage() + for r in info_records + ), f"expected INFO log with file path and entry count, got: {caplog.text}" + + # The raw JSON payload should be present in the log so a developer can + # diff it against what Studio later returns. + assert "Overwritten Asset Name" in caplog.text + assert "Overwritten Bucket Name" in caplog.text + + async def test_logs_info_when_config_file_missing( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + # tmp_path is empty — no uipath.json present. + missing_dir = tmp_path / "does-not-exist" + missing_dir.mkdir() + + with caplog.at_level(logging.INFO, logger="uipath._cli._utils._common"): + result = await read_resource_overwrites_from_file(str(missing_dir)) + + assert result == {} + info_messages = [ + r.getMessage() for r in caplog.records if r.levelno == logging.INFO + ] + assert any( + "Resource overwrites config file not found" in msg for msg in info_messages + ), f"expected INFO log for missing config, got: {info_messages}" + + async def test_logs_warning_when_json_is_malformed( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + (tmp_path / "uipath.json").write_text("{not valid json") + + with caplog.at_level(logging.WARNING, logger="uipath._cli._utils._common"): + result = await read_resource_overwrites_from_file(str(tmp_path)) + + assert result == {} + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert any( + "Failed to parse resource overwrites" in r.getMessage() for r in warnings + ) + + async def test_unrecognized_overwrite_key_is_skipped_with_warning( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + overwrites = { + **_VALID_OVERWRITES, + "totallyUnknownKind.foo": {"name": "x", "folderPath": "y"}, + } + _write_uipath_json(tmp_path, overwrites) + + with caplog.at_level(logging.WARNING, logger="uipath._cli._utils._common"): + result = await read_resource_overwrites_from_file(str(tmp_path)) + + # Valid entries still parsed; unknown key dropped. + assert set(result.keys()) == set(_VALID_OVERWRITES.keys()) + assert any( + "Skipping unrecognized resource overwrite" in r.getMessage() + and "totallyUnknownKind.foo" in r.getMessage() + for r in caplog.records + if r.levelno == logging.WARNING + ) + + +class TestStudioClientGetResourceOverwritesLogging: + """Behavior: StudioClient.get_resource_overwrites logs bindings + raw payload.""" + + @pytest.fixture + def studio_client(self) -> StudioClient: + # Inject a mock UiPath so no real HTTP setup is required. + mock_uipath = MagicMock() + mock_uipath.api_client.request_async = AsyncMock() + client = StudioClient(project_id="test-project-id", uipath=mock_uipath) + # Avoid the network call that resolves the solution id. + client._get_solution_id = AsyncMock(return_value="test-solution-id") # type: ignore[method-assign] + return client + + async def test_warns_and_returns_empty_when_bindings_file_missing( + self, + studio_client: StudioClient, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + ) -> None: + from uipath.platform.common._config import ConfigurationManager + + missing_path = tmp_path / "bindings.json" + monkeypatch.setattr( + ConfigurationManager, + "bindings_file_path", + property(lambda self: missing_path), + ) + + with caplog.at_level(logging.WARNING): + result = await studio_client.get_resource_overwrites() + + assert result == {} + assert any( + "Bindings file not found" in r.getMessage() + for r in caplog.records + if r.levelno == logging.WARNING + ) + # No request should have been made when there is nothing to upload. + studio_client.uipath.api_client.request_async.assert_not_called() + + async def test_logs_bindings_content_and_received_overwrites_at_info( + self, + studio_client: StudioClient, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + ) -> None: + from uipath.platform.common._config import ConfigurationManager + + bindings_path = tmp_path / "bindings.json" + bindings_content = json.dumps( + {"version": "2", "resources": [{"name": "my_bucket", "kind": "bucket"}]} + ) + bindings_path.write_text(bindings_content) + monkeypatch.setattr( + ConfigurationManager, + "bindings_file_path", + property(lambda self: bindings_path), + ) + monkeypatch.delenv("UIPATH_TENANT_ID", raising=False) + + response = MagicMock() + response.json.return_value = { + "bucket.my_bucket": { + "name": "prod_bucket", + "folderPath": "Prod/Folder", + } + } + studio_client.uipath.api_client.request_async = AsyncMock(return_value=response) + + with caplog.at_level(logging.INFO, logger="uipath._cli._utils._studio_project"): + result = await studio_client.get_resource_overwrites() + + # Returned dict is parsed via ResourceOverwriteParser. + assert set(result.keys()) == {"bucket.my_bucket"} + + info_text = "\n".join( + r.getMessage() for r in caplog.records if r.levelno == logging.INFO + ) + # Bindings content is logged so we can compare what was sent to Studio. + assert "Resource bindings" in info_text + assert "my_bucket" in info_text + # Received overwrites payload is logged with the solution id and count. + assert "Resource overwrites received for solution test-solution-id" in info_text + assert "(1 entries)" in info_text + assert "prod_bucket" in info_text + + async def test_parses_received_overwrites_into_resource_overwrite_objects( + self, + studio_client: StudioClient, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + from uipath.platform.common._config import ConfigurationManager + + bindings_path = tmp_path / "bindings.json" + bindings_path.write_text("{}") + monkeypatch.setattr( + ConfigurationManager, + "bindings_file_path", + property(lambda self: bindings_path), + ) + + response = MagicMock() + response.json.return_value = { + "bucket.my_bucket": { + "name": "prod_bucket", + "folderPath": "Prod/Folder", + } + } + studio_client.uipath.api_client.request_async = AsyncMock(return_value=response) + + result = await studio_client.get_resource_overwrites() + + parsed = result["bucket.my_bucket"] + assert isinstance(parsed, GenericResourceOverwrite) + assert parsed.resource_identifier == "prod_bucket" + assert parsed.folder_identifier == "Prod/Folder" + + async def test_passes_tenant_id_header_from_environment( + self, + studio_client: StudioClient, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + from uipath.platform.common._config import ConfigurationManager + + bindings_path = tmp_path / "bindings.json" + bindings_path.write_text("{}") + monkeypatch.setattr( + ConfigurationManager, + "bindings_file_path", + property(lambda self: bindings_path), + ) + monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-from-env") + + response = MagicMock() + response.json.return_value = {} + request_mock = AsyncMock(return_value=response) + studio_client.uipath.api_client.request_async = request_mock + + await studio_client.get_resource_overwrites() + + # The header carrying the tenant id should reflect the env var value. + call_kwargs = request_mock.await_args.kwargs + headers = call_kwargs["headers"] + assert any(value == "tenant-from-env" for value in headers.values()), headers diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index ad93a67ee..9490d076e 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-05-20T15:14:33.9075119Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.70" +version = "2.10.71" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 4cea46bd3122ccca45d2feed985d695973cc6063 Mon Sep 17 00:00:00 2001 From: Gabriel Martin <91462031+gcuip@users.noreply.github.com> Date: Thu, 28 May 2026 10:32:27 +0200 Subject: [PATCH 077/191] feat: send jobkey to ECS [ECS-1819] (#1687) --- packages/uipath-platform/pyproject.toml | 2 +- .../uipath/platform/common/_job_context.py | 13 ++ .../_context_grounding_service.py | 33 ++++- .../tests/common/test_job_context.py | 22 +++ .../test_context_grounding_service.py | 138 +++++++++++++++++- packages/uipath-platform/uv.lock | 4 +- packages/uipath/pyproject.toml | 2 +- packages/uipath/uv.lock | 4 +- 8 files changed, 206 insertions(+), 12 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/common/_job_context.py create mode 100644 packages/uipath-platform/tests/common/test_job_context.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index b7a89f319..215882460 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.58" +version = "0.1.59" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/common/_job_context.py b/packages/uipath-platform/src/uipath/platform/common/_job_context.py new file mode 100644 index 000000000..ccbd99f22 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/_job_context.py @@ -0,0 +1,13 @@ +from ._config import UiPathConfig +from .constants import HEADER_JOB_KEY + + +def header_job_key() -> dict[str, str]: + """Return the X-UiPath-JobKey header when the orchestrator job key is set. + + Returns an empty dict when ``UiPathConfig.job_key`` is unset or empty. + """ + job_key = UiPathConfig.job_key + if not job_key: + return {} + return {HEADER_JOB_KEY: job_key} diff --git a/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py b/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py index 2e6e40628..6bff3f77f 100644 --- a/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py +++ b/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py @@ -12,6 +12,7 @@ from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import FolderContext, header_folder from ..common._http_config import get_httpx_client_kwargs +from ..common._job_context import header_job_key from ..common._models import Endpoint, RequestSpec from ..common.constants import ( ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE, @@ -221,6 +222,7 @@ def retrieve_across_folders( spec.method, spec.endpoint, params=spec.params, + headers=spec.headers, ).json() return [ @@ -250,6 +252,7 @@ async def retrieve_across_folders_async( spec.method, spec.endpoint, params=spec.params, + headers=spec.headers, ) ).json() @@ -268,6 +271,7 @@ def _retrieve_system_indexes( spec.method, spec.endpoint, params=spec.params, + headers=spec.headers, ).json() return [ @@ -286,6 +290,7 @@ async def _retrieve_system_indexes_async( spec.method, spec.endpoint, params=spec.params, + headers=spec.headers, ) ).json() @@ -455,7 +460,7 @@ def list( "GET", Endpoint("/ecs_/v2/indexes"), params={"$expand": "dataSource"}, - headers={**header_folder(folder_key, None)}, + headers={**header_folder(folder_key, None), **header_job_key()}, ).json() return [ ContextGroundingIndex.model_validate(item) @@ -483,7 +488,7 @@ async def list_async( "GET", Endpoint("/ecs_/v2/indexes"), params={"$expand": "dataSource"}, - headers={**header_folder(folder_key, None)}, + headers={**header_folder(folder_key, None), **header_job_key()}, ) ).json() return [ @@ -521,6 +526,7 @@ def retrieve_by_id( spec.method, spec.endpoint, params=spec.params, + headers=spec.headers, ).json() @traced(name="contextgrounding_retrieve_by_id", run_type="uipath") @@ -553,6 +559,7 @@ async def retrieve_by_id_async( spec.method, spec.endpoint, params=spec.params, + headers=spec.headers, ) return response.json() @@ -1997,6 +2004,7 @@ def _ingest_spec( endpoint=Endpoint(f"/ecs_/v2/indexes/{key}/ingest"), headers={ **header_folder(folder_key, None), + **header_job_key(), }, ) @@ -2024,6 +2032,7 @@ def _retrieve_across_folders_spec( method="GET", endpoint=Endpoint("/ecs_/v2/indexes/allacrossfolders"), params=params, + headers={**header_job_key()}, ) def _retrieve_system_indexes_spec( @@ -2040,6 +2049,7 @@ def _retrieve_system_indexes_spec( method="GET", endpoint=Endpoint("/ecs_/v2/indexes/allsystemindexes"), params=params, + headers={**header_job_key()}, ) def _list_spec( @@ -2054,6 +2064,7 @@ def _list_spec( }, headers={ **header_folder(folder_key, None), + **header_job_key(), }, ) @@ -2074,6 +2085,7 @@ def _retrieve_spec( }, headers={ **header_folder(folder_key, None), + **header_job_key(), }, ) @@ -2126,6 +2138,7 @@ def _create_spec( json=payload.model_dump(by_alias=True, exclude_none=True), headers={ **header_folder(folder_key, None), + **header_job_key(), }, ) @@ -2156,7 +2169,7 @@ def _create_ephemeral_spec( method="POST", endpoint=Endpoint("/ecs_/v2/indexes/createephemeral"), json=payload.model_dump(by_alias=True, exclude_none=True), - headers={**header_folder(folder_key, None)}, + headers={**header_folder(folder_key, None), **header_job_key()}, ) def _build_data_source(self, source: SourceConfig) -> Dict[str, Any]: @@ -2256,6 +2269,7 @@ def _retrieve_by_id_spec( endpoint=Endpoint(f"/ecs_/v2/indexes/{id}"), headers={ **header_folder(folder_key, None), + **header_job_key(), }, ) @@ -2272,6 +2286,7 @@ def _delete_by_id_spec( endpoint=Endpoint(f"/ecs_/v2/indexes/{id}"), headers={ **header_folder(folder_key, None), + **header_job_key(), }, ) @@ -2299,6 +2314,7 @@ def _search_spec( }, headers={ **header_folder(folder_key, None), + **header_job_key(), }, ) @@ -2335,6 +2351,7 @@ def _unified_search_spec( json=json_body, headers={ **header_folder(folder_key, None), + **header_job_key(), }, ) @@ -2364,6 +2381,7 @@ def _deep_rag_creation_spec( }, headers={ **header_folder(folder_key, None), + **header_job_key(), }, ) @@ -2387,7 +2405,7 @@ def _deep_rag_ephemeral_creation_spec( params={ "$select": "id,lastDeepRagStatus,createdDate", }, - headers={}, + headers={**header_job_key()}, ) def _batch_transform_creation_spec( @@ -2437,6 +2455,7 @@ def _batch_transform_creation_spec( }, headers={ **header_folder(folder_key, None), + **header_job_key(), }, ) @@ -2463,7 +2482,7 @@ def _batch_transform_ephemeral_creation_spec( column.model_dump(by_alias=True) for column in output_columns ], }, - headers={}, + headers={**header_job_key()}, ) def _deep_rag_retrieve_spec( @@ -2476,6 +2495,7 @@ def _deep_rag_retrieve_spec( params={ "$expand": "content", }, + headers={**header_job_key()}, ) def _batch_transform_retrieve_spec( @@ -2485,6 +2505,7 @@ def _batch_transform_retrieve_spec( return RequestSpec( method="GET", endpoint=Endpoint(f"/ecs_/v2/batchRag/{id}"), + headers={**header_job_key()}, ) def _batch_transform_get_read_uri_spec( @@ -2494,6 +2515,7 @@ def _batch_transform_get_read_uri_spec( return RequestSpec( method="GET", endpoint=Endpoint(f"/ecs_/v2/batchRag/{id}/GetReadUri"), + headers={**header_job_key()}, ) def _batch_transform_download_blob_spec( @@ -2503,6 +2525,7 @@ def _batch_transform_download_blob_spec( return RequestSpec( method="GET", endpoint=Endpoint(f"/ecs_/v2/batchRag/{id}/DownloadBlob"), + headers={**header_job_key()}, ) def _resolve_folder_key(self, folder_key, folder_path): diff --git a/packages/uipath-platform/tests/common/test_job_context.py b/packages/uipath-platform/tests/common/test_job_context.py new file mode 100644 index 000000000..6a4e1a97d --- /dev/null +++ b/packages/uipath-platform/tests/common/test_job_context.py @@ -0,0 +1,22 @@ +import pytest + +from uipath.platform.common._job_context import header_job_key +from uipath.platform.common.constants import ENV_JOB_KEY, HEADER_JOB_KEY + + +def test_returns_header_when_env_var_set(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(ENV_JOB_KEY, "test-job-key") + + assert header_job_key() == {HEADER_JOB_KEY: "test-job-key"} + + +def test_returns_empty_when_env_var_unset(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(ENV_JOB_KEY, raising=False) + + assert header_job_key() == {} + + +def test_returns_empty_when_env_var_blank(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(ENV_JOB_KEY, "") + + assert header_job_key() == {} diff --git a/packages/uipath-platform/tests/services/test_context_grounding_service.py b/packages/uipath-platform/tests/services/test_context_grounding_service.py index ace7affcd..70c529ec6 100644 --- a/packages/uipath-platform/tests/services/test_context_grounding_service.py +++ b/packages/uipath-platform/tests/services/test_context_grounding_service.py @@ -6,7 +6,11 @@ from pytest_httpx import HTTPXMock from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.common.constants import HEADER_USER_AGENT +from uipath.platform.common.constants import ( + ENV_JOB_KEY, + HEADER_JOB_KEY, + HEADER_USER_AGENT, +) from uipath.platform.context_grounding import ( BatchTransformCreationResponse, BatchTransformOutputColumn, @@ -3782,3 +3786,135 @@ def test_unified_search_with_scope( assert "filter" not in request_body assert request_body["scope"]["folder"] == "docs" assert request_body["scope"]["extension"] == ".pdf" + + +class TestJobKeyHeader: + """X-UiPath-JobKey is attached to outbound ECS calls when UIPATH_JOB_KEY is set.""" + + _INDEX = ContextGroundingIndex( + id="test-index-id", + name="test-index", + last_ingestion_status="Completed", + ) + + def test_ingest_data_carries_job_key_header( + self, + service: ContextGroundingService, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv(ENV_JOB_KEY, "job-key-ingest") + + with patch.object(service, "request") as mock_request: + mock_request.return_value = MagicMock() + service.ingest_data(self._INDEX, folder_key="test-folder-key") + + headers = mock_request.call_args[1]["headers"] + assert headers[HEADER_JOB_KEY] == "job-key-ingest" + + @pytest.mark.anyio + async def test_ingest_data_async_carries_job_key_header( + self, + service: ContextGroundingService, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv(ENV_JOB_KEY, "job-key-ingest-async") + + with patch.object(service, "request_async") as mock_request: + mock_request.return_value = MagicMock() + await service.ingest_data_async(self._INDEX, folder_key="test-folder-key") + + headers = mock_request.call_args[1]["headers"] + assert headers[HEADER_JOB_KEY] == "job-key-ingest-async" + + def test_unified_search_carries_job_key_header( + self, + service: ContextGroundingService, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv(ENV_JOB_KEY, "job-key-search") + + with patch.object(service, "request") as mock_request: + mock_response = MagicMock() + mock_response.json.return_value = { + "semanticResults": {"values": []}, + "explanation": None, + } + mock_request.return_value = mock_response + with patch.object(service, "retrieve", return_value=self._INDEX): + service.unified_search( + name="test-index", + query="test query", + folder_key="test-folder-key", + ) + + headers = mock_request.call_args[1]["headers"] + assert headers[HEADER_JOB_KEY] == "job-key-search" + + def test_start_deep_rag_carries_job_key_header( + self, + service: ContextGroundingService, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv(ENV_JOB_KEY, "job-key-deeprag") + + with patch.object(service, "request") as mock_request: + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "new-deep-rag-task-id", + "lastDeepRagStatus": "Queued", + "createdDate": "2024-01-15T10:30:00Z", + } + mock_request.return_value = mock_response + service.start_deep_rag( + index_id="test-index-id", + name="my-deep-rag-task", + prompt="Summarize", + glob_pattern="*.pdf", + citation_mode=CitationMode.INLINE, + folder_key="test-folder-key", + ) + + headers = mock_request.call_args[1]["headers"] + assert headers[HEADER_JOB_KEY] == "job-key-deeprag" + + def test_start_batch_transform_carries_job_key_header( + self, + service: ContextGroundingService, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv(ENV_JOB_KEY, "job-key-batch") + + with patch.object(service, "request") as mock_request: + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "new-batch-id", + "lastBatchRagStatus": "Queued", + } + mock_request.return_value = mock_response + service.start_batch_transform( + index_id="test-index-id", + name="my-batch-task", + prompt="Extract", + output_columns=[ + BatchTransformOutputColumn(name="col1", description="d") + ], + enable_web_search_grounding=False, + folder_key="test-folder-key", + ) + + headers = mock_request.call_args[1]["headers"] + assert headers[HEADER_JOB_KEY] == "job-key-batch" + + def test_ingest_data_omits_job_key_header_when_env_unset( + self, + service: ContextGroundingService, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv(ENV_JOB_KEY, raising=False) + + with patch.object(service, "request") as mock_request: + mock_request.return_value = MagicMock() + service.ingest_data(self._INDEX, folder_key="test-folder-key") + + headers = mock_request.call_args[1]["headers"] + assert HEADER_JOB_KEY not in headers diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 13e5c945d..bee52f2aa 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-05-20T15:43:10.4544027Z" +exclude-newer = "2026-05-25T20:56:22.964456Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.58" +version = "0.1.59" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index e6c906102..4aea69f52 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.8, <0.6.0", "uipath-runtime>=0.10.1, <0.11.0", - "uipath-platform>=0.1.53, <0.2.0", + "uipath-platform>=0.1.59, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 9490d076e..304458d8c 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-05-25T20:56:31.043599Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.58" +version = "0.1.59" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 1b81042fbb4f8f59d465ca59d2e3892f8bb4607f Mon Sep 17 00:00:00 2001 From: vldcmp-uipath Date: Thu, 28 May 2026 15:49:59 +0300 Subject: [PATCH 078/191] feat(agent): add Function tool type for agent process tools (#1689) Co-authored-by: Claude Opus 4.7 (1M context) --- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/agent/models/agent.py | 3 + .../uipath/tests/agent/models/test_agent.py | 69 +++++++++++++++++++ packages/uipath/uv.lock | 2 +- 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 4aea69f52..2cfaef895 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.71" +version = "2.10.72" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 72e7c438f..6fda35789 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -113,6 +113,7 @@ class AgentToolType(str, CaseInsensitiveEnum): API = "Api" PROCESS_ORCHESTRATION = "ProcessOrchestration" FLOW = "Flow" + FUNCTION = "Function" INTEGRATION = "Integration" INTERNAL = "Internal" IXP = "Ixp" @@ -792,6 +793,7 @@ class AgentProcessToolResourceConfig(BaseAgentToolResourceConfig): AgentToolType.API, AgentToolType.PROCESS_ORCHESTRATION, AgentToolType.FLOW, + AgentToolType.FUNCTION, ] output_schema: Dict[str, Any] = Field(EMPTY_SCHEMA, alias="outputSchema") properties: AgentProcessToolProperties @@ -1336,6 +1338,7 @@ def _normalize_resources(v: Dict[str, Any]) -> None: "api": "Api", "processorchestration": "ProcessOrchestration", "flow": "Flow", + "function": "Function", "integration": "Integration", "internal": "Internal", "ixp": "Ixp", diff --git a/packages/uipath/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py index 14ca8018d..6b2825833 100644 --- a/packages/uipath/tests/agent/models/test_agent.py +++ b/packages/uipath/tests/agent/models/test_agent.py @@ -3627,6 +3627,75 @@ def test_flow_tool_resource_case_insensitive(self): assert isinstance(tool_resource, AgentProcessToolResourceConfig) assert tool_resource.type == AgentToolType.FLOW + def test_function_tool_type_enum_value(self): + """AgentToolType.FUNCTION exists with the wire value 'Function' and is case-insensitive.""" + assert AgentToolType.FUNCTION.value == "Function" + assert AgentToolType("function") is AgentToolType.FUNCTION + assert AgentToolType("FUNCTION") is AgentToolType.FUNCTION + + def test_function_tool_resource_deserialization(self): + """A resource with type='Function' is parsed as AgentProcessToolResourceConfig.""" + resources = [ + { + "$resourceType": "tool", + "type": "Function", + "id": "function-tool-1", + "inputSchema": { + "type": "object", + "properties": {"input": {"type": "string"}}, + }, + "outputSchema": {"type": "object", "properties": {}}, + "arguments": {}, + "settings": {"timeout": 0, "maxAttempts": 0, "retryDelay": 0}, + "properties": { + "processName": "MyFunction", + "folderPath": "/Shared/Functions", + }, + "name": "Function Tool", + "description": "Test Function tool", + } + ] + + json_data = self._agent_dict_with_resources(resources) + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + tool_resource = config.resources[0] + assert isinstance(tool_resource, AgentProcessToolResourceConfig) + assert tool_resource.type == AgentToolType.FUNCTION + assert tool_resource.properties.process_name == "MyFunction" + assert tool_resource.properties.folder_path == "/Shared/Functions" + + def test_function_tool_resource_case_insensitive(self): + """A resource with lowercase type='function' also deserializes via CaseInsensitiveEnum.""" + resources = [ + { + "$resourceType": "tool", + "type": "function", + "id": "function-tool-2", + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": {"type": "object", "properties": {}}, + "arguments": {}, + "settings": {"timeout": 0, "maxAttempts": 0, "retryDelay": 0}, + "properties": { + "processName": "MyFunction", + "folderPath": "/Shared/Functions", + }, + "name": "Function Tool", + "description": "Test Function tool", + } + ] + + json_data = self._agent_dict_with_resources(resources) + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + tool_resource = config.resources[0] + assert isinstance(tool_resource, AgentProcessToolResourceConfig) + assert tool_resource.type == AgentToolType.FUNCTION + def test_escalation_missing_escalation_type_defaults_to_zero(self): """Test that missing escalationType defaults to 0.""" resources = [ diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 304458d8c..b319fa1ba 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.71" +version = "2.10.72" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From f2345830234ec4d3621f8ce6046eef651b8216bb Mon Sep 17 00:00:00 2001 From: Norman <82416746+norman-le@users.noreply.github.com> Date: Thu, 28 May 2026 17:33:23 -0400 Subject: [PATCH 079/191] feat: add client side tools to bridge and new cas events [JAR-9629] (#1638) --- packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/chat/__init__.py | 4 + .../src/uipath/core/chat/exchange.py | 13 + .../uipath-core/src/uipath/core/chat/tool.py | 18 ++ packages/uipath-core/uv.lock | 4 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/_cli/_chat/_bridge.py | 84 +++--- .../uipath/src/uipath/agent/models/agent.py | 12 + .../uipath/tests/agent/models/test_agent.py | 206 ++++++++++++++ packages/uipath/tests/cli/chat/test_bridge.py | 253 ++++++++++++++++++ packages/uipath/uv.lock | 4 +- 12 files changed, 567 insertions(+), 37 deletions(-) diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 147726394..c07e4c3b7 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.16" +version = "0.5.17" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/chat/__init__.py b/packages/uipath-core/src/uipath/core/chat/__init__.py index 77fa6cbee..ee4a4c674 100644 --- a/packages/uipath-core/src/uipath/core/chat/__init__.py +++ b/packages/uipath-core/src/uipath/core/chat/__init__.py @@ -71,6 +71,7 @@ ) from .event import UiPathConversationEvent, UiPathConversationLabelUpdatedEvent from .exchange import ( + UiPathClientSideToolDeclaration, UiPathConversationExchange, UiPathConversationExchangeData, UiPathConversationExchangeEndEvent, @@ -107,6 +108,7 @@ UiPathSessionStartEvent, ) from .tool import ( + UiPathConversationExecutingToolCallEvent, UiPathConversationToolCall, UiPathConversationToolCallConfirmation, UiPathConversationToolCallConfirmationData, @@ -138,6 +140,7 @@ "UiPathSessionEndingEvent", "UiPathSessionEndEvent", # Exchange + "UiPathClientSideToolDeclaration", "UiPathConversationExchangeStartEvent", "UiPathConversationExchangeEndEvent", "UiPathConversationExchangeEvent", @@ -171,6 +174,7 @@ "UiPathConversationCitationData", "UiPathConversationCitation", # Tool + "UiPathConversationExecutingToolCallEvent", "UiPathConversationToolCallStartEvent", "UiPathConversationToolCallEndEvent", "UiPathConversationToolCallConfirmation", diff --git a/packages/uipath-core/src/uipath/core/chat/exchange.py b/packages/uipath-core/src/uipath/core/chat/exchange.py index 788bbe560..835489dfb 100644 --- a/packages/uipath-core/src/uipath/core/chat/exchange.py +++ b/packages/uipath-core/src/uipath/core/chat/exchange.py @@ -28,11 +28,24 @@ ) +class UiPathClientSideToolDeclaration(BaseModel): + """A client-side tool declaration from the SDK client.""" + + name: str + input_schema: dict[str, Any] | None = Field(None, alias="inputSchema") + output_schema: dict[str, Any] | None = Field(None, alias="outputSchema") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + class UiPathConversationExchangeStartEvent(BaseModel): """Signals the start of an exchange of messages within a conversation.""" conversation_sequence: int | None = Field(None, alias="conversationSequence") metadata: dict[str, Any] | None = Field(None, alias="metaData") + client_side_tools: list[UiPathClientSideToolDeclaration] | None = Field( + None, alias="clientSideTools" + ) timestamp: str | None = None model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) diff --git a/packages/uipath-core/src/uipath/core/chat/tool.py b/packages/uipath-core/src/uipath/core/chat/tool.py index 8af5fb604..514e42908 100644 --- a/packages/uipath-core/src/uipath/core/chat/tool.py +++ b/packages/uipath-core/src/uipath/core/chat/tool.py @@ -27,6 +27,8 @@ class UiPathConversationToolCallStartEvent(BaseModel): metadata: dict[str, Any] | None = Field(None, alias="metaData") require_confirmation: bool | None = Field(None, alias="requireConfirmation") input_schema: Any | None = Field(None, alias="inputSchema") + is_client_side_tool: bool | None = Field(None, alias="isClientSideTool") + output_schema: Any | None = Field(None, alias="outputSchema") model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) @@ -43,6 +45,19 @@ class UiPathConversationToolCallEndEvent(BaseModel): model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) +class UiPathConversationExecutingToolCallEvent(BaseModel): + """Signals the client that the tool is about to be executed. + + Emitted in all scenarios. For client-side tools, the client should begin + executing its handler upon receiving this event. + """ + + timestamp: str | None = None + input: dict[str, Any] | None = None + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + class UiPathConversationToolCallConfirmationEvent(BaseModel): """Signals a tool call confirmation (approve/reject) from the client.""" @@ -82,6 +97,9 @@ class UiPathConversationToolCallEvent(BaseModel): confirm: UiPathConversationToolCallConfirmationEvent | None = Field( None, alias="confirmToolCall" ) + executing: UiPathConversationExecutingToolCallEvent | None = Field( + None, alias="executingToolCall" + ) meta_event: dict[str, Any] | None = Field(None, alias="metaEvent") error: UiPathConversationErrorEvent | None = Field(None, alias="toolCallError") diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index 9b043599c..9aa9417f4 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-05-20T15:11:06.1716446Z" +exclude-newer = "2026-05-25T19:32:11.835974Z" exclude-newer-span = "P2D" [[package]] @@ -1011,7 +1011,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.16" +version = "0.5.17" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index bee52f2aa..dabbd63ad 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1063,7 +1063,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.16" +version = "0.5.17" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 2cfaef895..a7a978f7c 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.72" +version = "2.10.73" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/_chat/_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_bridge.py index 2a382a59e..96566e898 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_bridge.py @@ -13,8 +13,11 @@ UiPathConversationEvent, UiPathConversationExchangeEndEvent, UiPathConversationExchangeEvent, + UiPathConversationExecutingToolCallEvent, UiPathConversationMessageEvent, UiPathConversationToolCallConfirmationEvent, + UiPathConversationToolCallEndEvent, + UiPathConversationToolCallEvent, ) from uipath.core.triggers import UiPathResumeTrigger from uipath.runtime.chat import UiPathChatProtocol @@ -122,9 +125,11 @@ def __init__( self._client: Any | None = None self._connected_event = asyncio.Event() - self._tool_confirmation_event = asyncio.Event() - self._tool_confirmation_value: ( - UiPathConversationToolCallConfirmationEvent | None + self._tool_resume_event = asyncio.Event() + self._tool_resume_value: ( + UiPathConversationToolCallConfirmationEvent + | UiPathConversationToolCallEndEvent + | None ) = None self._current_message_id: str | None = None @@ -362,33 +367,52 @@ async def emit_exchange_error_event(self, error: Exception) -> None: async def emit_interrupt_event(self, resume_trigger: UiPathResumeTrigger): """No-op. - Tool confirmation — the only interrupt pattern CAS uses today — is - handled end-to-end via ``startToolCall`` with ``requireConfirmation: - true`` paired with ``wait_for_resume()``. This is deliberately - simpler than the old interrupt-based flow: CAS needs - ``requireConfirmation`` on the tool call event itself to render the - confirmation UI, so a parallel ``startInterrupt`` event would be - redundant. - - The only hypothetical reason to put work here is a generic, - non-tool-call agent interrupt (e.g. a coded agent calling - ``interrupt("do you want to continue?")``). Nothing uses that today - and it's not a near-term requirement — the method is kept for - generic flexibility. + Tool confirmation is handled end-to-end via ``startToolCall`` with + ``requireConfirmation: true`` paired with ``wait_for_resume()``. + executingToolCall is emitted by the MessageMapper (non-confirmed + tools) and the runtime loop post-confirmation (confirmed tools). """ return None + async def emit_executing_tool_call_event( + self, + tool_call_id: str, + tool_input: dict[str, Any] | None = None, + ) -> None: + """Emit an executingToolCall event. + + Called by the runtime loop after a tool-call confirmation resumes + to signal that the tool is about to execute with the final input. + """ + if not self._current_message_id: + return + + executing_event = UiPathConversationMessageEvent( + message_id=self._current_message_id, + tool_call=UiPathConversationToolCallEvent( + tool_call_id=tool_call_id, + executing=UiPathConversationExecutingToolCallEvent( + input=tool_input, + ), + ), + ) + await self.emit_message_event(executing_event) + async def wait_for_resume(self) -> dict[str, Any]: - """Wait for a confirmToolCall event to be received.""" - self._tool_confirmation_event.clear() - self._tool_confirmation_value = None + """Wait for a tool resume event (confirmToolCall or endToolCall) to be received.""" + if self._tool_resume_value is None: + self._tool_resume_event.clear() + await self._tool_resume_event.wait() - await self._tool_confirmation_event.wait() + value = self._tool_resume_value + self._tool_resume_value = None + self._tool_resume_event.clear() - if self._tool_confirmation_value: - return self._tool_confirmation_value.model_dump( - mode="python", by_alias=False - ) + """For the case where there's no tool confirmation and the client side tool sends endToolCall back before wait_for_resume is called. + Unlikely in practice, but possible in theory, since executingToolCall is emitted during the streaming. + """ + if value: + return value.model_dump(mode="python", by_alias=False) return {} @property @@ -424,13 +448,13 @@ async def _handle_conversation_event( parsed_event.exchange and parsed_event.exchange.message and (tool_call := parsed_event.exchange.message.tool_call) - and (confirm := tool_call.confirm) ): - logger.info( - f"Received confirmToolCall for tool_call_id: {tool_call.tool_call_id}, approved: {confirm.approved}" - ) - self._tool_confirmation_value = confirm - self._tool_confirmation_event.set() + if confirm := tool_call.confirm: + self._tool_resume_value = confirm + self._tool_resume_event.set() + elif end := tool_call.end: + self._tool_resume_value = end + self._tool_resume_event.set() except Exception as e: logger.warning(f"Error parsing conversation event: {e}") diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 6fda35789..7123a43e0 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -117,6 +117,7 @@ class AgentToolType(str, CaseInsensitiveEnum): INTEGRATION = "Integration" INTERNAL = "Internal" IXP = "Ixp" + CLIENT_SIDE = "ClientSide" UNKNOWN = "Unknown" # fallback branch discriminator @@ -945,6 +946,15 @@ class AgentInternalToolResourceConfig(BaseAgentToolResourceConfig): ) +class AgentClientSideToolResourceConfig(BaseAgentToolResourceConfig): + """Resource config for client-side tools executed by the client SDK.""" + + type: Literal[AgentToolType.CLIENT_SIDE] = AgentToolType.CLIENT_SIDE + properties: BaseResourceProperties = Field(default_factory=BaseResourceProperties) + output_schema: Optional[Dict[str, Any]] = Field(None, alias="outputSchema") + arguments: Optional[Dict[str, Any]] = Field(default_factory=dict) + + class AgentUnknownToolResourceConfig(BaseAgentToolResourceConfig): """Fallback for unknown tool types (parent normalizer sets type='Unknown').""" @@ -958,6 +968,7 @@ class AgentUnknownToolResourceConfig(BaseAgentToolResourceConfig): AgentIntegrationToolResourceConfig, AgentInternalToolResourceConfig, AgentIxpExtractionResourceConfig, + AgentClientSideToolResourceConfig, AgentUnknownToolResourceConfig, # when parent sets type="Unknown" ], Field(discriminator="type"), @@ -1342,6 +1353,7 @@ def _normalize_resources(v: Dict[str, Any]) -> None: "integration": "Integration", "internal": "Internal", "ixp": "Ixp", + "clientside": "ClientSide", "unknown": "Unknown", } CONTEXT_MODE_MAP = { diff --git a/packages/uipath/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py index 6b2825833..fcfefa946 100644 --- a/packages/uipath/tests/agent/models/test_agent.py +++ b/packages/uipath/tests/agent/models/test_agent.py @@ -8,6 +8,7 @@ AgentBooleanOperator, AgentBooleanRule, AgentBuiltInValidatorGuardrail, + AgentClientSideToolResourceConfig, AgentContextResourceConfig, AgentContextRetrievalMode, AgentContextType, @@ -4088,3 +4089,208 @@ def test_argument_group_name_recipient_missing_argument_name_raises(self): payload = {"type": 8} with pytest.raises(ValidationError): TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_agent_with_client_side_tool(self): + """Test agent with ClientSide tool resource.""" + + json_data = { + "version": "1.0.0", + "id": "aaaaaaaa-0000-0000-0000-000000000010", + "name": "Agent with ClientSide Tool", + "metadata": {"isConversational": False, "storageVersion": "26.0.0"}, + "messages": [ + {"role": "System", "content": "You are an agentic assistant."}, + ], + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": { + "type": "object", + "properties": {"content": {"type": "string"}}, + }, + "settings": { + "model": "gpt-4o-2024-11-20", + "maxTokens": 16384, + "temperature": 0, + "engine": "basic-v2", + }, + "resources": [ + { + "$resourceType": "tool", + "id": "cst-0001-0000-0000-000000000001", + "name": "browser_navigate", + "description": "Navigate to a URL in the browser", + "location": "external", + "type": "ClientSide", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The URL to navigate to", + } + }, + "required": ["url"], + }, + "outputSchema": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "content": {"type": "string"}, + }, + }, + "arguments": {"timeout": 30}, + "properties": {}, + "isEnabled": True, + } + ], + "features": [], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + assert config.name == "Agent with ClientSide Tool" + assert len(config.resources) == 1 + + tool = config.resources[0] + assert isinstance(tool, AgentClientSideToolResourceConfig) + assert tool.resource_type == AgentResourceType.TOOL + assert tool.type == AgentToolType.CLIENT_SIDE + assert tool.name == "browser_navigate" + assert tool.description == "Navigate to a URL in the browser" + + # Validate input schema + assert tool.input_schema["type"] == "object" + assert "url" in tool.input_schema["properties"] + assert tool.input_schema["required"] == ["url"] + + # Validate outputSchema alias deserializes to output_schema + assert tool.output_schema is not None + assert tool.output_schema["type"] == "object" + assert "title" in tool.output_schema["properties"] + assert "content" in tool.output_schema["properties"] + + # Validate arguments + assert tool.arguments == {"timeout": 30} + + def test_agent_with_client_side_tool_lowercase_type(self): + """Test that _normalize_resources handles lowercase 'clientside' type.""" + + json_data = { + "version": "1.0.0", + "id": "aaaaaaaa-0000-0000-0000-000000000011", + "name": "Agent with clientside Tool", + "metadata": {"isConversational": False, "storageVersion": "26.0.0"}, + "messages": [ + {"role": "System", "content": "You are an agentic assistant."}, + ], + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": { + "type": "object", + "properties": {"content": {"type": "string"}}, + }, + "settings": { + "model": "gpt-4o-2024-11-20", + "maxTokens": 16384, + "temperature": 0, + "engine": "basic-v2", + }, + "resources": [ + { + "$resourceType": "tool", + "id": "cst-0002-0000-0000-000000000001", + "name": "clipboard_copy", + "description": "Copy text to clipboard", + "location": "external", + "type": "clientside", + "inputSchema": { + "type": "object", + "properties": { + "text": {"type": "string"}, + }, + }, + "properties": {}, + "isEnabled": True, + } + ], + "features": [], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + tool = config.resources[0] + assert isinstance(tool, AgentClientSideToolResourceConfig) + assert tool.type == AgentToolType.CLIENT_SIDE + assert tool.name == "clipboard_copy" + + # output_schema and arguments should default + assert tool.output_schema is None + assert tool.arguments == {} + + def test_agent_with_client_side_tool_output_schema_alias(self): + """Test that the outputSchema alias correctly maps to output_schema.""" + + json_data = { + "version": "1.0.0", + "id": "aaaaaaaa-0000-0000-0000-000000000012", + "name": "Agent with ClientSide outputSchema alias", + "metadata": {"isConversational": False, "storageVersion": "26.0.0"}, + "messages": [ + {"role": "System", "content": "You are an agentic assistant."}, + ], + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": { + "type": "object", + "properties": {"content": {"type": "string"}}, + }, + "settings": { + "model": "gpt-4o-2024-11-20", + "maxTokens": 16384, + "temperature": 0, + "engine": "basic-v2", + }, + "resources": [ + { + "$resourceType": "tool", + "id": "cst-0003-0000-0000-000000000001", + "name": "screen_capture", + "description": "Capture a screenshot", + "location": "external", + "type": "ClientSide", + "inputSchema": { + "type": "object", + "properties": { + "region": {"type": "string"}, + }, + }, + "outputSchema": { + "type": "object", + "properties": { + "imageBase64": { + "type": "string", + "description": "Base64-encoded image", + } + }, + "required": ["imageBase64"], + }, + "properties": {}, + "isEnabled": True, + } + ], + "features": [], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + tool = config.resources[0] + assert isinstance(tool, AgentClientSideToolResourceConfig) + + # Access via Python attribute name (snake_case) + assert tool.output_schema is not None + assert tool.output_schema["type"] == "object" + assert "imageBase64" in tool.output_schema["properties"] + assert tool.output_schema["required"] == ["imageBase64"] diff --git a/packages/uipath/tests/cli/chat/test_bridge.py b/packages/uipath/tests/cli/chat/test_bridge.py index 2da4f31ad..bbd385def 100644 --- a/packages/uipath/tests/cli/chat/test_bridge.py +++ b/packages/uipath/tests/cli/chat/test_bridge.py @@ -1,5 +1,6 @@ """Tests for SocketIOChatBridge and get_chat_bridge.""" +import asyncio import logging from datetime import datetime from typing import Any, cast @@ -9,6 +10,7 @@ from uipath._cli._chat._bridge import SocketIOChatBridge, get_chat_bridge from uipath._cli._debug._bridge import SignalRDebugBridge +from uipath.core.triggers import UiPathApiTrigger, UiPathResumeTrigger class MockRuntimeContext: @@ -351,3 +353,254 @@ async def test_send_with_datetime_does_not_raise(self) -> None: assert parsed_data["message"] == "test message" assert isinstance(parsed_data["timestamp"], str) assert isinstance(parsed_data["nested"]["created_at"], str) + + +class TestEmitInterruptEvent: + """Tests for emit_interrupt_event (now a no-op for executingToolCall).""" + + def _make_bridge(self) -> SocketIOChatBridge: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._current_message_id = "msg-100" + return bridge + + @pytest.mark.anyio + async def test_emit_interrupt_event_is_noop(self) -> None: + """emit_interrupt_event no longer emits executingToolCall.""" + bridge = self._make_bridge() + + emitted_events: list[Any] = [] + + async def capture_emit(event: Any) -> None: + emitted_events.append(event) + + bridge.emit_message_event = capture_emit # type: ignore[assignment] + + trigger = UiPathResumeTrigger( + api_resume=UiPathApiTrigger( + request={ + "tool_call_id": "tc-42", + "tool_name": "my_tool", + "input": {"key": "value"}, + } + ) + ) + + await bridge.emit_interrupt_event(trigger) + + assert len(emitted_events) == 0 + + +class TestEmitExecutingToolCall: + """Tests for emit_executing_tool_call_event (post-confirmation executingToolCall emission).""" + + def _make_bridge(self) -> SocketIOChatBridge: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._current_message_id = "msg-100" + return bridge + + @pytest.mark.anyio + async def test_emits_executing_tool_call_event( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Should emit executingToolCall with tool_call_id and input.""" + monkeypatch.setenv("CAS_WEBSOCKET_DISABLED", "true") + bridge = self._make_bridge() + await bridge.connect() + + emitted_events: list[Any] = [] + original_emit = bridge.emit_message_event + + async def capture_emit(event: Any) -> None: + emitted_events.append(event) + await original_emit(event) + + bridge.emit_message_event = capture_emit # type: ignore[assignment] + + await bridge.emit_executing_tool_call_event( + tool_call_id="tc-42", + tool_input={"key": "value"}, + ) + + assert len(emitted_events) == 1 + event = emitted_events[0] + assert event.message_id == "msg-100" + assert event.tool_call is not None + assert event.tool_call.tool_call_id == "tc-42" + assert event.tool_call.executing is not None + assert event.tool_call.executing.input == {"key": "value"} + + @pytest.mark.anyio + async def test_no_message_id_does_not_emit(self) -> None: + """Should not emit if no current message ID is set.""" + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + # _current_message_id is not set + + emitted_events: list[Any] = [] + + async def capture_emit(event: Any) -> None: + emitted_events.append(event) + + bridge.emit_message_event = capture_emit # type: ignore[assignment] + + await bridge.emit_executing_tool_call_event(tool_call_id="tc-42") + + assert len(emitted_events) == 0 + + @pytest.mark.anyio + async def test_none_input_emits_with_none( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Should emit with None input when no input provided.""" + monkeypatch.setenv("CAS_WEBSOCKET_DISABLED", "true") + bridge = self._make_bridge() + await bridge.connect() + + emitted_events: list[Any] = [] + original_emit = bridge.emit_message_event + + async def capture_emit(event: Any) -> None: + emitted_events.append(event) + await original_emit(event) + + bridge.emit_message_event = capture_emit # type: ignore[assignment] + + await bridge.emit_executing_tool_call_event(tool_call_id="tc-42") + + assert len(emitted_events) == 1 + assert emitted_events[0].tool_call.executing.input is None + + +class TestWaitForResumeEndToolCall: + """Tests for wait_for_resume unblocking on endToolCall events.""" + + @pytest.mark.anyio + async def test_end_tool_call_unblocks_wait_for_resume(self) -> None: + """Receiving an endToolCall event unblocks wait_for_resume and returns parsed payload.""" + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + + end_event = { + "conversationId": "conv-123", + "exchange": { + "exchangeId": "exch-456", + "message": { + "messageId": "msg-200", + "toolCall": { + "toolCallId": "tc-99", + "endToolCall": { + "output": {"result": "ok"}, + "isError": False, + }, + }, + }, + }, + } + + async def simulate_end_event() -> None: + await asyncio.sleep(0.05) + await bridge._handle_conversation_event(end_event, "sid-1") + + task = asyncio.create_task(simulate_end_event()) + result = await bridge.wait_for_resume() + await task + + assert result["output"] == {"result": "ok"} + assert result["is_error"] is False + + @pytest.mark.anyio + async def test_confirm_tool_call_unblocks_wait_for_resume(self) -> None: + """Receiving a confirmToolCall event also unblocks wait_for_resume.""" + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + + confirm_event = { + "conversationId": "conv-123", + "exchange": { + "exchangeId": "exch-456", + "message": { + "messageId": "msg-200", + "toolCall": { + "toolCallId": "tc-99", + "confirmToolCall": { + "approved": True, + "input": {"edited": "data"}, + }, + }, + }, + }, + } + + async def simulate_confirm_event() -> None: + await asyncio.sleep(0.05) + await bridge._handle_conversation_event(confirm_event, "sid-1") + + task = asyncio.create_task(simulate_confirm_event()) + result = await bridge.wait_for_resume() + await task + + assert result["approved"] is True + assert result["input"] == {"edited": "data"} + + @pytest.mark.anyio + async def test_early_end_tool_call_is_not_lost(self) -> None: + """An endToolCall that arrives before wait_for_resume is called must not be lost.""" + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + + end_event = { + "conversationId": "conv-123", + "exchange": { + "exchangeId": "exch-456", + "message": { + "messageId": "msg-300", + "toolCall": { + "toolCallId": "tc-100", + "endToolCall": { + "output": {"early": True}, + "isError": False, + }, + }, + }, + }, + } + + # Simulate the event arriving BEFORE wait_for_resume is called + await bridge._handle_conversation_event(end_event, "sid-1") + + result = await bridge.wait_for_resume() + + assert result["output"] == {"early": True} + assert result["is_error"] is False diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index b319fa1ba..3cf75bd40 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.72" +version = "2.10.73" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2659,7 +2659,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.16" +version = "0.5.17" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, From 825faa64d14081cce1b2eee4274824f45f217264 Mon Sep 17 00:00:00 2001 From: Norman <82416746+norman-le@users.noreply.github.com> Date: Fri, 29 May 2026 12:17:26 -0400 Subject: [PATCH 080/191] chore: update ver (#1693) --- packages/uipath/pyproject.toml | 6 +++--- packages/uipath/uv.lock | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index a7a978f7c..844177cc5 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "uipath" -version = "2.10.73" +version = "2.10.74" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath-core>=0.5.8, <0.6.0", - "uipath-runtime>=0.10.1, <0.11.0", + "uipath-core>=0.5.17, <0.6.0", + "uipath-runtime>=0.11.0, <0.12.0", "uipath-platform>=0.1.59, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 3cf75bd40..71ce494d4 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-05-25T20:56:31.043599Z" +exclude-newer = "2026-05-27T15:26:21.545236Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.73" +version = "2.10.74" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2625,7 +2625,7 @@ requires-dist = [ { name = "truststore", specifier = ">=0.10.1" }, { name = "uipath-core", editable = "../uipath-core" }, { name = "uipath-platform", editable = "../uipath-platform" }, - { name = "uipath-runtime", specifier = ">=0.10.1,<0.11.0" }, + { name = "uipath-runtime", specifier = ">=0.11.0,<0.12.0" }, ] [package.metadata.requires-dev] @@ -2729,14 +2729,14 @@ dev = [ [[package]] name = "uipath-runtime" -version = "0.10.1" +version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1e/87/2e625219b3364a7153549e6056bce41d2050725ed0844f2711c414a872c0/uipath_runtime-0.10.1.tar.gz", hash = "sha256:9ed1bdb6737ad64cc5bb7ef0c8466dbae8ca010858ecd856818396ea264eb3d5", size = 141189, upload-time = "2026-04-23T11:34:53.102Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/8d/4d36d6a5dda4ca5f25e52508bc20dd82cb92fcdf2a36cd0adc4f9832d047/uipath_runtime-0.11.0.tar.gz", hash = "sha256:cc94f2fdab43b593ef678eff904fc6cdd4831963cffe39a83909ffcf9082d76f", size = 143685, upload-time = "2026-05-29T15:13:30.562Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/41/bc3465ee89dd01f8a9045d7d22d0f0927c0d437242eeded8d3d5b33f50ed/uipath_runtime-0.10.1-py3-none-any.whl", hash = "sha256:f04483db92ee7683513762a79bf48c229c7133d5adc7fef10ea5eaa4c7ce9b29", size = 43057, upload-time = "2026-04-23T11:34:51.781Z" }, + { url = "https://files.pythonhosted.org/packages/e7/08/c7b90851d4544ff5e76ca7c55452597aae1619cf1ebc2c0aa7b098110f14/uipath_runtime-0.11.0-py3-none-any.whl", hash = "sha256:08bf53a0e38bb3d19edc6708d2ecb7d918aa96fdda13e35f3ad0e6f2a6c392b9", size = 43770, upload-time = "2026-05-29T15:13:29.282Z" }, ] [[package]] From 644a45b566708c49a35819a52a338bc76e19781a Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Tue, 2 Jun 2026 11:26:34 +0300 Subject: [PATCH 081/191] ci: require min-version bumps for co-changed internal packages (#1694) --- .../scripts/check_dependency_version_bumps.py | 189 ++++++++++++++++++ .../test_check_dependency_version_bumps.py | 147 ++++++++++++++ .github/workflows/check-dependency-bumps.yml | 28 +++ .github/workflows/ci.yml | 4 + .github/workflows/test-cd-scripts.yml | 5 +- 5 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/check_dependency_version_bumps.py create mode 100644 .github/scripts/test_check_dependency_version_bumps.py create mode 100644 .github/workflows/check-dependency-bumps.yml diff --git a/.github/scripts/check_dependency_version_bumps.py b/.github/scripts/check_dependency_version_bumps.py new file mode 100644 index 000000000..fef305dea --- /dev/null +++ b/.github/scripts/check_dependency_version_bumps.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Enforce minimum-version bumps between co-changed internal packages. + +The monorepo ships several packages that depend on one another +(``uipath`` -> ``uipath-platform`` -> ``uipath-core``). When a PR changes +the *source* of a dependency package (say ``uipath-core``) **and** the +source of one of its dependents (say ``uipath``), the dependent is almost +certainly relying on the new behaviour. If the dependent does not also +raise the lower bound of its requirement on the dependency, then anyone who +installs the dependent on its own can resolve an older dependency that +predates the new behaviour — a silent runtime break. + +This check fails such a PR. For every pair of co-changed (dependency, +dependent) packages it requires the dependent's lower-bound constraint on +the dependency (the ``>=`` part of e.g. ``uipath-core>=0.5.8, <0.6.0``) to +be at least the dependency's new version declared in this PR. + +The internal dependency graph is discovered from the pyproject files, so no +hard-coded list needs maintaining as packages are added. +""" + +import re +import sys +from pathlib import Path +from typing import TypedDict + +from check_version_uniqueness import get_changed_packages + +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib # type: ignore[no-redef] + +PACKAGES_DIR = Path("packages") + + +class PackageInfo(TypedDict): + """Resolved metadata for a single monorepo package.""" + + dir: str + name: str + version: str + dependencies: list[str] + + +def normalize_name(name: str) -> str: + """Normalize a PyPI project name (PEP 503): case-insensitive, -/_/. + treated as equivalent.""" + return re.sub(r"[-_.]+", "-", name).lower() + + +def version_key(version: str) -> tuple[int, ...]: + """Numeric sort key so ``0.5.17`` > ``0.5.8`` (``0.5.18rc1`` -> ``(0, 5, 18)``).""" + parts: list[int] = [] + for component in version.split("."): + digits = "" + for ch in component: + if ch.isdigit(): + digits += ch + else: + break + parts.append(int(digits) if digits else 0) + return tuple(parts) + + +def parse_requirement(requirement: str) -> tuple[str | None, str | None]: + """Extract (normalized name, lower-bound version) from a requirement string. + + Returns the lower bound found in a ``>=`` clause, or ``None`` if there is + no ``>=`` constraint. The name is ``None`` if the string is unparseable. + """ + name_match = re.match(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)", requirement) + if not name_match: + return None, None + name = normalize_name(name_match.group(1)) + + lower: str | None = None + lower_match = re.search(r">=\s*([0-9][0-9A-Za-z._-]*)", requirement) + if lower_match: + lower = lower_match.group(1) + return name, lower + + +def load_package(package_dir: str) -> PackageInfo | None: + """Read a package's name, version and dependency list from pyproject.toml.""" + pyproject = PACKAGES_DIR / package_dir / "pyproject.toml" + if not pyproject.exists(): + return None + with open(pyproject, "rb") as f: + data = tomllib.load(f) + project = data.get("project", {}) + name = project.get("name") + version = project.get("version") + if not name or not version: + return None + return PackageInfo( + dir=package_dir, + name=name, + version=version, + dependencies=list(project.get("dependencies", [])), + ) + + +def get_all_packages() -> dict[str, PackageInfo]: + """Map package directory name -> package info for every package.""" + packages: dict[str, PackageInfo] = {} + if not PACKAGES_DIR.is_dir(): + return packages + for item in sorted(PACKAGES_DIR.iterdir()): + if item.is_dir() and (item / "pyproject.toml").exists(): + info = load_package(item.name) + if info: + packages[item.name] = info + return packages + + +def check(packages: dict[str, PackageInfo], changed: set[str]) -> list[str]: + """Return a list of violation messages (empty when the PR is compliant).""" + name_to_dir: dict[str, str] = { + normalize_name(info["name"]): pkg_dir for pkg_dir, info in packages.items() + } + + violations: list[str] = [] + for dependent_dir in sorted(changed): + dependent = packages.get(dependent_dir) + if not dependent: + continue + + for requirement in dependent["dependencies"]: + dep_name, lower = parse_requirement(requirement) + if dep_name is None: + continue + + dep_dir = name_to_dir.get(dep_name) + # Only internal packages that *also* changed in this PR are in scope. + if dep_dir is None or dep_dir == dependent_dir or dep_dir not in changed: + continue + + dep_version = packages[dep_dir]["version"] + dep_display = packages[dep_dir]["name"] + + if lower is None: + violations.append( + f"{dependent['name']} requires '{requirement}' but has no '>=' lower bound on " + f"{dep_display}; pin it to >={dep_version} (both packages changed in this PR)." + ) + elif version_key(lower) < version_key(dep_version): + violations.append( + f"{dependent['name']} pins {dep_display}>={lower}, but {dep_display} was bumped to " + f"{dep_version} in this PR. Raise the minimum to >={dep_version}." + ) + else: + print(f"OK: {dependent['name']} requires {dep_display}>={lower} (>= new {dep_version})") + + return violations + + +def main() -> int: + packages = get_all_packages() + if not packages: + print("No packages found.") + return 0 + + changed = set(get_changed_packages()) + if not changed: + print("No source changes to internal packages detected.") + return 0 + + print(f"Changed packages: {', '.join(sorted(changed))}") + + violations = check(packages, changed) + if violations: + print("\nDependency version bump check FAILED:\n", file=sys.stderr) + for v in violations: + print(f" - {v}", file=sys.stderr) + print( + "\nWhen you change an internal package and a dependent of it in the same PR, " + "the dependent must require the dependency's new version so a standalone install " + "cannot resolve an older, incompatible release.", + file=sys.stderr, + ) + return 1 + + print("\nAll co-changed internal dependencies have an up-to-date minimum version.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/.github/scripts/test_check_dependency_version_bumps.py b/.github/scripts/test_check_dependency_version_bumps.py new file mode 100644 index 000000000..e4135e89f --- /dev/null +++ b/.github/scripts/test_check_dependency_version_bumps.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Tests for check_dependency_version_bumps.py.""" + +from unittest import mock + +from check_dependency_version_bumps import ( + PackageInfo, + check, + normalize_name, + parse_requirement, + version_key, +) + + +def pkg(name: str, version: str, dependencies: list[str] | None = None) -> PackageInfo: + return PackageInfo( + dir=name, + name=name, + version=version, + dependencies=dependencies or [], + ) + + +class TestVersionKey: + def test_numeric_components(self): + assert version_key("0.5.18") == (0, 5, 18) + + def test_compares_numerically_not_lexically(self): + # The trap a string compare would fall into: "0.5.8" > "0.5.17". + assert version_key("0.5.17") > version_key("0.5.8") + + def test_strips_prerelease_suffix(self): + assert version_key("0.5.18rc1") == (0, 5, 18) + + +class TestNormalizeName: + def test_case_and_separators_equivalent(self): + assert normalize_name("UiPath_Core") == normalize_name("uipath-core") + assert normalize_name("uipath.core") == "uipath-core" + + +class TestParseRequirement: + def test_extracts_name_and_lower_bound(self): + assert parse_requirement("uipath-core>=0.5.8, <0.6.0") == ("uipath-core", "0.5.8") + + def test_no_lower_bound(self): + assert parse_requirement("click") == ("click", None) + assert parse_requirement("httpx<1.0") == ("httpx", None) + + def test_whitespace_after_operator(self): + assert parse_requirement("uipath-core >= 0.5.8") == ("uipath-core", "0.5.8") + + +class TestCheck: + def _packages(self) -> dict[str, PackageInfo]: + return { + "uipath-core": pkg("uipath-core", "0.5.18"), + "uipath-platform": pkg( + "uipath-platform", "0.1.60", ["uipath-core>=0.5.8, <0.6.0"] + ), + "uipath": pkg( + "uipath", + "2.10.74", + [ + "uipath-core>=0.5.8, <0.6.0", + "uipath-platform>=0.1.59, <0.2.0", + "click>=8.3.1", + ], + ), + } + + def test_passes_when_only_dependency_changed(self): + # uipath-core changed alone -> dependents not touched, nothing to enforce. + assert check(self._packages(), {"uipath-core"}) == [] + + def test_passes_when_only_dependent_changed(self): + assert check(self._packages(), {"uipath"}) == [] + + def test_fails_when_co_changed_without_min_bump(self): + # uipath-core bumped to 0.5.18 but uipath still pins >=0.5.8. + violations = check(self._packages(), {"uipath-core", "uipath"}) + assert len(violations) == 1 + assert "uipath" in violations[0] + assert "0.5.18" in violations[0] + + def test_passes_when_min_raised_to_new_version(self): + packages = self._packages() + packages["uipath"]["dependencies"] = [ + "uipath-core>=0.5.18, <0.6.0", + "click>=8.3.1", + ] + assert check(packages, {"uipath-core", "uipath"}) == [] + + def test_passes_when_min_already_above_new_version(self): + packages = self._packages() + packages["uipath"]["dependencies"] = ["uipath-core>=0.6.0, <0.7.0"] + assert check(packages, {"uipath-core", "uipath"}) == [] + + def test_fails_when_no_lower_bound_on_co_changed_dep(self): + packages = self._packages() + packages["uipath"]["dependencies"] = ["uipath-core"] + violations = check(packages, {"uipath-core", "uipath"}) + assert len(violations) == 1 + assert "no '>=' lower bound" in violations[0] + + def test_external_dependencies_are_ignored(self): + # click is not an internal package, so it is never in scope. + assert check(self._packages(), {"uipath"}) == [] + + def test_transitive_chain_each_edge_enforced(self): + # All three changed: uipath must bump core AND platform; platform must bump core. + packages = self._packages() + violations = check(packages, {"uipath-core", "uipath-platform", "uipath"}) + # uipath->core (stale), uipath->platform (0.1.59 < 0.1.60), platform->core (stale) + assert len(violations) == 3 + + def test_no_self_reference(self): + packages = {"uipath": pkg("uipath", "2.0.0", ["uipath>=1.0.0"])} + assert check(packages, {"uipath"}) == [] + + +class TestMain: + def _run(self, packages: dict[str, PackageInfo], changed: list[str]) -> int: + from check_dependency_version_bumps import main + + with ( + mock.patch("check_dependency_version_bumps.get_all_packages", return_value=packages), + mock.patch("check_dependency_version_bumps.get_changed_packages", return_value=changed), + ): + return main() + + def test_returns_zero_when_compliant(self): + packages = { + "uipath-core": pkg("uipath-core", "0.5.18"), + "uipath": pkg("uipath", "2.0.0", ["uipath-core>=0.5.18, <0.6.0"]), + } + assert self._run(packages, ["uipath-core", "uipath"]) == 0 + + def test_returns_one_on_violation(self): + packages = { + "uipath-core": pkg("uipath-core", "0.5.18"), + "uipath": pkg("uipath", "2.0.0", ["uipath-core>=0.5.8, <0.6.0"]), + } + assert self._run(packages, ["uipath-core", "uipath"]) == 1 + + def test_returns_zero_when_no_changes(self): + assert self._run({"uipath-core": pkg("uipath-core", "0.5.18")}, []) == 0 \ No newline at end of file diff --git a/.github/workflows/check-dependency-bumps.yml b/.github/workflows/check-dependency-bumps.yml new file mode 100644 index 000000000..acdff63a6 --- /dev/null +++ b/.github/workflows/check-dependency-bumps.yml @@ -0,0 +1,28 @@ +name: Check Dependency Version Bumps + +on: + workflow_call: + +permissions: + contents: read + +jobs: + check-dependency-bumps: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Enforce min-version bumps for co-changed internal packages + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: python .github/scripts/check_dependency_version_bumps.py \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8be2b6b53..1dd0471f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,3 +27,7 @@ jobs: check-versions: if: github.event_name == 'pull_request' uses: ./.github/workflows/check-version-availability.yml + + check-dependency-bumps: + if: github.event_name == 'pull_request' + uses: ./.github/workflows/check-dependency-bumps.yml diff --git a/.github/workflows/test-cd-scripts.yml b/.github/workflows/test-cd-scripts.yml index a2f1bcdf1..3c6655a9d 100644 --- a/.github/workflows/test-cd-scripts.yml +++ b/.github/workflows/test-cd-scripts.yml @@ -9,8 +9,11 @@ on: - '.github/scripts/test_detect_publishable_packages.py' - '.github/scripts/check_version_uniqueness.py' - '.github/scripts/test_check_version_uniqueness.py' + - '.github/scripts/check_dependency_version_bumps.py' + - '.github/scripts/test_check_dependency_version_bumps.py' - '.github/workflows/cd.yml' - '.github/workflows/check-version-availability.yml' + - '.github/workflows/check-dependency-bumps.yml' permissions: contents: read @@ -32,4 +35,4 @@ jobs: - name: Run tests working-directory: .github/scripts - run: python -m pytest test_detect_publishable_packages.py test_check_version_uniqueness.py -v + run: python -m pytest test_detect_publishable_packages.py test_check_version_uniqueness.py test_check_dependency_version_bumps.py -v From 636d01be531031fae2a91f93094fbc472727476f Mon Sep 17 00:00:00 2001 From: Amey Date: Tue, 2 Jun 2026 10:00:17 -0400 Subject: [PATCH 082/191] feat: emit RunSource on eval telemetry from UIPATH_EVAL_RUN_SOURCE [AE-1329] (#1697) --- packages/uipath/pyproject.toml | 2 +- packages/uipath/src/uipath/_cli/_evals/_telemetry.py | 10 ++++++++++ packages/uipath/tests/cli/eval/test_eval_telemetry.py | 4 ++++ packages/uipath/uv.lock | 2 +- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 844177cc5..72b6e5c56 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.74" +version = "2.10.75" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/_evals/_telemetry.py b/packages/uipath/src/uipath/_cli/_evals/_telemetry.py index e28186576..04cb7e2c4 100644 --- a/packages/uipath/src/uipath/_cli/_evals/_telemetry.py +++ b/packages/uipath/src/uipath/_cli/_evals/_telemetry.py @@ -329,5 +329,15 @@ def _enrich_properties(self, properties: dict[str, Any]) -> None: if tenant_id: properties["TenantId"] = tenant_id + # Origin of the eval-set run as classified by the caller (e.g. Manual, + # Protegi, FirstSuccessfulRun). The Agents backend forwards the value + # via UIPATH_EVAL_RUN_SOURCE so adoption dashboards can exclude + # auto-triggered runs (e.g. first-successful-run) from user-driven counts. + # Distinct from the `Source` dimension below, which categorises the SDK + # emitter ("uipath-python-cli"), not the run origin. + run_source = os.getenv("UIPATH_EVAL_RUN_SOURCE") + if run_source: + properties["RunSource"] = run_source + properties["Source"] = "uipath-python-cli" properties["ApplicationName"] = "UiPath.Eval" diff --git a/packages/uipath/tests/cli/eval/test_eval_telemetry.py b/packages/uipath/tests/cli/eval/test_eval_telemetry.py index fdfe7135c..48911638a 100644 --- a/packages/uipath/tests/cli/eval/test_eval_telemetry.py +++ b/packages/uipath/tests/cli/eval/test_eval_telemetry.py @@ -431,6 +431,7 @@ def test_enrich_properties_adds_env_vars(self, mock_get_claim): "UIPATH_PROJECT_ID": "project-123", "UIPATH_ORGANIZATION_ID": "org-456", "UIPATH_TENANT_ID": "tenant-abc", + "UIPATH_EVAL_RUN_SOURCE": "FirstSuccessfulRun", }, ): subscriber._enrich_properties(properties) @@ -440,6 +441,7 @@ def test_enrich_properties_adds_env_vars(self, mock_get_claim): assert properties["CloudOrganizationId"] == "org-456" assert properties["CloudUserId"] == "user-789" assert properties["TenantId"] == "tenant-abc" + assert properties["RunSource"] == "FirstSuccessfulRun" @patch("uipath._cli._evals._telemetry.get_claim_from_token") def test_enrich_properties_skips_missing_env_vars(self, mock_get_claim): @@ -455,6 +457,7 @@ def test_enrich_properties_skips_missing_env_vars(self, mock_get_claim): "UIPATH_PROJECT_ID", "UIPATH_ORGANIZATION_ID", "UIPATH_TENANT_ID", + "UIPATH_EVAL_RUN_SOURCE", ]: os.environ.pop(key, None) @@ -465,6 +468,7 @@ def test_enrich_properties_skips_missing_env_vars(self, mock_get_claim): assert "CloudOrganizationId" not in properties assert "CloudUserId" not in properties assert "TenantId" not in properties + assert "RunSource" not in properties class TestExceptionHandling: diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 71ce494d4..b4399ef58 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.74" +version = "2.10.75" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From fcd0c94204150d36142294fb267817f545245417 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Tue, 2 Jun 2026 17:18:11 +0300 Subject: [PATCH 083/191] docs: fix pack default extensions list and leading dot example (#1699) --- packages/uipath/docs/cli/index.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/uipath/docs/cli/index.md b/packages/uipath/docs/cli/index.md index 165c5431a..2d8d1d814 100644 --- a/packages/uipath/docs/cli/index.md +++ b/packages/uipath/docs/cli/index.md @@ -198,6 +198,7 @@ By default, the following file types are included in the `.nupkg` file: - `.json` - `.yaml` - `.yml` +- `.md` --- @@ -212,7 +213,7 @@ To include additional files, update the `uipath.json` file by adding a `packOpti "" ], "fileExtensionsIncluded": [ - "" + "" ] } } From bf200a3a57ed2aaf3bdcdd4c461d3351c77c90bc Mon Sep 17 00:00:00 2001 From: chetanyauipath <107464888+chetanyauipath@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:32:58 +0530 Subject: [PATCH 084/191] feat(tasks): add QuickForm task creation SDK + agent.json model (#1683) Co-authored-by: Claude Opus 4.7 --- packages/uipath-platform/pyproject.toml | 2 +- .../platform/action_center/_tasks_service.py | 278 ++++++++++++++++-- .../tests/services/test_actions_service.py | 145 +++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 4 +- .../uipath/src/uipath/agent/models/agent.py | 21 ++ packages/uipath/uv.lock | 4 +- 7 files changed, 421 insertions(+), 35 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 215882460..dfc759f4d 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.59" +version = "0.1.60" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py b/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py index 662109ce4..491b1bd73 100644 --- a/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py +++ b/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py @@ -118,10 +118,34 @@ def _create_spec( ), } + _apply_priority_labels_and_actionable_toggle( + json_payload, priority, labels, is_actionable_message_enabled + ) + _apply_task_source(json_payload, source_name) + + return RequestSpec( + method="POST", + endpoint=Endpoint("/orchestrator_/tasks/AppTasks/CreateAppTask"), + json=json_payload, + headers=header_folder(app_folder_key, app_folder_path), + ) + + +def _apply_priority_labels_and_actionable_toggle( + payload: Dict[str, Any], + priority: Optional[str], + labels: Optional[List[str]], + is_actionable_message_enabled: Optional[bool], +) -> None: + """Apply priority / tags / isActionableMessageEnabled to ``payload`` in-place. + + Shared between AppTask and QuickForm spec builders — they handle these three + optional fields identically. + """ if priority and (normalized_priority := _normalize_priority(priority)): - json_payload["priority"] = normalized_priority + payload["priority"] = normalized_priority if labels is not None: - json_payload["tags"] = [ + payload["tags"] = [ { "name": label, "displayName": label, @@ -131,37 +155,29 @@ def _create_spec( for label in labels ] if is_actionable_message_enabled is not None: - json_payload["isActionableMessageEnabled"] = is_actionable_message_enabled + payload["isActionableMessageEnabled"] = is_actionable_message_enabled - project_id = UiPathConfig.project_id - trace_id = UiPathConfig.trace_id - if project_id and trace_id: - folder_key = UiPathConfig.folder_key - job_key = UiPathConfig.job_key - process_key = UiPathConfig.process_uuid +def _apply_task_source(payload: Dict[str, Any], source_name: str) -> None: + """Populate ``payload["taskSource"]`` when UiPathConfig has project_id + trace_id. - task_source_metadata: Dict[str, Any] = { + Shared between AppTask and QuickForm spec builders — the taskSource block is + identical for both task types. + """ + project_id = UiPathConfig.project_id + trace_id = UiPathConfig.trace_id + if not (project_id and trace_id): + return + payload["taskSource"] = { + "sourceName": source_name, + "sourceId": project_id, + "taskSourceMetadata": { "InstanceId": trace_id, - "FolderKey": folder_key, - "JobKey": job_key, - "ProcessKey": process_key, - } - - task_source = { - "sourceName": source_name, - "sourceId": project_id, - "taskSourceMetadata": task_source_metadata, - } - - json_payload["taskSource"] = task_source - - return RequestSpec( - method="POST", - endpoint=Endpoint("/orchestrator_/tasks/AppTasks/CreateAppTask"), - json=json_payload, - headers=header_folder(app_folder_key, app_folder_path), - ) + "FolderKey": UiPathConfig.folder_key, + "JobKey": UiPathConfig.job_key, + "ProcessKey": UiPathConfig.process_uuid, + }, + } def _normalize_priority(priority: str | None) -> str | None: @@ -196,6 +212,62 @@ def _normalize_priority(priority: str | None) -> str | None: return normalized +_TASK_TYPE_QUICKFORM = 6 + + +def _create_quickform_spec( + data: Optional[Dict[str, Any]], + title: str, + task_schema_key: str, + schema: Dict[str, Any], + creator_job_key: Optional[str] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + priority: Optional[str] = None, + labels: Optional[List[str]] = None, + is_actionable_message_enabled: Optional[bool] = None, + actionable_message_metadata: Optional[Dict[str, Any]] = None, + source_name: str = "Agent", +) -> RequestSpec: + """Build the RequestSpec for Orchestrator's GenericTasks/CreateTask endpoint. + + Sets TaskType=QuickFormTask. Mirrors _create_spec but skips the AppTask-specific + shape (no appId, no action-schema-derived fieldSet/actionSet) and instead sends + taskSchemaKey + inline schema together. + + Both taskSchemaKey AND schema are sent on every call: the Agents runtime has no + Action Center package.uploaded subscriber populating the TaskSchemas table, so + Orchestrator upserts the schema (keyed by taskSchemaKey) and then creates the task + in the same call. + + Wire contract: UiPath/Orchestrator/src/Core/Application/Dto/Tasks/TaskCreateRequest.cs. + """ + json_payload: Dict[str, Any] = { + "type": _TASK_TYPE_QUICKFORM, + "taskSchemaKey": task_schema_key, + "schema": schema, + "title": title, + "data": data if data is not None else {}, + } + + if creator_job_key is not None: + json_payload["creatorJobKey"] = creator_job_key + + _apply_priority_labels_and_actionable_toggle( + json_payload, priority, labels, is_actionable_message_enabled + ) + if actionable_message_metadata is not None: + json_payload["actionableMessageMetaData"] = actionable_message_metadata + _apply_task_source(json_payload, source_name) + + return RequestSpec( + method="POST", + endpoint=Endpoint("/orchestrator_/tasks/GenericTasks/CreateTask"), + json=json_payload, + headers=header_folder(folder_key, folder_path), + ) + + def _retrieve_action_spec( action_key: str, app_folder_key: Optional[str], @@ -506,6 +578,154 @@ def create( ) return Task.model_validate(json_response) + @traced(name="tasks_create_quickform", run_type="uipath") + async def create_quickform_async( + self, + title: str, + task_schema_key: str, + schema: Dict[str, Any], + data: Optional[Dict[str, Any]] = None, + *, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + assignee: Optional[str] = None, + recipient: Optional[TaskRecipient] = None, + priority: Optional[str] = None, + labels: Optional[List[str]] = None, + is_actionable_message_enabled: Optional[bool] = None, + actionable_message_metadata: Optional[Dict[str, Any]] = None, + creator_job_key: Optional[str] = None, + source_name: str = "Agent", + ) -> Task: + """Creates a new QuickForm task asynchronously. + + QuickForm tasks are schema-first HITL tasks rendered by FormLib in Action + Center. Both task_schema_key AND schema are required: the Agents runtime + does not pre-populate TaskSchemas via a package.uploaded subscriber, so + Orchestrator upserts the schema (keyed by task_schema_key) and creates + the task in the same call. + + Args: + title: The title of the task. + task_schema_key: UUID key of the schema. Used as the key under which + Orchestrator stores/looks up the schema in TaskSchemas. + schema: The HITL schema body to register/upsert. Sent inline on every + call. + data: Optional dictionary containing input data for the task. + folder_path: Optional folder path for the task. Required by the + Orchestrator controller (RequireOrganizationUnit) unless + folder_key is provided. + folder_key: Optional folder key, alternative to folder_path. + assignee: Optional username or email to assign the task to. + recipient: Optional structured recipient (user id / group id / + email). Resolved via identity service before assignment. + priority: Optional priority. Low / Medium / High / Critical. + labels: Optional list of labels for the task. + is_actionable_message_enabled: Whether actionable notifications are + enabled for this task. + actionable_message_metadata: Optional metadata override. For + QuickForm, when null, Orchestrator derives it from the + referenced TaskSchema. + creator_job_key: Optional. Identifies the job that triggered the + inline schema creation/upsert. + source_name: Source name on TaskSource. Defaults to 'Agent'. + + Returns: + Task: The created task object. + """ + spec = _create_quickform_spec( + title=title, + data=data, + task_schema_key=task_schema_key, + schema=schema, + creator_job_key=creator_job_key, + folder_key=folder_key, + folder_path=folder_path, + priority=priority, + labels=labels, + is_actionable_message_enabled=is_actionable_message_enabled, + actionable_message_metadata=actionable_message_metadata, + source_name=source_name, + ) + + response = await self.request_async( + spec.method, + spec.endpoint, + json=spec.json, + content=spec.content, + headers=spec.headers, + ) + json_response = response.json() + if assignee or recipient: + assign_spec = await _assign_task_spec( + self, json_response["id"], assignee, recipient + ) + await self.request_async( + assign_spec.method, + assign_spec.endpoint, + json=assign_spec.json, + content=assign_spec.content, + ) + return Task.model_validate(json_response) + + @traced(name="tasks_create_quickform", run_type="uipath") + def create_quickform( + self, + title: str, + task_schema_key: str, + schema: Dict[str, Any], + data: Optional[Dict[str, Any]] = None, + *, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + assignee: Optional[str] = None, + recipient: Optional[TaskRecipient] = None, + priority: Optional[str] = None, + labels: Optional[List[str]] = None, + is_actionable_message_enabled: Optional[bool] = None, + actionable_message_metadata: Optional[Dict[str, Any]] = None, + creator_job_key: Optional[str] = None, + source_name: str = "Agent", + ) -> Task: + """Create a new QuickForm task synchronously. + + See :meth:`create_quickform_async` for parameter docs. + """ + spec = _create_quickform_spec( + title=title, + data=data, + task_schema_key=task_schema_key, + schema=schema, + creator_job_key=creator_job_key, + folder_key=folder_key, + folder_path=folder_path, + priority=priority, + labels=labels, + is_actionable_message_enabled=is_actionable_message_enabled, + actionable_message_metadata=actionable_message_metadata, + source_name=source_name, + ) + + response = self.request( + spec.method, + spec.endpoint, + json=spec.json, + content=spec.content, + headers=spec.headers, + ) + json_response = response.json() + if assignee or recipient: + assign_spec = asyncio.run( + _assign_task_spec(self, json_response["id"], assignee, recipient) + ) + self.request( + assign_spec.method, + assign_spec.endpoint, + json=assign_spec.json, + content=assign_spec.content, + ) + return Task.model_validate(json_response) + @resource_override( resource_type="app", resource_identifier="app_name", diff --git a/packages/uipath-platform/tests/services/test_actions_service.py b/packages/uipath-platform/tests/services/test_actions_service.py index b97d326e8..6758c452a 100644 --- a/packages/uipath-platform/tests/services/test_actions_service.py +++ b/packages/uipath-platform/tests/services/test_actions_service.py @@ -1,3 +1,4 @@ +import json from typing import Any import pytest @@ -555,3 +556,147 @@ def test_create_raises_when_no_folder_key_or_path_provided( app_name="my-app", app_folder_path=None, ) + + +# --------------------------------------------------------------------------- +# QuickForm task tests +# --------------------------------------------------------------------------- + +_QF_SCHEMA: dict[str, Any] = { + "id": "7ebef452-fee9-45df-8fc2-01f1d0248540", + "fields": [ + {"id": "f1", "type": "text", "label": "F1", "direction": "input"}, + {"id": "f2", "type": "text", "label": "F2", "direction": "output"}, + ], + "outcomes": [ + {"id": "approve", "name": "Approve", "type": "string", "isPrimary": True}, + ], +} +_QF_DEFAULTS = { + "title": "QF task", + "task_schema_key": _QF_SCHEMA["id"], + "schema": _QF_SCHEMA, +} +_QF_CREATE_RESPONSE = {"id": 42, "title": _QF_DEFAULTS["title"]} + + +@pytest.fixture +def qf_create_url(base_url: str, org: str, tenant: str) -> str: + return f"{base_url}{org}{tenant}/orchestrator_/tasks/GenericTasks/CreateTask" + + +@pytest.fixture +def qf_assign_url(base_url: str, org: str, tenant: str) -> str: + return ( + f"{base_url}{org}{tenant}" + "/orchestrator_/odata/Tasks/UiPath.Server.Configuration.OData.AssignTasks" + ) + + +def _posted_body(httpx_mock: HTTPXMock, url: str) -> dict[str, Any]: + for req in httpx_mock.get_requests(): + if str(req.url) == url: + return json.loads(req.content) + raise AssertionError(f"no request was POSTed to {url}") + + +@pytest.fixture +def qf_runner(httpx_mock: HTTPXMock, service: TasksService, qf_create_url: str) -> Any: + """Factory: stub the QF endpoint, call create_quickform with overrides, + return (task, posted_body). One call per test eliminates setup duplication. + """ + httpx_mock.add_response( + url=qf_create_url, status_code=200, json=_QF_CREATE_RESPONSE + ) + + def _run(**overrides: Any) -> tuple[Task, dict[str, Any]]: + task = service.create_quickform(**{**_QF_DEFAULTS, **overrides}) + return task, _posted_body(httpx_mock, qf_create_url) + + return _run + + +@pytest.fixture +def qf_runner_async( + httpx_mock: HTTPXMock, service: TasksService, qf_create_url: str +) -> Any: + """Async variant of qf_runner.""" + httpx_mock.add_response( + url=qf_create_url, status_code=200, json=_QF_CREATE_RESPONSE + ) + + async def _run(**overrides: Any) -> tuple[Task, dict[str, Any]]: + task = await service.create_quickform_async(**{**_QF_DEFAULTS, **overrides}) + return task, _posted_body(httpx_mock, qf_create_url) + + return _run + + +def test_create_quickform_baseline_payload(qf_runner: Any) -> None: + task, body = qf_runner() + assert body == { + "type": 6, + "taskSchemaKey": _QF_DEFAULTS["task_schema_key"], + "schema": _QF_SCHEMA, + "title": _QF_DEFAULTS["title"], + "data": {}, + } + assert isinstance(task, Task) + assert task.id == 42 + + +def test_create_quickform_data_passthrough(qf_runner: Any) -> None: + _, body = qf_runner(data={"x": 1}) + assert body["data"] == {"x": 1} + + +def test_create_quickform_includes_optional_fields_when_set(qf_runner: Any) -> None: + _, body = qf_runner( + priority="High", + labels=["a", "b"], + is_actionable_message_enabled=True, + actionable_message_metadata={"fieldSet": {}, "actionSet": {}}, + creator_job_key="3fa85f64-5717-4562-b3fc-2c963f66afa6", + ) + assert body["priority"] == "High" + assert {tag["name"] for tag in body["tags"]} == {"a", "b"} + assert body["isActionableMessageEnabled"] is True + assert body["actionableMessageMetaData"] == {"fieldSet": {}, "actionSet": {}} + assert body["creatorJobKey"] == "3fa85f64-5717-4562-b3fc-2c963f66afa6" + + +def test_create_quickform_omits_optional_fields_when_unset(qf_runner: Any) -> None: + _, body = qf_runner() + for omitted in ( + "creatorJobKey", + "priority", + "tags", + "isActionableMessageEnabled", + "actionableMessageMetaData", + ): + assert omitted not in body + + +async def test_create_quickform_async_baseline_payload(qf_runner_async: Any) -> None: + task, body = await qf_runner_async() + assert body["type"] == 6 + assert body["taskSchemaKey"] == _QF_DEFAULTS["task_schema_key"] + assert task.id == 42 + + +def test_create_quickform_with_assignee_triggers_assign_call( + httpx_mock: HTTPXMock, qf_runner: Any, qf_assign_url: str +) -> None: + httpx_mock.add_response(url=qf_assign_url, status_code=200, json={}) + qf_runner(assignee="user@example.com") + body = _posted_body(httpx_mock, qf_assign_url) + assert body["taskAssignments"][0]["UserNameOrEmail"] == "user@example.com" + + +async def test_create_quickform_async_with_assignee_triggers_assign_call( + httpx_mock: HTTPXMock, qf_runner_async: Any, qf_assign_url: str +) -> None: + httpx_mock.add_response(url=qf_assign_url, status_code=200, json={}) + await qf_runner_async(assignee="user@example.com") + body = _posted_body(httpx_mock, qf_assign_url) + assert body["taskAssignments"][0]["UserNameOrEmail"] == "user@example.com" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index dabbd63ad..084f3efb8 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.59" +version = "0.1.60" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 72b6e5c56..fae01ad9c 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.10.75" +version = "2.10.76" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.17, <0.6.0", "uipath-runtime>=0.11.0, <0.12.0", - "uipath-platform>=0.1.59, <0.2.0", + "uipath-platform>=0.1.60, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 7123a43e0..5aa959e8f 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -726,6 +726,9 @@ class AgentEscalationChannel(BaseCfg): ) priority: Optional[str] = None labels: List[str] = Field(default_factory=list) + # schema_body avoids shadowing pydantic.BaseModel.schema(); JSON alias stays "schema". + schema_id: Optional[str] = Field(None, alias="schemaId") + schema_body: Optional[Dict[str, Any]] = Field(None, alias="schema") @model_validator(mode="before") @classmethod @@ -769,6 +772,23 @@ class AgentIxpVsEscalationResourceConfig(BaseAgentResourceConfig): ) +class AgentQuickFormEscalationResourceConfig(BaseAgentResourceConfig): + """Quick Form Agent escalation resource configuration model (escalationType=2). + + Quick Form escalations render a schema-first HITL task in Action Center via FormLib. + The schema (and its key) live on the channel (see AgentEscalationChannel.schema_id / + schema) and are sent inline to Orchestrator's GenericTasks/CreateTask endpoint. + """ + + id: Optional[str] = Field(None, alias="id") + resource_type: Literal[AgentResourceType.ESCALATION] = Field( + alias="$resourceType", default=AgentResourceType.ESCALATION, frozen=True + ) + channels: List[AgentEscalationChannel] = Field(alias="channels") + is_agent_memory_enabled: bool = Field(default=False, alias="isAgentMemoryEnabled") + escalation_type: Literal[2] = Field(default=2, alias="escalationType") + + class BaseAgentToolResourceConfig(BaseAgentResourceConfig): """Base agent tool resource configuration model.""" @@ -978,6 +998,7 @@ class AgentUnknownToolResourceConfig(BaseAgentToolResourceConfig): Union[ Annotated[AgentEscalationResourceConfig, Tag(0)], Annotated[AgentIxpVsEscalationResourceConfig, Tag(1)], + Annotated[AgentQuickFormEscalationResourceConfig, Tag(2)], ], Discriminator(lambda v: v.get("escalation_type") or v.get("escalationType") or 0), ] diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index b4399ef58..96764f7fb 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.75" +version = "2.10.76" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.59" +version = "0.1.60" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 335d7bc50d1d77365740c7755507e61c52e65dde Mon Sep 17 00:00:00 2001 From: Alexandra Baduna Date: Thu, 4 Jun 2026 13:57:47 +0300 Subject: [PATCH 085/191] docs(studio-web): document coded function integration [PRODEV-605] (#1675) Co-authored-by: Claude Sonnet 4.6 --- packages/uipath/docs/FAQ.md | 8 +- packages/uipath/docs/cli/index.md | 16 +- packages/uipath/docs/core/agents.md | 245 ++++++++++ .../assets/maestro_execution_trace_light.png | Bin 0 -> 591864 bytes .../assets/maestro_service_task_light.png | Bin 0 -> 596758 bytes .../assets/orchestrator_processes_light.png | Bin 0 -> 124279 bytes .../studio_web_select_function_dark.png | Bin 0 -> 153206 bytes .../studio_web_select_function_light.png | Bin 0 -> 150036 bytes .../uipath/docs/core/environment_variables.md | 4 +- packages/uipath/docs/core/functions.md | 426 ++++++++++++++++++ packages/uipath/docs/core/getting_started.md | 4 + packages/uipath/docs/core/studio_web.md | 158 ++++++- packages/uipath/docs/core/traced.md | 42 +- packages/uipath/docs/index.md | 33 +- packages/uipath/mkdocs.yml | 6 +- 15 files changed, 892 insertions(+), 50 deletions(-) create mode 100644 packages/uipath/docs/core/agents.md create mode 100644 packages/uipath/docs/core/assets/maestro_execution_trace_light.png create mode 100644 packages/uipath/docs/core/assets/maestro_service_task_light.png create mode 100644 packages/uipath/docs/core/assets/orchestrator_processes_light.png create mode 100644 packages/uipath/docs/core/assets/studio_web_select_function_dark.png create mode 100644 packages/uipath/docs/core/assets/studio_web_select_function_light.png create mode 100644 packages/uipath/docs/core/functions.md diff --git a/packages/uipath/docs/FAQ.md b/packages/uipath/docs/FAQ.md index c1cd1aec2..602a42196 100644 --- a/packages/uipath/docs/FAQ.md +++ b/packages/uipath/docs/FAQ.md @@ -1,6 +1,6 @@ # Frequently Asked Questions (FAQ) -### Q: Why am I getting a "Failed to prepare environment" error when deploying my python agent to UiPath Cloud Platform? +### Q: Why am I getting a "Failed to prepare environment" error when deploying my Python project to UiPath Cloud Platform? #### Error Message @@ -30,7 +30,7 @@ #### Description -This error might occur when deploying coded-agents to UiPath Cloud Platform, even though the same project might work correctly in your local environment. The issue is often related to how Python packages are discovered and distributed during the cloud deployment process. +This error might occur when deploying coded functions or coded agents to UiPath Cloud Platform, even though the same project might work correctly in your local environment. The issue is often related to how Python packages are discovered and distributed during the cloud deployment process. #### Common Causes @@ -283,7 +283,7 @@ If you encounter SSL certificate errors: //// -### Q: Why are my agent runs hanging on UiPath Cloud Platform? +### Q: Why are my job runs hanging on UiPath Cloud Platform? #### Error Message @@ -298,7 +298,7 @@ You may see errors like these in the logs panel: #### Description -If your Python agent runs are hanging or not completing when deployed to UiPath Cloud Platform's serverless environment, this may be caused by a library incompatibility issue from an outdated version of the UiPath Python library. +If your Python job runs are hanging or not completing when deployed to UiPath Cloud Platform's serverless environment, this may be caused by a library incompatibility issue from an outdated version of the UiPath Python library. #### Solution diff --git a/packages/uipath/docs/cli/index.md b/packages/uipath/docs/cli/index.md index 2d8d1d814..f73afa0d1 100644 --- a/packages/uipath/docs/cli/index.md +++ b/packages/uipath/docs/cli/index.md @@ -1,5 +1,7 @@ # CLI Reference +The following commands apply to both **coded functions** and **coded agents**. The entry point name (`main`, `agent`, or any key you define in `uipath.json`) is the first argument to `run`, `debug`, `eval`, and `invoke`. + ::: mkdocs-click :module: uipath._cli :command: auth @@ -132,7 +134,7 @@ For step-by-step debugging with breakpoints and variable inspection (supported f ```console # Install debugpy package uv pip install debugpy -# Run agent with debugging enabled +# Run with debugging enabled uipath run [ENTRYPOINT] [INPUT] --debug ``` For vscode: @@ -154,19 +156,19 @@ Depending on the shell you are using, it may be necessary to escape the input js /// tab | Bash/ZSH ```console -uipath run agent '{"topic": "UiPath"}' +uipath run main '{"message": "hello"}' ``` /// /// tab | Windows CMD ```console -uipath run agent "{""topic"": ""UiPath""}" +uipath run main "{""message"": ""hello""}" ``` /// /// tab | Windows PowerShell ```console -uipath run agent '{\"topic\":\"uipath\"}' +uipath run main '{\"message\":\"hello\"}' ``` /// @@ -312,7 +314,7 @@ Selected feed: Orchestrator Tenant Processes Feed ```shell -> uipath invoke agent '{"topic": "UiPath"}' +> uipath invoke main '{"message": "hello"}' ⠴ Loading configuration ... ⠴ Starting job ... ✨ Job started successfully! @@ -380,7 +382,7 @@ File 'uipath.json' is up to date :depth: 1 :style: table -Runs your agent under the debug runtime, with a debug bridge attached. Locally, the bridge is the interactive **console** (read commands from stdin, stop at breakpoints). In the cloud, the bridge is **SignalR** (driven by Studio Web / Orchestrator). The `--attach` flag lets you override that default, including `none` for executors that need the debug command's surrounding behavior (bindings fetch, state streaming) but cannot speak the interactive debug protocol. +Runs your project under the debug runtime, with a debug bridge attached. Locally, the bridge is the interactive **console** (read commands from stdin, stop at breakpoints). In the cloud, the bridge is **SignalR** (driven by Studio Web / Orchestrator). The `--attach` flag lets you override that default, including `none` for executors that need the debug command's surrounding behavior (bindings fetch, state streaming) but cannot speak the interactive debug protocol. ### Attach modes @@ -427,7 +429,7 @@ Next: analyze_sentiment :depth: 1 :style: table -Runs an evaluation set against your agent. Entry point and eval set are auto-discovered from the project if not passed explicitly. Evaluations run in parallel (see `--workers`) and, unless `--no-report` is passed, results are reported back to Studio Web when `UIPATH_PROJECT_ID` is set. +Runs an evaluation set against your project. Entry point and eval set are auto-discovered from the project if not passed explicitly. Evaluations run in parallel (see `--workers`) and, unless `--no-report` is passed, results are reported back to Studio Web when `UIPATH_PROJECT_ID` is set. ### Common flags diff --git a/packages/uipath/docs/core/agents.md b/packages/uipath/docs/core/agents.md new file mode 100644 index 000000000..5076d43b6 --- /dev/null +++ b/packages/uipath/docs/core/agents.md @@ -0,0 +1,245 @@ +# Python Coded Agents + +A coded agent is Python code that uses an LLM reasoning loop to make decisions, call tools, and produce a result. You write the agent logic using a framework of your choice — the `uipath` SDK provides the platform layer: authentication, assets, buckets, connections, tracing, and human-in-the-loop. Package it with the CLI and deploy it as an Orchestrator job. + +Use a coded agent when your automation needs multi-step reasoning, dynamic tool selection, or LLM-driven decisions. Use a [coded function](./functions.md) when your logic is deterministic and no LLM is required. + +--- + +## Architecture + +Every coded agent is built from two layers: + +| Layer | Package | Responsibility | +|-------|---------|---------------| +| **Platform** | `uipath` | Auth, assets, buckets, connections, tracing, human-in-the-loop, CLI, packaging | +| **Framework** | one extension (see below) | LLM calls, tool routing, agent loop, memory | + +The `uipath` package is always required. Add one framework extension on top: + +| Framework | Package | Best for | +|-----------|---------|---------| +| LangChain / LangGraph | `uipath-langchain` | Graph-based agents, complex multi-step flows | +| LlamaIndex | `uipath-llamaindex` | RAG-heavy agents, document reasoning | +| OpenAI Agents SDK | `uipath-openai-agents` | OpenAI-native tool use, handoffs | +| PydanticAI | `uipath-pydantic-ai` | Type-safe agents with Pydantic models | +| Google ADK | `uipath-google-adk` | Gemini models, Google ecosystem | +| UiPath Agent Framework | `uipath-agent-framework` | UiPath-native agent primitives | + +--- + +## Quickstart + +The example below uses LangChain. Swap `uipath-langchain` for the framework of your choice. + +//// tab | uv + + + +```shell +> mkdir my-agent && cd my-agent +> uv init . --python 3.11 +> uv add uipath uipath-langchain + +> uipath auth +⠋ Authenticating with UiPath ... +✓ Authentication successful. + +> uipath new agent +✓ Created new agent project. + +> uipath init +⠋ Initializing UiPath project ... +✓ Created 'entry-points.json' file. +✓ Created 'bindings.json' file. + +> uipath run agent '{"message": "hello"}' +``` + +//// + +//// tab | pip + + + +```shell +> mkdir my-agent && cd my-agent +> python -m venv .venv +> source .venv/bin/activate +> pip install uipath uipath-langchain + +> uipath auth +⠋ Authenticating with UiPath ... +✓ Authentication successful. + +> uipath new agent +✓ Created new agent project. + +> uipath init +⠋ Initializing UiPath project ... +✓ Created 'entry-points.json' file. +✓ Created 'bindings.json' file. + +> uipath run agent '{"message": "hello"}' +``` + +//// + +--- + +## Project Structure + +``` +my-agent/ +├── main.py # agent logic +├── pyproject.toml # project metadata and dependencies +├── uipath.json # entry point declarations +├── entry-points.json # generated — I/O JSON Schema +└── bindings.json # generated — resource binding overrides +``` + +### `uipath.json` + +```json +{ + "agents": { + "agent": "main.py:agent" + } +} +``` + +### `pyproject.toml` + +```toml +[project] +name = "my-agent" +version = "0.1.0" +description = "..." +authors = [{ name = "Your Name", email = "you@example.com" }] +requires-python = ">=3.11" +dependencies = ["uipath>=2.0", "uipath-langchain>=2.0"] + +[tool.uipath] +type = "agent" +``` + +`[tool.uipath] type = "agent"` is required — it identifies the project as an agent to the runtime and packaging tools. + +--- + +## Input & Output + +Define `Input` and `Output` as Python dataclasses, the same way as [coded functions](./functions.md#input--output): + +```python +from dataclasses import dataclass + + +@dataclass +class Input: + message: str + + +@dataclass +class Output: + response: str + + +def agent(input: Input) -> Output: + ... +``` + +--- + +## Platform Services + +The `uipath` SDK gives your agent access to Orchestrator resources at runtime — credentials are injected automatically when running as a job. + +```python +from uipath.platform import UiPath + +sdk = UiPath() +``` + +The full set of Orchestrator services is available to agents: + +- **Assets** — read credentials and configuration: [Assets reference](./assets.md) +- **Buckets** — download and upload files: [Buckets reference](./buckets.md) +- **Connections** — Integration Service connections for ERP and SaaS: [Connections reference](./connections.md) +- **Context Grounding** — semantic search over enterprise data: [Context Grounding reference](./context_grounding.md) + +--- + +## Tracing + +Apply `@traced` to custom steps inside your agent to make them visible in the Orchestrator job trace view and Maestro dashboards. Do **not** trace the entry point — the runtime wraps it automatically. + +```python +from uipath.tracing import traced + + +@traced(name="lookup_vendor", run_type="uipath") +def lookup_vendor(vendor_id: str) -> dict: + ... +``` + +See [Tracing](./traced.md) for the full decorator reference. + +--- + +## Framework Guides + +Each framework extension has its own getting started guide and sample agents: + +| Framework | Guide | Samples | +|-----------|-------|---------| +| LangChain / LangGraph | [Get Started](../langchain/quick_start.md) | [Samples](https://github.com/UiPath/uipath-langchain-python/tree/main/samples) | +| LlamaIndex | [Get Started](../llamaindex/quick_start.md) | [Samples](https://github.com/UiPath/uipath-integrations-python/tree/main/packages/uipath-llamaindex/samples) | +| OpenAI Agents SDK | [Get Started](../openai-agents/quick_start.md) | [Samples](https://github.com/UiPath/uipath-integrations-python/tree/main/packages/uipath-openai-agents/samples) | +| PydanticAI | [README](https://github.com/UiPath/uipath-integrations-python/blob/main/packages/uipath-pydantic-ai/README.md) | [Samples](https://github.com/UiPath/uipath-integrations-python/tree/main/packages/uipath-pydantic-ai/samples) | +| Google ADK | [README](https://github.com/UiPath/uipath-integrations-python/blob/main/packages/uipath-google-adk/README.md) | [Samples](https://github.com/UiPath/uipath-integrations-python/tree/main/packages/uipath-google-adk/samples) | +| UiPath Agent Framework | [README](https://github.com/UiPath/uipath-integrations-python/blob/main/packages/uipath-agent-framework/README.md) | [Samples](https://github.com/UiPath/uipath-integrations-python/tree/main/packages/uipath-agent-framework/samples) | + + +--- + +## Pack & Publish + +The same CLI workflow applies as for coded functions: + + + +```shell +> uipath pack +⠋ Packaging project ... +Name : my-agent +Version : 0.1.0 +Description: Add your description here +Authors : Your Name +✓ Project successfully packaged. + +> uipath publish +⠋ Fetching available package feeds... +Select feed number: 0 +✓ Package published successfully! +``` + +After publishing, the agent registers as an Orchestrator Process and can be invoked from Maestro, the Orchestrator API, or the CLI. + +See [CLI Reference](../cli/index.md) for full `pack`, `publish`, and `invoke` options. + +--- + +## Studio Web Integration + +Connect your agent to a Studio Web solution for cloud debugging, evaluation, and solution packaging. + +See [Studio Web Integration](./studio_web.md) for setup and sync details. + +--- + +## Evaluations + +Coded agents support evaluations in Studio Web and locally via `uipath eval`. Evaluators cover LLM output quality, tool call correctness, and trajectory analysis. + +See the [Evaluations documentation](../eval/index.md) for available evaluators and how to define evaluation sets. diff --git a/packages/uipath/docs/core/assets/maestro_execution_trace_light.png b/packages/uipath/docs/core/assets/maestro_execution_trace_light.png new file mode 100644 index 0000000000000000000000000000000000000000..d3d36416814f9ed91352506c48760134baa8ba59 GIT binary patch literal 591864 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 z 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 kO>7~f?aMgV&RPk!Y^We=)~r?VvKvM{ z^lDKt&GY1mO{b(!NV=-lYl3)bzYS9j754;EgcQ7pXS%wd+wH$9gSW2Z`7$55sA9(5 z!QF30Bv7g4k6r!lsX0{P|1;p`jlyeGY8Y$FlfSb=YKq`u+-boALZ;DvrjiDaD z?#FU-jXu8NO_xBFPNt31+iyN~6sGtTM4aH`HA}D!Z%RxZ>6)F9j0$bNZr)6udO$!vWDzG#T>g#PplZ_EP&4bTN? z)wtgw?#^E!E-ThKNx#$ptl~z_xv6PXLkOEK5Bc=2sMKuT{kpVQyp`t1h*aInt=*A@ z47q&BKc)8$XVqqvXohF)Ao)vRwSfK74B1%`_!e9Cz2=5JegxLU$(YT=rMTs_Sk1vS z2gLsVRQl}7Y|L0<2a1SvxZ67CK%Y< zdui`e`+Qf{r$m*w393=h!@XfQ+Y?D-;CXoCi?=7*z~dK(P){nK%K5aSn(qE}7`*-NdR2@ysWw_Q^2Tul|t$D3=c;J}#?JbZFCS`04LZtJck z-c)+8jqQ`gxqX@Yk$Ls8gPg&-b!QK}5>>@A6>mg)s%8A}1g$y-NdB)^!)q!MI~l15dkTK7P{W`8s- zhP>8wX^>478iaE+%s#kYo4@94yzQ$6neApY5KkqZo{TX{&yKG1@#t*3SgE?a8CJ>T z;QV8Ow4Bj)-?@(e&-3y7Ubei$qvV>z&XZhxWAC~fK12Uh*zVV#-7AQ>%H~vI*7aza zR;~CG+G|(ar;#zM#QI&n!zf$G>2^s)V!NblWGAPZ$jQxO-o zqv zEGNzmS1so4L(fJ}T(RwLrp8XHuU8?GBiQTj%{QsE@m#c!KQguRgfao6+g!d>f5>o} zW5cw~!)Kr4^ggG-`-z>P9Ie(or(F7#prq{xbazssUHpKu2CsoF#g*+Y;9*?h$G^5_ z^=*xi!Kk9!Y(Gitm2Oee<%%pU^NR7?1EbH#wv#lcERLVPP3MY^G;Se*_VR$np7AIOBy$Qzrwc*LL@V?rJP;==XTIy5o(?TUU7P%^%z@be^X>UQ zjW{$DzLqsB1huoD*(XsZ zQ_Lg8eS3DM>K+_%6}yvNa(=e1LNTc1Zd;c5JJL?wU+-a-)%qDlQH1<9uZ14*SmRV4 zcN#ik`ubEf(l!h@7G7O;kyw3(@7z|i`a6SnkDqfd+$B`AHc2D#wb1z#7N1ZQnqwF- zrE9iM{>u5YQuU~V-v>Q!k&d6YI)Z1hl8X zrw}f!9R1;o){p994z2SC?tq*9e{+CSRrjytf9Y`MJMZo!4^_PCwIb>u+&oilb2A-g zL~XjfK6-Q>hWN(-mnq;?26y4~Y&bV^$&MsNdESy3czWyU=>mk(*N_*~O+cBNgroVBlk-!abX{9Maaw8w1~`&l^*q8C`@sJX?vZ_;=;E3~n2ZHj93Szp!k*ZqE z<~;fFiYH3a`IYD@si@H9xic!D-|8e;a~ZG@aM*KQs!fg0zN&glStG(-blV4Pdud+! z+UhhhXmA{PTKl^WGLdZh|1E(kT+2T{Q6t*FmJh?0~ zgJM%#K||1M$5Y-2nL~O>?pz3qcb;p3{WPxjl*cxL&pKU-Rde=rKgK$rL}V91sZ#Du zTM>0firX5ADxkCbz;)X1c4oltHCKk8q#k%C7~#aX4UMBWq%nPNw)jQ_3;2_~%*2!B zRZ)tFChTjHgVd%oyBM5xtplt$GSr)d+SZ908bN0+f#QKlonUq1;_H&>T27zX=Q`w1 z>}8QjFS$8992{SH=d>aAdHzNEoZaB2h(7t28|5O5RDAcoH%TL=&UZ|3{J8&7dK%ao zn2w{Z!1E{_k&y&NJlNaL&~oYeT0nlMeEa3gsjP`v&wIW~iVx1T*JrI>j6}zi`>wt~ zPo)AV)2oX@X!)c{Vl=b)!QK8nb+k^UW~Ja6;k#x=dQJ0uh#TT@%Twl$QF#9w*Uqw< zuPX{4u`4v|0<6Luj;0hZmuO|Rj_H^{?Djj9h(N|OhnTGGI>1{eYAaQsN_Ovm$3cWIbZ-(!UmS2CFF|Sd<>c_7(^2pc&OhLsTgYE{h`9cWjQ*cM9-?>k z!fF0j;3WhW6%+F-@CNM>q>YQ9%-SmzSQ?@iI09`RNuAV6&0gzt}Thz9bC ze!nZ2#?MjneJ`3((Y!Z#qxe}`jvV_;{;yCp`r=&s7eynO+-QI2n-20#9B0_xhuqB6 zMv8bV3E0ZvX&-CjQ-f$Gun!z@ZI)C8&WN(f1JQqd3E*E-%e#^ZS$5l+gX7DHxB{b? z1{+E5AiiF*%!IrJg?(MW8(;|!)Y+Zs7I;JHEYM3gLr|e`YLnGe(v#E0-V#~at2F-y zYnLU%!t$1-!I%cylL5hRaA6X$v0jQK2_qFl{?Nl^pi)=`cB!q~_=|eVPBRZLyRR zM-hTNN)H84fTLwvQhI&xlQ_vQBQ0WG8@V^I5NnXr`n!Ch8su23a4GzE`miX!Xut7} zys$xG+2@e+(6ek13m`C7l3RSNG@lRKK5d)Zt@Uzbhsw#urY)~+Ac$ANP)yKc@Qw8cRJ;nKgCEU>kPj#6-mlkd*fq0%M7zq*05yXR9 z1ZvS&=3L+4UT3Z^EOK?~koh$r=~qC8hU18r4EOL}MvMX#%24ZoQWKhnFx&XufpFZ{ z)~@$~*m8!Zc|tKhc01OpV+)77UUnUcXs`upRyzRg>TfBNgBu>JqwVMJtnO!gC<%Y` z-o;vUv^XUse(-Mk||ye>Hv;>&Vj6(j;hF|@5*IW^KG4ep{kd&dJg zA$c3BjAE8U^`n=K=m2sb4LzVYN1j1#e`50G22Es*&+2{Rx>1WOSTtifWf_*@i`|i6 zLlY}*Lb|&_LO=wRkS;0d?(QyW79ovvceiv%cP{D9Mf@l1v-fuI z=Q-#7@{SKM){n_tbB=M3d)#$hLvqlX9Xit<48IFKuz?tLJ^B&J%d_qb%JU6dU7Yaz zkqv)C{&eE`66bystLG^ftT0UYM#!B5vn$?9#lfM?47r3$#ZKA)dxUJdW zh{&uo2zr?tI}FntTmwps`|Kf_FZ)*Fyh3bM&+4tJ;W5}5VsRAH9?FoGFGg*35^gF}%RgP+2_E zbYV?eQVrFKKatxwlpH*_#ZHp~H5c&;a0bLG^yg_`;+qd#n>YpvfjQ8ihY6NEVC|+V z;`Ri<6x(?Il-Fc}y84Mx(UMSP^aEsI~obD2315x1u1N|x)p zq5rTq!Or`lsbB9))Yl{+9i4t3+J3FLxo6gI`YAbWh_`iaPai1vF2_1QclIdVuRAhTZQ<#k*=KR9U!60=kd!A}kmBQdqG+#eSQLuM!!sYyV9RwZZAe|13&4S7Dgwrl5W2`n(T6 zWUi(2xQi_OC0#D}ia-UC19nWXHPcjj?Q?Cq+tApD%Z&gV6D^rM}35Y`> zTL{|38hf)-#m$8swYp(2k-xk){&C6;_TkbAy+iV1%ZwP*%0vQlDw*3-vqDUC-NN%1^&;SvHzFY&g~-BzH}XAF~P?eYKzORl)w z_Nk{z4ML9J9jIqUrzUjr1IYr}*jtNhnPcN)tt zE8JA=8tXlSA$`lsim*eyT0F@;36{hKCYQ|h{frRX)$`*Wm+y~h>ciLj2gjzUCtJpf z;R%~lAuCyp_3kw%-Z#WWSCrX?Hp0P1wi(;NC)UT6MPx*4wrIx%X!rNS>hkX!{lk?2+yDblvxjMsvHE0dbOxM_O9Q939W z&{$labw^_IGwW&&^@P(c$W82-AygJQ!xgfYF#L9QN#pFK1v z;oF zVjcFHp^5rcE)c1~gSlzV8+fLls@F6VTB%wn`6$z3Eo-uNN|$o?JT?8M{Xq2q{tYZ2t)U&_G==b zdTLf^Mk)0@T-|rS+2im)XjD95ihwI@S-Z2Qv2yxGz(-x5W5xQR$nVbqug`EfYk$0I zmNAQHbECzY=V42YF8V#UaTS{xjp+gw@6-S0)KzATB)pLXX z_`@La4DNBiK00iWTNE8@h6>b>9?c@!$lii8iX# z$HInmAgcZ_xz~GEd!~xG=+3iJF@_h#>vL`bEsoOC@sJgcZCVoujofx~@kj~tPcusd z&@?pa0OtZmeXlq~4D6hX(xOeDVy9*Yo72R&r+{-vPQWwK*_Sp%BVYJtK8%>veDmbZ zFSk|(mcqsxfm7Vmx0*+9H~s`=0tBhCKmtaVJn=N4zyIL#oW@p!b=I))c<4pa-`9wU zbXZsnsOYC69DG~kE41jLZoNdtx4jlkI{A)&JkKVhNfp=1a10X^=;X`Aej~}gE(UxqCiS&9e6?_ltXoG%NlH6$jjw>kwMF&)WM$kxBydK^&j}4WCvj(U7>~>H< zL(|)=148@PB|sF=eb4N#O;u<*ugC{nO2Mwi7+44+BFX^uf z2gt)`!E!JjL=ubUroK;hXIJ!+r`GtduM02>_tKZGZDC481qOQS33W_$Net9{h5OVT z^M2-uzR+X;YcAHFjL7hld@Z(8ct~(18>LKSGp{QTsF}y~)ju36`KK&LuJm#@#JWj_ zS;12(mOmyEz|u;7OuZ;X0CF=pzj_VVSnh2b_f2geo0ecc*ubp5GwBcoVThZKisZHJ z9xprSE9YqAV@Z{|rq@M`5E(Af#4%X4VERbCI}~Lm)*Dwo%kAe^sA@M)G}G{7Ks7B8ox_uPz@!1bm$^Ba_CRr2(aR0Wi9^OYBX4E~b z56Lt(f7*+U^a#oH(a(anEWq-uKIq51QJ!gpi77Zv75EW`vTDZCtP=YcgDL@ZU7Z4` zT_Lj(*Y8H^Cn^)x9~U6;5W9H#-2&t~e}fx(o1~@?4KeAFRj?lG&f+6Td(JN47XdHIH_C%vLgFbv0Mc!jDPktj3v_8?DJKt5~tbb(^c4L;jtnwi)NIw9&5^Jhf z!CwD#_}DO`Y<&TDN5y(!#zoX|!u$(7N8i*waGFRv-F4?=c(eSM_LIg5+`n8mkzS27 z0iu6BQ$W^ewmGu?d0p~5+|4Ae{j1nhvAweOJ(KQ|Ef71u=CjTzQ!$CV`3v`}5rUB9 zgceAg$C0f=xwGJ&7m2J8TaC4iCTT~_vF*gdG4tX+?|c2&M!w+wF^L`Mfj~inIjsN} zopUAPw>@Fo(0a{~*KQN=gV#pM-3emdknfAnmUs=Ws`d07c>Tx9N)mRfG>5FM3{ddI z__}&)MZd2oqvWhgAf|T9@SsO+lt}lG9QVMpqd6zP2MOg)k7#q*2RlcNsP5cNxBf~a zvKVzl`)lSSna^{OvFWcg^3C~8XjQS6YN4{>#aJya)JT_cA4`1E37>TNirUn{qrUD3f5kIye*Y`6GL)MO2Z zHf1GDfPqwKQ&OoJG;NyJ{M?B+=FPS&4R;DX0gWo&oWZIEwM}B@Yv>)(O?>c(%!du% z5KNOf-jzEKfx}BXbi$IVzs!doRM8jzW+bEc52A}_E)tOR-X6|N7aLr&S~pJ<2G=W} z&zus>JghwE$%s?mBE*>0c!i@TlLv5w@G?X zWZ7Cw^(Kk*RaMgOb7k;^9P8|kKM`BNpdJn7Y& zv5>jCV5)~V1NVzFGCp#HMg@X|b+gax^Y>mMLVNEB{@yxJ|MZ|_o$_iNFDL0p;sUA7 z9-yQTY7JHaz~cVz%@PwR*!lg2|LV_)01E!!O>43w(M2u8r&{}P!aoXc^p5Gb{SiW)A*nUk8k4u5<$TMlwq_SFUqfokJPGf*Oj zxxOj2wW8JhZ;xkn`Mcxy&+mKq1b(h!S*{?`Iz1>SXRJ!`PZc_6;X++^YpU>qqfp-R z_-%F&IL>}v(WvpaFW}&wY{vh;UR}KO9#QtcxBt(f3j_?f|D&0u|2OgZ`%S@=|G$5| zm2%c?MT9%u&O6Ao>6aTjJQer45$?a#2EazkCi$NWqSgeha9i>~kwwXsKL(}YkV+Fg zv)-p{@F)KvAgi_d|1nmwD9Q$B2d!SdHV&p(sNFBCC*%aQd$Th#RSgEkCK3qVUoNUy zU;4UO^m6<6*lzWhXv|e&dyt1h%5Q6JJ5aBZV%*J*lYdhOYl&I^9x(Bb*O7yDddG%0 z8!Fk?(Sr#)bJ-`FZp+>=sUzBNA0gaXsP&Mnz9MQ6h?_7sTk*zJp7<2Gy-+H=Xo0d6 zYv90kf!4rg^_zky66U_4`{$g=!fmQH=?!(t+^I;V28 zhjnsX*j<1j!*qTNFF#s*`uiA6(%n1U!ibMggbcAGfZ3 zyQUV=Q8S(Y;ki0HZdjjYMHP12r`~k}sWs@05H~X$}x0MCfze|q5f6dheQF6{Y8s!yvP+I9_J!4^xJ|OTALtNGjP4l+J zip1;84ulH}MtI3&GNR_REfak|=4!&s&lH~?4yrvn(_O(|KkBGAMAW`}`A*AJCI`!4 zIO4%om#4CuWI@>}JcM-xya4qmF+G zUDO@E=C86-6?mkZ`ik5IXIV2V)~WueccWv=A|(||CK0vny~a?!y(;7f4JlL5!5#gp zWsB@Fo1qy`lv@L)6fkm@J9>PXOvJ&h{DNj<@^i?5jhujnD^Mt7WlKE`$?B;b%t_x~ z`~(oL6-2$$UF(SiHY+kh@dwkhK&rFMdU0sUubV1qOy=igLqWUoCYJ%a^R0&!o9e-3 zuqUfUBh8wn&47^CL>6oe4o|wk(9eoN%Ns}Nt}>|MppDU3E@8IPm;*Eb<|t>6simph zqp8#o>z+8xf)+J)x!>-DhTDt5FJiteo^2>B|9vJof-bBh1JoKD4vS2Z$w`W`zE;qC z?)n#ccRwtWqHq24BB96tLKJ0X*OQHb$i?__WjFBS8|$Gb8n_3X%iyET;r9t9DQBEF z?E}Nv{@4n@1*-FiUV1yKtu*22BwHo6TrIKhggQWkJ3J}5`~4+pIoM5=F5itddYI8D z7>zG7c$+IH8DqJskovSKlXwgT(N zU~Ejx`jq$t;mr@@h=LvO!?cb?w@C%hvJuG(nVTRJ$f%I9aVD2K2d0mbIsuxje)Quo zvu^sKHy&ih4l7E$$e*$zLMfV({g_F)!+fT2(yHw~U6t>7*o!IqV z=IVp4h`^3i7~X9RZf%%D%}xNc&Gd0&cT6EGx5|+=D6rfv3RT{e!>cxbH=IED+Ms-M zu?jO6(kCCQ#vjU4 zn!Ch2QE<&QG98jxLDA^lyi031o_6{iOHXcO$;y^pA zCdOoS@yN~qc6^qFmL&0__?{&pIFL@Xav;A1P+djuUf(o4$EbzRy2FRa8(I$_`m&2Y zS#gwf<>tQ-xV^+)3U73|8YABRJx&EEYZEdnzTCNhmLl~p>h%?Rq#{M;6jkOMKDG~F zoSqjOuICO5FZn!59@E70Fh@AOpxvc&!sNgG*Z{-48aS<6_$swGug4)nRx=o*;qcMlV7xg!I4K!aw3>~heTCGr{ZS+Nni3}LM!pu;J0~P37+Xtn z>E<n}VR1-;~NSB-KWD<~5#&p{{9NRo0;1lSM);`_{u1#3Wn z2E%DxJMSK3_xcfHoUn1v;*-e$obO#R9mGpdf>qtdnwJUMQ z73M}nw(i5OU?`WF>vE9)Wsb+D$s_;eZ^p11!1W&)@I0Ij98w|wKN}_VcV2{mo#Qh- z$tqqDHbE6JORAVf5(QmM$aS8_+VWKKcipw)12Y3xzQWz?o%jB?efzcbD<^d?td;Hi zq=|27uwgU)P*jgj>-dDl-(asAj;Gjd%tz(Wy;uwHJQhv2Jhp?x0jJ9gFEhdx;$-6sDZ(q87%gAQ>xUX7eww$Y! z)YFH6ihk&I^cEi|voK|#O~*sbxQ!h3o5npG!sU&LMzwDyD|UCG;(hj&p43^ZT$PfP zWJQAN=yE{TO#>_Vxb~;lpz+4C!|j<%WL&Qv*P`R};A&8hLN`-|a^s%*lfTZOy4PCb zgjbj1YuqJ{RtAr}xiL8@d(#rN*r|}=amTy~Avdnl!JOi+^)qW-eu=Q-t!lL{TO!c$ z!BoAs6Z1P1pVSf(5_TkGjTRFU{x!-LjZywg+?wfEnXdFxwjL$(i%+kha7Y-iz@IwQ z0O9~xht=|VfEd9=muNxg=USSN&F=d+|J}ZsIQJw)68orSqxx`BCdynEPj_&yK1emM z6cv5Vto=BWN;){xG=3nUsTGd9|5*GBB{8~9c3-=&qn-x8callMP8hz7d5#r7+D z#%{4vXoIci*Rm6uh$?OP8zKv({b9(k;*nSw~OMN4l&R-e_*Vsq9mu_->NQk+Luke{c(< z^KLY$d!XCmbm75nuC3F%Sx$P|vrykxK!z*+q5gW*D225-ztYma+s>|*8;hD+b-}0Y z%mH9LNEd)rQBs{>YB4K+lE$FEXQ*pBfH4ek1;$wN%%byDCv)l(b~`%a9;+GBUyU3W zVKlJqe7qXc5WHwVo%=-h3^X(m63-Mp3{$R*bNl7cE7b72{;#e${m>@?ji1mnRcTiD zCiasu$_?||e(_oH7Kh*q7As$N83lJ-5qii^6i%ag*jL~B$XByscU6ILWl%{>gmAo_ z{4iE+8l2Vv4w|*H5_tAzqJ#$hgYj1q62QJmNNYjXIE>#h4 z#AIF@i7i+N2%eR~>7yW7^1DMV7(M6VnRMm@g}hU71B-_2{eW5S@nB4Muc9XgfaW>x zz)f`V^V@dRdO&?u!?x-K5gPJcq-dROiexi5wAq@!(L?Va=6NFEr1seMMLD^4vlKA3 zAYp|iYol4&##R|UTjl||AR-}Wcn7z=lAx-e#^?8dKum~H8S*BMI;&&R7YN_xT0%z; zd6`**p5JpOK-~e!JLq;?FMnRf*LYC49&Ap>ptEFT_v8>mcvHYpl7D&N#)XwVM7gy7 zM-Ti^Z_~!nhMhs`EA7D#SJR6xQQ4|m(|!QwGWtoqTYE)_OVoL$GaXT$ z+;s7YgPR3K7F#Q zsvV#aw{()N%-uLxjP3#8IH^)zW5>)_3{a&<<$eOa$CAmg1t457`KbFZl$CQmFMn5e zONU)%xv3^6>;(F=%Cg9|jj5Xt<7xsPaWZ?`@_pEvME2n%53aFgd2nq^rVBNXdxG@s zbOEhqUuxaNDjI?U9$R;=SZ!*w&csBU?pHI7`p)_?HkT@}PjN_>NBwMl(S`E6X|bq* z;6m1PH+6vSz;A%5%$y3pjId~`Tgch@x<@2B6eolO1j5UV11dQXm0|7bU7Xmej zUv>Sw8*}BCvM)c3qe$uFzq8BRsoNJLRYHn5TP<4AaopZ5I*)&dS=G;S&M zM~SEX>72FmLQ2=;BiW{4;zO4OHUpDH@#3b{z^bI;lQTHxb4856zJm~&JADl#<|xbYgg5-85lEgJ=~GvLxw=kGWFUl^{{l+_syV5R;pe~ ziYk44D0Vzw@qd%d)BX&?#yhrX5c(_A$9H9Z&jB4q-iE`e%w|KP4Ui`&vqhFDA)=@X zx|pD((rdi`x$@=nCoxPyqcHJl4scvSxaK$Z5?Zx}bE>4D*)#72jo)3LgrHapRi z&fvnTjeTy%Y3?0Y_=z`?p^2#O=TmN>fPa`u#~v3>u7CQujT;x%cr+dIENcHt&rOFk zip~Uo?xZP-b;hc=Qgw=t?RLVU2UpA4=STZPY;SGgPZFg1ji7db^<{{HRLVq)Lev{D z(uic?v}3|97NR@B-;#lGW6-r`wmORQj2|o>mnV2;_F!Fd_$v&+vE*x$_ts7OY?>(AMKAyBKLro_an;p|mfaKv8Xo&+S}AV$Fd&NsfVX(`meyzsT4C+1+c_$b7l8 ziBmGraERcsLl6CF2(D+on)vG zFl2wNVDr4_neGn?q0k1rMBkV)YQZhTV=gBLxzcYsfloBLI-NY)#ga2Te{=&3+qOh` z8vSH+bZqCb%0ZztVP02cekGfq;SNX3rK1mH_4YB^Hdd$CvBX;eh#0g_|cqed{~=y zFWn+3E!pUW&eFQec{m$kD8%24_#r{X5t*-90%Twt9wDhd7waDdB%7%eFx?lrOo<#c*$u7@2#pdLxXU%wH^NQ zO|K5JQc0_;cYqu_nML~H2dqINVR0S*rh)0R8<&%2oQyiLoI??mDqaS3hDYr?H|}1( zkw|Fc;&NQtpY{yT1reJj4%G=$NZQ2U|qNY-@w_tWiVb5k2x^t-FBMoa@*DlTY{PCKH@ zD<8m)vNoUh+B`b=0Gzs%=MWPrbT5YagtmMj=IMq31+I5;`n;vv1(>J`^}ffONfzXy zv|19xkXJ{P2YgE0S0Z*I39G^gkfE=w|47+SGk4SknXiNNUG z^@01RtqTZn=GjON=##gf*N(uU;&gdZi8h`>5)#pnCsEdpd5_oZ%V{6@3jdVO$>;Yd zXOKd@)Y`Z-j<-m=LCGaak`gz6&dh3wB7zcpg%TUdN$Yj5)WuWgrH>g#!E~NeQm=8n z!06%hK5)v9LhT3#a1JuLhL4r-x0CbuvEJ;?!Swlub17;>|!Wd zUvz^`F`mSvD4(p-)uZHu{m7OrbgFfQtb3c&-N!XnOr`tzo58;h-tcmh1Vu9=nq$p` zE3$-7z}*dS+VPcACgrwI;CdX_JG%cAd3LnRBa_E&CZk2uCz|#|Vagd)#@s(ymx7JZ zhH%_#Kygr;k9;F0tvl*-h_a0<&L{JV$2YeGrlAx7Tgj&p7JARrH@79JUP+5|@JYL@ zKb<-;5&1Hu_W2Wo{^;25v4+R4k+o0Vh5okG^IBemHw%);+@6 z{;()CsX!(dBaKXY3yV9KSZqMcf6dj*3bdrSdAgCR`bQx8LHTco0r?9KIEFeO-?1#} z2rS_XAI7d`V|hnJ9M+VDGdLYL-bcBrv)m)C1cinM3-fo@hX_j>`P!h{$0&Z^XY%;o zW*)&&i3M}3bo(wAVp@$mmq@n4S{XflHaArRetJuJLs6{dtfD>pwfB}!U#UXfSOo&T zIL$R5>f4iIqM52`HANpCpVLTp$)M&zMcy{urN$G{~wc@sf z(hI4dE1e?1F8O}PX(jG0V?nYdZB#NRIF6*M=2VDPbhA4jOhKT5Z=R%BvTHCQM~w3F z4rXNg8DI{0ol}IZMGmLY@{ak^pJBq8$wr?G9x;`FT_5_`aHpUEco}bgRver~7*ji5Bul_7 zf6R&1dF(32Y^?rd+SfIfXsQzpv}I{ev%23vlVW>4fK0t(usBpZCbKiIr;(J-MzB{i z6IBST`pMWC)V(xz!7G&8Qagre_un#?Kx*ggp1v>Rz-XvvL_POOrs=%USJW?Te&1c6 z4n>9J0{}r*K9YnbOrdojxfNl${?!F>CuVMn7TPN3Q43gjjaB8h$KZ zJqQOsZLZy+3b9Z7c++;(9k(!MFxKo#5h3+%2j#5~JSMX)$w$>=Jl^OoOXnxY`G;t; zx{Z&IyWsoM_l6l?%Vmf6;@jdHQF@7fW(Wo^t$VWzsz~I%6VN;lUKB zxifT00oP$f1oVEGjGSH)8;2g4=tM#q_#!NzS-X|0neb)V=>{$8L5;NZ*WcMC6$$c#%{yR@m5lx&%y>$)%&mP zv|3$0hYify@W{!aYE zQ7(WCEjxBg2S4}~2?BWEno(^VR_k`Bu{!Qd7d9}X|FIn|{g93ZAMb3a_QD0d{3&SA zgugySH7g5upbN;_>*V(s|{@fq#C5HBXi+HuL3qQQd4!z?=a;Yh|j5(4$ zPX8Lr6scF_6_;;W1Jb=~@G9x{-V_+cflIJDf@@Yd031VxrpiQ&slrMq{2M1Wor8Xy z^fv#|b#|R#=Ce)~8WWH@Ax;Zk>MlD1;&hhPO6HhP{<|$)#rEi+)^>mT)r`Od9>0Z+ zn4R))QwnOAr{TO$djph_GS$~%TOZbcQAMo&I(`2wv4DG5tl?jB28 z-}a69x=LhzlHMsM@3hr{675L~tS~ckxCJ#zh^e{ZKnD6+b+X{;85|qyp4r_FSEbi zSza9`e&CD1IQ|@vfZdvDDZDvf$kE;;6o!Hf&XB4Yl6$`mYZ2mdf#D=Y_kWIMK?@j89c2NHPUXQtP+$C6 zuiK!exD0(k*J5XsDNLRE7bS4&{GV-Y=HA~&B{S5(*#~=rh`~O0q9xOABL}h)^~`TN zD&!|)q?lJ7bq-M2Q(^L|43tDz4>b!Z^fv*oiGkec(1bj6&s5&Y5>Liu_H<+1(XxX9 z(LibPt%EaYnyNz#CF^dD4sBU#f9{*{q{Xv|LWXPh>T0N0wr>ya!8`1>!rA2`_PB*g zI`l^l94Jgz3;6HXw~?k~r<-he!%;#>vV4{Yb_9wRT6G>|aWA5bIwUR|HwGWpk5Sr<(d#)FmmOcxVyeVQ8YwT&VU`BIZcTDN zDHv(mQI>PvST^6>QQZ##762b z01HN5&5gtEK?k&I0?uxCpkxG?V77>f>ICdhbv0lS7S7oM3DQ0*QN_7)K?P^giI)mg zud0xKW|ZO*HZk`$0a`q$D=IHk7>9|%??QB=_C44dky&5U$n4E(zQp=lAC%ywv2p#= z;h8SqlTka7!`@ZzfHKXjw#}7ip*U4NPSRtiyW{oNaYKAE_bg6&?NKvy{>+8$wA=n} zLuc4c22fIhLYCj7z~%Q0S0Bj+wfad`pDY)ONoTs7zM@N!z(9J8zVnzfLf%3&Dwu-P z7+!FD+*+sM03wvvnZ%Varco`u=M)v&|GE`^IG~lKZF9aCK_)zDgBhY+&DYpH@S5C- zy&dqBeaAji9KA_*4a>Wj@eVUMLQ2(mqu^@oM-{c<7kXS^gpwVkQW-BX-``pV)yFKI z;YX}<2+PS-`lD_`jbpUwr8)cruOWNELCZ_Q#w-1vZCz%6WJI>?-9_d7*DuNdg^goYTg(8-Pg*Hb`pPIWUEaA@3bZhY0d_?((2}G1( zrp{PsNXQ{B#?mFJm`Rkaj)QaS^VTWyf3B`Sew)z(B{y@3_bDJ_nh+S8VHXgiVHN`(aX!MSa{%d<8OOOsL@^ZZF(ceu*Z8VG=Iy3wJr6_E z_f6O}xg80e96vvKZDGUN-oHgY>u%%nnD?+O{4+gce_g7&fJ)AT@Td_1^D;DsiP_&W zqqeZA+_9Sfi2xA}0CKAyLxJ-QiuSAU>{TnWZ66!g1FNgagUor#!Um(=uTo{K2utuu z{t7bZXjxXtPyr>*hdG_@nKRp@SGVBn$;b-RU`tDq;7gAF=2R;*4!`W0_K(OvX;3rsKGupYIB}V*dQA$~D|! zzRR$P1-nQI(7?q+NFK5xu8fO!7o%;2BXKan><_M(}*BO_qiSUl`&8;{re}|pHbKiuTkU>@Vu3)#C$LmK6E~lKG?+G04?&$ z1}eWapF2e)%Z;gQWPPSY2$sqHSq09`+Ry^e5PZ=q*4q5lX0EH8<2gb>n8P#iAvHy6z|}^hZBk)H%M6}QMY=-`i(9t@+VcV)M*Owc=n&aFuUJ0ytbwFJ5Z*Xy0y+eW`8tQCZY2DjSYEM z%i1S2*eJX+0}+l47fBTp9O0bT5nm0KEZK1d%qg5EP*x=IKhXxh<-PnIz!bqlmr{}@ zC&8HnnT||OXgeFyXCa-iRJy>zk`5u!*31j`teMU%1P?$rZl5_Q8s|)~+v83)cgMDX zmzk1E3YdYih{LfJNervg*6J|F^ZeRQqTJW_QZ4(Inf*O0tnr6p2#wGDuBeKNk!LkZ z&VFAC+!?Nx8vaI$N!NQxi!1(QS6V;Cw=P<66M6i-$Qz~N($FDft2V!s-M5V2HTm4E zA8`F)nl8?vWEFXbuD78%Z{*7z0l5C1n3j-M@Ayje7XJurC=Y=FmhS_w z-_-$m-yc=0!vFw4wnQAB*jZX;RVZllGLwZUzD4^sSuihc3c&hpaCm1W9SJuxfuj6T|F0|imV0Rzb7 z&nnFX4C!y}^mASxobevFc_+_|k>_+qwX_N&9WV}gh9i<1`LC<7v~zJq06Wzg3?fEPn%-v4^b9OPHFr_S|`GH&SFVT!NZvD>^%Eum`FdiH5$ViQU zA&I0hJzg91>3oFh|BS7=nn?+J={*W5VQQ}(p8W31!8S{JYFGTQ&%<)%5@nv|i?t#N z3Y?ZMbIpA1B^g9Guu6OlxhnHuE8QqZ@)#5rK!QF~67~O*YLxXSmU+td%Tk}f!-HS= zd%b?uN4lS5$cuRkg7DudtcAY_pL`ETD}YSYuY>C(O=rmM zY#ZJi?T*!&Vw^H&nFxLU*F#u9-u8H8&hFG_@N(|`zrhnFvuz!YQkiA*+25gaQ5PT5jlJC_wKz zE{vEe;D?5W{en6yL<)0LfSw=1*;!x5putHfa5=1Z^hl!-H~_R;hvP_~2?kKcVXqog znezA&=O)6jC`b@SoCn<|y9Kipl2$c=?Ccj1GIQuEC2hCklCXay#(S5Vl)j)y>6=xX z+cwvI&k!lOO#yyC*5lKYqP^g_e<9{0ja5qd8IBxvoUBVJ> zr^&+iEZ0HYpGGuimf&&p!lW2@6Q0rphn6t#Wc!;Khxog~?)+eE#`GwLl<9;dPT-2O z;cP`xAqrakKsMmWsqqv=i2qo9DHA?3^cu-}TArDjmBi*Vt2Ojz#>c3+2%|s+(}4_i z@bWIwnn{LbACOC&Dct1Kwp|@oarYAKh z|Lpg=B5;JL(STlmv_16T%tC{oZVYo#TG-LxR-~>XU+mET16U)$y^=+1)$%R0Q9@R^Zh7&tXA!_9{$vK`qD92HML&o z3oq~?luCB`)Cp(#^{@_3a^v|sRn&RqkG^0;t}Fk7$*^C;d z&;!(;9~J#?tA*t9@SvkU4_zVu>O&>TJ_O+JY1z-Hni|H|4{3s*Bw$+Ng&!6lEfB!_VYSvM>@ zBq{mL+N)EymTJ>K1fQ4I5lv;ijuBsMjd16N0NCa#!2BW;GZc`agAfF}nhhWEwcSAI zey;)ms#Tr-0QOe6B9Rr)bnVtZ#}8%+GM@IYhV0fqGKWf(^WQ(ge{azV0_p(f;SQf7 zGc(g-x$W_-TImx|A<2_LyOczgS#AlDB1|xaJvlAb7tZM1+Q!4m!4Doudco8N1QY=; zPdLlbK4?F(PI2X6^&OpXc`S z{Busjy&McQq!j!S8 zzdB4P5h>#Tn~$z224!t|Q+?_}7S3RSpfA?0*t&(gU11|9uHUuxEx>5UL{WsB$g;bt zeHimMcY%@< zXEb}97^MB@Ri~eP^o+Qlfk1aB5+ZNp&z%M#UZ>g3T#DayHIn1|W4(V=TmbCwSS*r_ zSA}zv4lSRoS0-vc@3K7X0$KlwY+%w&o^D4##u{C&RA$9Do>P&z@u32TeKkv-re39# zjB{nEKAJ!dp zFsDOne!^T|U@CgDZR!7N?@~@t`%9;DhGfsZl0>Z4kh}Y1txNX>Z&IL9B;Ovw`Bh;1yRzvEuDYyKl4FXiN zCi!T>5g+y}7JNQN^NZV*^f=0ms{K3wVf}7Jf{q9};C)0m;drFDFl(tSlQ(CQ+&*u| z(z26kP^~#p)+sjyZqDWg=t@o1U$Z(EElRO;PqAB1q$9cE2>n9@q<+R;V5?EAo(DlQ6^Sz@1OL97a+?Za6nx@EFc*U>gBn?d-a=c;= zHb)~5A}KR?Gy0*P9F^#SCPy}lKIkfEWS_hCE|V&*-}#Z{5~m!yUKvN@+I}knA?lJR zwTC|fI*L&j>;!I+YImT+sTj$a=hdx=Ln*F8LHYBQ(OSuZc^e;tyUW)LcT+vF%v?p^ ze;gN|F7-gqRK*r3yRbu?5xD4Qy&vxuT1O{K(uZTvxeaWwW-+JrBr!Fq?sdCdS zFyEQECjZdri+MblrC)xYqj^DA>(S>dZ_?$(ac#J%?Ze?rPxWK5=BVQTG4`GTO>|q^ zFo=RmQ<2^j6jYk@PUNPkH0c7;AxQ7NiKz4@(o0Z4dWX=P^p+qULg*y)0HG%#U%b!r zoaemfoFCu(U|MJYKtS`+aC2G2w|Aq>zD9*wH{#u=?N*&lS3DBcVV}E2@m|Wo$kP=PP+w zI(UC0U(h#UwA4LJD}Z{kihn0}g0*Q_@$8}1N>I;fvGu2{;9={L_0ipWejOw5nj%&> zj@Wo~OqEtZS#Ih}k6#FKptZxp+MhW^W~0BYoz0JDy-C3&v*m$SQ>+-O zGNzn5zYx8<9=kh|1h1?1&|+zuW~Nl%Z^?G5ZB9x=v-rCL7lRy`VINX)3qx&PteaNB%J-EnqC}e-kV}yPJy=WEON!Fi zuXfH!t?s3Fi%78e#jnCBA6xq~q72;&^7<0&XQLAm-?{)>OWPCkZcZC~Mr!5Tq?Bx_ zqYF%Sy8Y=CIPdc24ZfOLW%1gYOJR@drn2n zK!2H7o_w|DOfc+@ZxrM^?3nxB^qOSRcg>safJlSg_DESZ7OvWo-YUdQgjbPDIR8u4 z=+a;E-Mh}DXL|PIHG5`$B~B~J0;LNxac9ni+`FL>mseoO@?RHGR9)&ApV(&`9{em? zONI8%e`9UFWc-ivxcryS4~xtMOxs6`Dz?h#&KjPVd`JlTvS(YiH@19b+B&y5l{@Kv z9d1{NIV6Ws8)qo>P+lwY&GcgO)qPra&zg=KfT!kZ0Y9iwR+kfVN#qM(U3JO{=i8G8 zQ|$b;UQRWIW&VG zj6>T4O{{Q|ZbVIcsQ4uDIg2|x^C!`gn%K3bC%uW!)n)x6$X*X&4>k3U*~ z&BFC)WZ%!tV%dle!mW&-np`Fd0*j(9DI)?-y-U`ODWPFU;z-zLFk5g8%gl#UBCr}) zh+mHs8BwanE|=x}Yv&-2ubB$5e@qR(_rK#G5pXqO$&OzeoPnKy6rHw)3kKO#*PVmp zm`GK`yWdfjk^|Oz0mB~P$1cguYl<-gaQ#B#9_Qg!{T9YMOoOA}c_Ji82{v|(P#liiOGdSdU0yjIrI?E{RgPGa`b=hl}5>Ly5pd) zXRlYQ!P7bCs9{t{`!g465l>{guN6sliA@$3-e1#NTE#la_C%=z>qj85@3*!!ReR}V zLK@tBly;qwP5{^G*~$-8u{c)a*F^-%?!9n8=L%*%C z#v##x7Kv-R1v~tux|V}qwMh}VwbD7R=m;M2y`1XIW0^g5S)R=W^ZJ>K^&gS1XUE&B z4-<*Ci)4=AAUj+;1pF2n$&Q8(#5<=>D72KVcTQhQZ4R#$PQc4z*Z>m!78e{SX;w#J zKHRurodf2kuC3a8*HhvB&~d@Vu+v1t<-rx(j(-=}LlSch#c@>{sGVS-Mi*}Txhmhp z>}x8QrN^ucY)cWM8Io~+`OvZMi$=D!+b;Z0N?kyP6*D!meG!=?f%m^@788a1fxgzX z4^r=#AiO96C(eV*d*y1PPc9gb)9`2kyU9Gf3t&8p4}LK)0s0p7{--zDy*$i;@o`>E znG&HH8Za27wK{rnq;2gdva+c zl$FBCX+eF_{ac$x?A)$N^F5RWKnSSA#m(wnriv8$r?L2k_+g(Jfj&7jbm|cBznm(&y{6g!mdXeXLp*flX0%!Rva12EmVjnW)dFvirGhrLLyc zey%Pv8OnMJFoCi~=b(Kth*Q+sbl1|+>Q6O|`Iw+qeZM>?2SJhSVr%) z_KRkt&sil2O4^;sDm>@Iz{Og5;!u1bi*!E_E&ATeI2e@3wqUwM0pKdA?yfG@{+!ie zARP?;vZh)jFKkLhRCV{F>G6~8*DdD3(fi#lzq@1-gsU0Jf5f7g$mb_BK9SIF2v{`xh0j!w∾qyXLx4m11q#41YNglm(3Ys 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%0DjJ3&%pmp9r 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 ZM{KS~A{6NHXLf}RZkS##)xGuT{{TpPRTcmM literal 0 HcmV?d00001 diff --git a/packages/uipath/docs/core/assets/maestro_service_task_light.png b/packages/uipath/docs/core/assets/maestro_service_task_light.png new file mode 100644 index 0000000000000000000000000000000000000000..314cb383e19c4138960227bbb8a6d6552b653578 GIT binary patch literal 596758 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⁣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

  • tDYe*iw7m{CwWROvP?vqJ32EMd&4b{G=a?M6mf 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

    F4k72l`UYZITm5H)8t0awSd1%G! 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 CCK-JI literal 0 HcmV?d00001 diff --git a/packages/uipath/docs/core/assets/studio_web_select_function_dark.png b/packages/uipath/docs/core/assets/studio_web_select_function_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..68a66fc923e1d53b4203f73842124584cb1e39e0 GIT binary patch literal 153206 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

    E&*Poz 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⺺RG>ZKCKiu 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 z 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

    x8ZAdsUeVemfHyCnlDMDN`3k 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 literal 0 HcmV?d00001 diff --git a/packages/uipath/docs/core/environment_variables.md b/packages/uipath/docs/core/environment_variables.md index 6c88d3532..bcf1bcc8b 100644 --- a/packages/uipath/docs/core/environment_variables.md +++ b/packages/uipath/docs/core/environment_variables.md @@ -17,12 +17,12 @@ UIPATH_FOLDER_PATH=/default/path export UIPATH_FOLDER_PATH=/system/path ``` /// warning -When deploying your agent to production, ensure that all required environment variables (such as API keys and custom configurations) are properly configured in your process settings. This step is crucial for the successful operation of your published package. +When deploying your project to production, ensure that all required environment variables (such as API keys and custom configurations) are properly configured in your process settings. This step is crucial for the successful operation of your published package. /// ## Design -Create a `.env` file in your project's root directory to manage environment variables locally. When using the `uipath auth` or `uipath new my-agent` commands, this file is automatically created. +Create a `.env` file in your project's root directory to manage environment variables locally. When using the `uipath auth` or `uipath new` commands, this file is automatically created. The `uipath auth` command automatically populates this file with essential variables: diff --git a/packages/uipath/docs/core/functions.md b/packages/uipath/docs/core/functions.md new file mode 100644 index 000000000..e367270e8 --- /dev/null +++ b/packages/uipath/docs/core/functions.md @@ -0,0 +1,426 @@ +# Python Coded Functions + +A coded function is Python code with typed input and output that runs as an Orchestrator job. You write plain Python — no agent framework, no LLM required — package it with the CLI, and invoke it from Maestro processes, Coded Apps, or any UiPath job trigger. + +Use coded functions for deterministic compute steps: document extraction, ERP writes, data validation, external API calls. Use a [coded agent](./agents.md) when your logic needs an LLM decision loop or a multi-step reasoning chain. + +!!! warning "Preview Feature" + This feature is in preview and is subject to changes. + +--- + +## Quickstart + +//// tab | uv + + + +```shell +> mkdir my-function && cd my-function +> uv init . --python 3.11 +> uv add uipath + +> uipath auth +⠋ Authenticating with UiPath ... +✓ Authentication successful. + +> uipath new my-function +✓ Created 'main.py' file. +✓ Created 'pyproject.toml' file. +✓ Created 'uipath.json' file. + +> uipath init +⠋ Initializing UiPath project ... +✓ Created 'entry-points.json' file. +✓ Created 'bindings.json' file. + +> uipath run main '{"message": "hello"}' +{"message": "hello"} +``` + +//// + +//// tab | pip + + + +```shell +> mkdir my-function && cd my-function +> python -m venv .venv +> source .venv/bin/activate +> pip install uipath + +> uipath auth +⠋ Authenticating with UiPath ... +✓ Authentication successful. + +> uipath new my-function +✓ Created 'main.py' file. +✓ Created 'pyproject.toml' file. +✓ Created 'uipath.json' file. + +> uipath init +⠋ Initializing UiPath project ... +✓ Created 'entry-points.json' file. +✓ Created 'bindings.json' file. + +> uipath run main '{"message": "hello"}' +{"message": "hello"} +``` + +//// + +--- + +## Project Structure + +``` +my-function/ +├── main.py # function logic +├── pyproject.toml # project metadata and dependencies +├── uipath.json # entry point declarations +├── entry-points.json # generated — I/O JSON Schema +└── bindings.json # generated — resource binding overrides +``` + +### `uipath.json` + +Declares which Python functions are callable entry points: + +```json +{ + "functions": { + "main": "main.py:main" + } +} +``` + +The key (`"main"`) is the entry point name used in CLI commands. The value (`"main.py:main"`) is `:`. + +### `pyproject.toml` + +```toml +[project] +name = "my-function" +version = "0.1.0" +description = "..." +authors = [{ name = "Your Name", email = "you@example.com" }] +requires-python = ">=3.11" +dependencies = ["uipath>=2.0"] + +[tool.uipath] +type = "function" +``` + +`[tool.uipath] type = "function"` is required — it identifies the project as a function to the runtime and packaging tools. + +### Generated files + +| File | Purpose | +|------|---------| +| `entry-points.json` | Input/output JSON Schema derived from your dataclasses — used by Maestro for variable binding | +| `bindings.json` | Resource binding overrides (assets, connections, buckets) for local development | + +/// warning +`uipath init` executes `main.py` to derive the I/O schema. Re-run it after every change to your `Input` or `Output` dataclasses. +/// + +--- + +## Input & Output + +Define `Input` and `Output` as Python dataclasses. The runtime validates against these at invocation time and exports them as JSON Schema for Maestro variable binding. + +```python +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class Input: + document_id: str = "" + amount: float = 0.0 + + +@dataclass +class Output: + result_id: str = "" + status: str = "" + error_type: str = "" + error_message: str = "" + + +def main(input: Input) -> Output: + ... +``` + +### Supported types + +| Python type | Notes | +|-------------|-------| +| `str`, `int`, `float`, `bool` | Primitives | +| `list[str]`, `list[dict]` | Arrays | +| `dict[str, Any]` | Freeform object | +| Nested `@dataclass` | Becomes a nested JSON object | +| `X \| None`, `Optional[X]` | Nullable field | + +### Error output pattern + +Return business errors as typed output fields rather than raising exceptions. This lets Maestro inspect the error reason and route the process accordingly: + +```python +@dataclass +class Output: + bill_id: str = "" + error_type: str = "" # e.g. "VENDOR_NOT_FOUND", "VALIDATION_ERROR" + error_message: str = "" # human-readable detail + + +def main(input: Input) -> Output: + try: + bill_id = create_vendor_bill(input) + return Output(bill_id=bill_id) + except VendorNotFoundError as exc: + return Output(error_type="VENDOR_NOT_FOUND", error_message=str(exc)) + except Exception as exc: + return Output(error_type="FAILED", error_message=str(exc)) +``` + +Reserve `raise` for unrecoverable infrastructure failures (network timeout, authentication error) that should mark the Orchestrator job as faulted. + +--- + +## Platform Services + +`UiPath()` gives your function access to Orchestrator resources at runtime. Credentials are injected automatically when running as a job — no configuration needed. + +```python +from uipath.platform import UiPath + +sdk = UiPath() +``` + +### Assets + +Read credential and configuration values stored in Orchestrator: + +```python +# String asset +asset = sdk.assets.retrieve("API_BASE_URL", folder_path="Shared") +base_url = str(asset.string_value or "") + +# Credential asset +creds = sdk.assets.retrieve("ERP_CREDENTIALS", folder_path="Shared") +username = str(creds.credential_username or "") +password = str(creds.credential_password or "") +``` + +See [Assets](./assets.md) for the full API reference. + +### Buckets + +Download and upload files: + +```python +# Download +sdk.buckets.download( + name="Invoices", + blob_file_path="incoming/acme-001.pdf", + destination_path="/tmp/acme-001.pdf", + folder_path="Shared", +) + +# Upload +sdk.buckets.upload( + name="Processed", + blob_file_path="results/acme-001-result.json", + content_file_path="/tmp/result.json", + folder_path="Shared", +) +``` + +See [Buckets](./buckets.md) for the full API reference. + +### Connections + +Access Integration Service connections for ERP and SaaS systems: + +```python +from uipath.platform.connections.connections import ActivityMetadata, ActivityParameterLocationInfo + +conn = sdk.connections.retrieve("your-connection-id") + +result = sdk.connections.invoke_activity( + activity_metadata=ActivityMetadata( + object_path="/your-endpoint", + method_name="POST", + content_type="application/json", + parameter_location_info=ActivityParameterLocationInfo(body_fields=["query"]), + ), + connection_id="your-connection-id", + activity_input={"query": "SELECT id FROM records LIMIT 10"}, +) +``` + +See [Connections](./connections.md) for the full API reference. + +--- + +## Tracing + +Use `@traced` to make individual steps visible as spans in the Orchestrator job trace view and Maestro dashboards. + +```python +from uipath.tracing import traced + + +@traced(name="fetch_document", run_type="uipath") +def fetch_document(document_id: str) -> bytes: + ... + + +@traced(name="extract_fields", run_type="uipath") +def extract_fields(content: bytes) -> dict: + ... + + +@traced(name="post_to_erp", run_type="uipath") +def post_to_erp(data: dict) -> str: + ... + + +def main(input: Input) -> Output: # entry point — NOT traced + content = fetch_document(input.document_id) + data = extract_fields(content) + result_id = post_to_erp(data) + return Output(result_id=result_id) +``` + +/// warning +Do not apply `@traced` to the entry point function. The Orchestrator runtime wraps the entire job in its own span — adding a second trace on the entry point creates a duplicate outer span. +/// + +Use `hide_input=True` or `hide_output=True` to redact sensitive data from trace storage: + +```python +@traced(name="get_api_token", run_type="uipath", hide_input=True, hide_output=True) +def get_api_token(client_id: str, client_secret: str) -> str: + ... +``` + + + Maestro execution trail showing @traced sub-step spans (assets_retrieve, ixp_digitize, netsuite_get_vendor, etc.) with durations and parent-child nesting under a Service Task + + +See [Tracing](./traced.md) for the full decorator reference. + +--- + +## Multiple Entry Points + +One project can expose several callable functions, each with its own `Input`/`Output`. Define them in `uipath.json`: + +```json +{ + "functions": { + "extract": "main.py:extract_data", + "validate": "main.py:validate_data", + "post_erp": "main.py:post_to_erp" + } +} +``` + +Run `uipath init` after adding new entry points. Each can be invoked independently: + + + +```shell +> uipath run extract '{"document_id": "invoice-001.pdf"}' +> uipath run validate '{"vendor_name": "Acme", "total": 1234.56}' +> uipath run post_erp '{"bill_id": "12345"}' +``` + +Each entry point publishes as a separate invocable function in Orchestrator. + +--- + +## Idempotency + +Functions may be retried by Maestro after a transient failure. Always check for an existing result before writing to an external system: + +```python +@traced(name="find_existing", run_type="uipath") +def find_existing(invoice_number: str) -> str | None: + # query external system by stable business key + ... + + +def main(input: Input) -> Output: + existing_id = find_existing(input.invoice_number) + if existing_id: + return Output(result_id=existing_id, status="Already Processed") + + result_id = create_record(input) + return Output(result_id=result_id, status="Created") +``` + +Use a stable, business-meaningful identifier (invoice number, order ID) as the idempotency key — avoid auto-generated IDs that don't exist before the first write. + +--- + +## Pack & Publish + + + +```shell +> uipath pack +⠋ Packaging project ... +Name : my-function +Version : 0.1.0 +Description: ... +Authors : Your Name +✓ Project successfully packaged. + +> uipath publish +⠋ Fetching available package feeds... +👇 Select package feed: + 0: Orchestrator Tenant Processes Feed + 1: Orchestrator Personal Workspace Feed +Select feed number: 0 +✓ Package published successfully! +``` + +After publishing, the function registers as an **Orchestrator Process**. It can then be: + +- Invoked as a **Maestro Service Task** — Maestro binds typed input/output to process variables automatically from the exported JSON Schema +- Triggered via the **Orchestrator API** (`POST /Jobs/StartJobs`) +- Run from the CLI: `uipath invoke main '{"..."}'` +- Started from a **Studio workflow** using the **Run Job** activity + + + Coded functions published as Function (python) type in the Orchestrator Processes list + + + Coded function wired as a Maestro Service Task with typed input/output variable binding + + +See [CLI Reference](../cli/index.md) for full `pack`, `publish`, and `invoke` options. + +--- + +## Studio Web Integration + +Connect your function to a Studio Web solution for cloud debugging or solution packaging: + + + +```shell +> uipath push +Pushing UiPath project to Studio Web... +Uploading 'main.py' +Uploading 'uipath.json' +Updating 'pyproject.toml' +✓ Project pushed successfully +``` + +See [Studio Web Integration](./studio_web.md) for setup and sync details. diff --git a/packages/uipath/docs/core/getting_started.md b/packages/uipath/docs/core/getting_started.md index 6c00b0089..50da82107 100644 --- a/packages/uipath/docs/core/getting_started.md +++ b/packages/uipath/docs/core/getting_started.md @@ -114,6 +114,10 @@ Upon successful authentication, your project will contain a `.env` file with you ### Writing Your Code +/// tip +This walkthrough creates a **coded function** — plain Python with typed input and output, no LLM required. For a complete reference including platform services, tracing, idempotency, and Maestro integration, see [Python Coded Functions](./functions.md). +/// + Open `main.py` in your code editor. You can start with this example code: ```python from dataclasses import dataclass diff --git a/packages/uipath/docs/core/studio_web.md b/packages/uipath/docs/core/studio_web.md index 762735e48..09286dd73 100644 --- a/packages/uipath/docs/core/studio_web.md +++ b/packages/uipath/docs/core/studio_web.md @@ -1,12 +1,15 @@ # Studio Web Integration -[Studio Web](https://docs.uipath.com/studio-web/automation-cloud/latest/user-guide/overview) is a cloud IDE for building projects such as RPAs, low code agents, and API workflows. It also supports importing coded agents built locally. Bringing your coded agent into Studio Web gives you: +[Studio Web](https://docs.uipath.com/studio-web/automation-cloud/latest/user-guide/overview) is a cloud IDE for building projects such as RPAs, low code agents, and API workflows. It also supports importing coded agents and coded functions built locally. Bringing your project into Studio Web gives you: - Cloud debugging with dynamic breakpoints -- Running and defining evaluations directly in the cloud +- Running and defining evaluations directly in the cloud (coded agents only) - A unified build experience alongside multiple project types - Self contained solution deployment units +!!! warning "Preview Feature" + Coded function support is in preview and is subject to changes. + Coded agent in Studio Web -There are two ways to connect a coded agent to Studio Web: using a [Cloud Workspace](#cloud-workspace) or a [Local Workspace](#local-workspace). +There are two ways to connect your project to Studio Web: using a [Cloud Workspace](#cloud-workspace) or a [Local Workspace](#local-workspace). --- @@ -28,10 +31,14 @@ There are two ways to connect a coded agent to Studio Web: using a [Cloud Worksp In a Cloud Workspace, your project lives in Studio Web and you sync code between your local IDE and the cloud. -### Importing a Coded Agent +### Importing a Coded Agent or Coded Function 1. Open your solution in Studio Web -2. Create a new Agent and select **Coded** +2. Create the project: + + //// tab | Agent + + Create a new Agent and select **Coded**: -3. Choose a sample project to start from, or push an existing local agent - -### Pushing an Existing Agent + //// -If you already have a coded agent locally, you can sync it to Studio Web: + //// tab | Function -1. Copy the `UIPATH_PROJECT_ID` from Studio Web into your `.env` file + Use the **Initial setup screen** to get started: - + + //// + +3. Choose a sample project to start from, or push an existing local project + +### Pushing an Existing Project + +If you already have a project locally, you can sync it to Studio Web: + +1. Copy the `UIPATH_PROJECT_ID` from Studio Web into your `.env` file + + + + + + 2. Push your project: + ```shell > uipath push Pushing UiPath project to Studio Web... @@ -79,7 +107,7 @@ If you already have a coded agent locally, you can sync it to Studio Web: 🔵 Resource import summary: 3 total resources - 1 created, 1 updated, 1 unchanged, 0 not found ``` - Notice the **Resource import summary** at the end. The push command also imports resources defined in `bindings.json` into the Studio Web solution, just like importing resources for a low code agent. This ensures that all required resources are packaged with the solution, so the coded agent works anywhere the solution is deployed. + Notice the **Resource import summary** at the end. The push command also imports resources defined in `bindings.json` into the Studio Web solution, just like importing resources for a low code agent. This ensures that all required resources are packaged with the solution, so the project works anywhere the solution is deployed. See [`uipath push`](../cli/index.md) in the CLI Reference. @@ -88,6 +116,7 @@ If you already have a coded agent locally, you can sync it to Studio Web: To pull the latest version from Studio Web to your local environment: + ```shell > uipath pull Pulling UiPath project from Studio Web... @@ -116,21 +145,24 @@ See [`uipath pull`](../cli/index.md) in the CLI Reference. In a Local Workspace, your project lives on your machine and is linked to a Studio Web solution. See the [Local Workspace documentation](https://docs.uipath.com/studio-web/automation-cloud/latest/user-guide/solutions-in-the-local-workspace) for setup details. -You can either start from a predefined template in Studio Web or set up a new agent from scratch. +You can either start from a predefined template in Studio Web or set up a new project from scratch. ### Starting from a Template -When creating a new Coded agent in Studio Web with a Local Workspace, you can pick one of the predefined templates. This creates the project files directly on your machine. Templates come with sample code and predefined evaluations you can run immediately. +When creating a new coded agent or coded function in Studio Web with a Local Workspace, you can pick one of the predefined templates. This creates the project files directly on your machine. Templates come with sample code and predefined evaluations you can run immediately. -### Setting Up a New Agent +### Setting Up a New Project -You can also create a coded agent from scratch in your local IDE and have it appear in Studio Web. +You can also create a project from scratch in your local IDE and have it appear in Studio Web. + +#### Coded Agent First, install the SDK package for the framework you want to use: //// tab | uv + ```shell # Pick the package that matches your framework: # uipath-langchain - LangChain / LangGraph @@ -149,6 +181,7 @@ Installed 42 packages in 0.8s //// tab | pip + ```shell # Pick the package that matches your framework: # uipath-langchain - LangChain / LangGraph @@ -166,6 +199,7 @@ Successfully installed uipath-langchain Then authenticate, scaffold the agent, and initialize the project: + ```shell > uipath auth ⠋ Authenticating with UiPath ... @@ -187,57 +221,133 @@ Selected tenant: Tenant1 That's it, your agent should now be visible in Studio Web. +#### Coded Function + +A coded function doesn't require an additional framework package. Authenticate, scaffold the project, and initialize it: + + + +```shell +> uipath auth +⠋ Authenticating with UiPath ... +🔗 If a browser window did not open, please open the following URL in your browser: [LINK] +👇 Select tenant: + 0: Tenant1 + 1: Tenant2 +Select tenant number: 0 +Selected tenant: Tenant1 +✓ Authentication successful. + +> uipath new my-function +✓ Created 'main.py' file. +✓ Created 'pyproject.toml' file. +✓ Created 'uipath.json' file. + +> uipath init +⠋ Initializing UiPath project ... +✓ Created 'entry-points.json' file. +``` + +That's it, your coded function should now be visible in Studio Web. + --- ## Publishing -Once your coded agent is in Studio Web, publishing works the same as any other project. Click **Publish** in Studio Web and it will be packaged and deployed through the standard workflow. +Once your project is in Studio Web, publishing works the same as any other project. Click **Publish** in Studio Web and it will be packaged and deployed through the standard workflow. --- ## Running and Debugging -Your agent can be run both in the cloud (via Studio Web) and locally using the CLI. +Your project can be run both in the cloud (via Studio Web) and locally using the CLI. + +The CLI commands below take the entrypoint name as the first argument. For a coded agent, this is the graph name declared in your framework's config (for example, `agent` in `langgraph.json`). For a coded function, this is the key declared in the `functions` map of `uipath.json` (for example, `main`). ### Running Locally +//// tab | Agent + + ```shell > uipath run agent '{"message": "hello"}' ``` +//// + +//// tab | Function + + + +```shell +> uipath run main '{"message": "hello"}' +``` + +//// + See [`uipath run`](../cli/index.md) in the CLI Reference. ### Debugging Locally Use `uipath debug` for an enhanced local debugging experience. Unlike `uipath run`, the debug command: -- Auto polls for trigger responses when the agent suspends (e.g., LangGraph interrupts) +- Auto polls for trigger responses when the project suspends (e.g., LangGraph interrupts) - Fetches binding overwrites from Studio Web (configurable in **Debug > Debug Configuration > Solution resources**) +//// tab | Agent + + ```shell > uipath debug agent '{"message": "hello"}' ``` +//// + +//// tab | Function + + + +```shell +> uipath debug main '{"message": "hello"}' +``` + +//// + See [`uipath debug`](../cli/index.md) in the CLI Reference. ### Evaluating Locally -Run evaluations against your agent using the CLI: +Run evaluations against your project using the CLI: + +//// tab | Agent + ```shell > uipath eval agent .\evaluations\eval-sets\faithfulness-multi-model.json ``` +//// + +//// tab | Function + + + +```shell +> uipath eval main .\evaluations\eval-sets\default.json +``` + +//// + See [`uipath eval`](../cli/index.md) in the CLI Reference and the [Evaluations documentation](../eval/index.md). --- ## Syncing Evaluations -Evaluations can be defined either in Studio Web or locally. They sync automatically when you use `uipath pull` and `uipath push`. +Evaluations can be defined either in Studio Web or locally, and sync automatically when you use `uipath pull` and `uipath push`. Defining and running evaluations in Studio Web is supported for coded agents only; coded functions can still be evaluated locally with `uipath eval`. /// note Custom evaluators must be created locally. See [Custom Evaluators](../eval/custom_evaluators.md) for details. diff --git a/packages/uipath/docs/core/traced.md b/packages/uipath/docs/core/traced.md index da8dbc5dc..195e5751a 100644 --- a/packages/uipath/docs/core/traced.md +++ b/packages/uipath/docs/core/traced.md @@ -71,9 +71,47 @@ def sensitive_operation(secret): - Regular functions (sync/async) - Generator functions (sync/async) -## Example with plain python agents +## Example with coded functions -When used with plain python agents please call `wait_for_tracers()` at the end of the script to ensure all traces are sent, if this is not called the agent could end without sending all the traces. +Apply `@traced` to individual steps inside your function. Do **not** trace the entry point — the Orchestrator runtime wraps the job execution in its own span, so decorating the entry point creates a duplicate outer span. + +```python hl_lines="4 9 14" +from uipath.tracing import traced + + +@traced(name="fetch_document", run_type="uipath") +def fetch_document(document_id: str) -> bytes: + ... + + +@traced(name="extract_fields", run_type="uipath") +def extract_fields(content: bytes) -> dict: + ... + + +@traced(name="post_to_erp", run_type="uipath") +def post_to_erp(data: dict) -> str: + ... + + +def main(input: Input) -> Output: # entry point — NOT traced + content = fetch_document(input.document_id) + data = extract_fields(content) + result_id = post_to_erp(data) + return Output(result_id=result_id) +``` + +Use `hide_input=True` or `hide_output=True` on steps that handle credentials or PII: + +```python hl_lines="1" +@traced(name="get_api_token", run_type="uipath", hide_input=True, hide_output=True) +def get_api_token(client_id: str, client_secret: str) -> str: + ... +``` + +## Example with plain python scripts + +When used outside the Orchestrator runtime (e.g. a standalone script), call `wait_for_tracers()` at the end to ensure all traces are flushed before the process exits. ```python hl_lines="3 8" diff --git a/packages/uipath/docs/index.md b/packages/uipath/docs/index.md index 231add0ca..7ec047397 100644 --- a/packages/uipath/docs/index.md +++ b/packages/uipath/docs/index.md @@ -11,29 +11,32 @@ title: Getting Started [See Details](./core/release_notes.md) +

    What do you want to build?

    +
    -- __UiPath SDK__ +- __Python Coded Functions__ --- - Code with full UiPath context to build custom automations and agents from the ground up. + Deterministic Python automation with typed input/output. No LLM required. Runs as an Orchestrator job, invokable from Maestro, Studio, or the CLI. - [Start Building](./core/getting_started.md) + **Requires:** `uipath` -
    + [Build a Function](./core/functions.md) -
    -- __UiPath MCP SDK__ +- __Python Coded Agents__ --- - Build and host Coded MCP Servers within UiPath. + AI-driven automation with LLM reasoning loops. Uses the `uipath` SDK for platform services plus a framework extension of your choice. - [Start Building](./mcp/quick_start.md) + **Requires:** `uipath` + one of the extensions below + + [Build an Agent](./core/agents.md)
    -

    Extensions

    +

    Agent Framework Extensions

    - __UiPath Langchain SDK__ @@ -60,3 +63,15 @@ title: Getting Started [Get Started](./openai-agents/quick_start.md)
    + +

    Other SDKs

    +
    +- __UiPath MCP SDK__ + + --- + + Build and host Coded MCP Servers within UiPath. + + [Start Building](./mcp/quick_start.md) + +
    diff --git a/packages/uipath/mkdocs.yml b/packages/uipath/mkdocs.yml index de3b9a278..382e0fdd5 100644 --- a/packages/uipath/mkdocs.yml +++ b/packages/uipath/mkdocs.yml @@ -56,11 +56,13 @@ nav: - Home: index.md - UiPath SDK: - Getting Started: core/getting_started.md - - Release Notes: core/release_notes.md - - Environment Variables: core/environment_variables.md + - Python Coded Functions: core/functions.md + - Python Coded Agents: core/agents.md - CLI Reference: cli/index.md - Tracing: core/traced.md - Studio Web Integration: core/studio_web.md + - Environment Variables: core/environment_variables.md + - Release Notes: core/release_notes.md - Services: - Assets: core/assets.md - Attachments: core/attachments.md From 0a9b2dd7545c9571d7751541d512eb1bdb4c4560 Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:44:20 -0500 Subject: [PATCH 086/191] feat: expose injecting conversation-id in low-code convo agent prompt (#1589) --- packages/uipath/pyproject.toml | 2 +- .../agent/react/conversational_prompts.py | 32 +++++++++- .../react/test_conversational_prompts.py | 62 +++++++++++++++++++ packages/uipath/uv.lock | 2 +- 4 files changed, 94 insertions(+), 4 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index fae01ad9c..c1a3ebddf 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.76" +version = "2.10.77" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/agent/react/conversational_prompts.py b/packages/uipath/src/uipath/agent/react/conversational_prompts.py index 7732c7f69..f79de6185 100644 --- a/packages/uipath/src/uipath/agent/react/conversational_prompts.py +++ b/packages/uipath/src/uipath/agent/react/conversational_prompts.py @@ -62,6 +62,8 @@ class PromptUserSettings(BaseModel): - Never attempt calls with incomplete data - On errors: modify parameters or change approach (never retry identical calls) +{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_conversationIdPrompt}} + ===================================================================== TOOL RESULTS ===================================================================== @@ -136,18 +138,26 @@ class PromptUserSettings(BaseModel): {user_settings_json} ```""" +_CONVERSATION_ID_TEMPLATE = """ +The current conversation ID is {conversation_id}. This may be useful to include in tool-calls when tool parameters specify passing in the conversation ID. Other than tool-call inputs, this ID should not be mentioned to the user. +""" + def get_chat_system_prompt( model: str, system_message: str, agent_name: str | None, user_settings: PromptUserSettings | None = None, + conversation_id: str | None = None, ) -> str: """Generate a system prompt for a conversational agent. Args: - agent_definition: Conversational agent definition + model: Model identifier. + system_message: The agent system prompt content. + agent_name: The agent display name; defaults to "Unnamed Agent" when None. user_settings: Optional user data that is injected into the system prompt. + conversation_id: Optional conversation identifier that is injected into the system prompt. Returns: The complete system prompt string @@ -177,6 +187,10 @@ def get_chat_system_prompt( "{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_userSettingsPrompt}}", get_user_settings_template(user_settings), ) + prompt = prompt.replace( + "{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_conversationIdPrompt}}", + get_conversation_id_template(conversation_id), + ) return prompt @@ -190,7 +204,7 @@ def get_user_settings_template( user_settings: User profile information Returns: - The user context template with JSON or empty string + The filled-in user settings template if user_settings is provided, otherwise an empty string """ if user_settings is None: return "" @@ -205,3 +219,17 @@ def get_user_settings_template( user_settings_json = json.dumps(settings_dict, ensure_ascii=False) return _USER_CONTEXT_TEMPLATE.format(user_settings_json=user_settings_json) + + +def get_conversation_id_template(conversation_id: str | None) -> str: + """Get the conversation ID prompt section. + + Args: + conversation_id: The ID of the current conversation, if any + + Returns: + The filled-in conversation ID template if conversation_id is provided, otherwise an empty string + """ + if not conversation_id: + return "" + return _CONVERSATION_ID_TEMPLATE.format(conversation_id=conversation_id) diff --git a/packages/uipath/tests/agent/react/test_conversational_prompts.py b/packages/uipath/tests/agent/react/test_conversational_prompts.py index a58a94807..434f68465 100644 --- a/packages/uipath/tests/agent/react/test_conversational_prompts.py +++ b/packages/uipath/tests/agent/react/test_conversational_prompts.py @@ -149,6 +149,68 @@ def test_generate_system_prompt_unnamed_agent_uses_default(self): assert "You are Unnamed Agent." in prompt +class TestConversationIdInPrompt: + """Tests for conversation_id in generated prompts.""" + + def test_prompt_includes_conversation_id_when_provided(self): + prompt = get_chat_system_prompt( + model="claude-3-sonnet", + system_message=SYSTEM_MESSAGE, + agent_name="Test Agent", + user_settings=None, + conversation_id="conv-abc-123", + ) + + assert "The current conversation ID is conv-abc-123" in prompt + assert ( + "This may be useful to include in tool-calls when tool parameters specify passing in the conversation ID." + in prompt + ) + assert ( + "{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_conversationIdPrompt}}" not in prompt + ) + + def test_prompt_omits_section_when_none(self): + prompt = get_chat_system_prompt( + model="claude-3-sonnet", + system_message=SYSTEM_MESSAGE, + agent_name="Test Agent", + user_settings=None, + conversation_id=None, + ) + + assert "conversation ID" not in prompt + assert ( + "{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_conversationIdPrompt}}" not in prompt + ) + + def test_prompt_omits_section_when_empty_string(self): + prompt = get_chat_system_prompt( + model="claude-3-sonnet", + system_message=SYSTEM_MESSAGE, + agent_name="Test Agent", + user_settings=None, + conversation_id="", + ) + + assert "conversation ID" not in prompt + + def test_prompt_defaults_to_no_conversation_id(self): + """conversation_id defaults to None — call sites that don't pass it + must not get a dangling placeholder.""" + prompt = get_chat_system_prompt( + model="claude-3-sonnet", + system_message=SYSTEM_MESSAGE, + agent_name="Test Agent", + user_settings=None, + ) + + assert ( + "{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_conversationIdPrompt}}" not in prompt + ) + assert "conversation ID" not in prompt + + class TestCitationFormat: """Tests for citation format in generated prompts.""" diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 96764f7fb..cf11b2a5b 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.76" +version = "2.10.77" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 6e1cfe55626fe8318ab8a9a5ecd5b5d532e7a1ea Mon Sep 17 00:00:00 2001 From: AAgnihotry <95259907+AAgnihotry@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:37:47 -0700 Subject: [PATCH 087/191] feat(eval): support list of keys in target_output_key (#1703) Co-authored-by: Claude Sonnet 4.6 --- packages/uipath/pyproject.toml | 2 +- .../list_target_output_key_test/bindings.json | 4 + .../entry-points.json | 41 ++ .../evaluations/eval-sets/default.json | 74 +++ .../evaluators/list-keys-exact-match.json | 10 + .../evaluators/list-keys-json-similarity.json | 10 + .../list_target_output_key_test/main.py | 74 +++ .../pyproject.toml | 12 + .../list_target_output_key_test/uipath.json | 17 + .../evaluations/eval-sets/list-keys.json | 51 ++ .../evaluators/list-keys-exact-match.json | 10 + .../eval/evaluators/output_evaluator.py | 147 ++++-- .../pyproject.toml | 12 + .../list-target-output-key-evals/run.sh | 13 + .../src/assert.py | 90 ++++ .../list-target-output-key-evals/uipath.json | 5 + .../evaluators/test_documentation_examples.py | 46 ++ .../evaluators/test_evaluator_methods.py | 442 ++++++++++++++++++ packages/uipath/uv.lock | 2 +- 19 files changed, 1013 insertions(+), 49 deletions(-) create mode 100644 packages/uipath/samples/list_target_output_key_test/bindings.json create mode 100644 packages/uipath/samples/list_target_output_key_test/entry-points.json create mode 100644 packages/uipath/samples/list_target_output_key_test/evaluations/eval-sets/default.json create mode 100644 packages/uipath/samples/list_target_output_key_test/evaluations/evaluators/list-keys-exact-match.json create mode 100644 packages/uipath/samples/list_target_output_key_test/evaluations/evaluators/list-keys-json-similarity.json create mode 100644 packages/uipath/samples/list_target_output_key_test/main.py create mode 100644 packages/uipath/samples/list_target_output_key_test/pyproject.toml create mode 100644 packages/uipath/samples/list_target_output_key_test/uipath.json create mode 100644 packages/uipath/samples/multi-output-agent/evaluations/eval-sets/list-keys.json create mode 100644 packages/uipath/samples/multi-output-agent/evaluations/evaluators/list-keys-exact-match.json create mode 100644 packages/uipath/testcases/list-target-output-key-evals/pyproject.toml create mode 100755 packages/uipath/testcases/list-target-output-key-evals/run.sh create mode 100644 packages/uipath/testcases/list-target-output-key-evals/src/assert.py create mode 100644 packages/uipath/testcases/list-target-output-key-evals/uipath.json diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index c1a3ebddf..c4da7e297 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.77" +version = "2.10.78" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/samples/list_target_output_key_test/bindings.json b/packages/uipath/samples/list_target_output_key_test/bindings.json new file mode 100644 index 000000000..6122d0e77 --- /dev/null +++ b/packages/uipath/samples/list_target_output_key_test/bindings.json @@ -0,0 +1,4 @@ +{ + "version": "2.0", + "resources": [] +} diff --git a/packages/uipath/samples/list_target_output_key_test/entry-points.json b/packages/uipath/samples/list_target_output_key_test/entry-points.json new file mode 100644 index 000000000..55ce49fba --- /dev/null +++ b/packages/uipath/samples/list_target_output_key_test/entry-points.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://cloud.uipath.com/draft/2024-12/entry-point", + "$id": "entry-points.json", + "entryPoints": [ + { + "filePath": "main", + "uniqueId": "main", + "type": "function", + "input": { + "type": "object", + "properties": { + "product_id": { + "type": "string" + } + }, + "description": "Input schema.", + "required": [ + "product_id" + ] + }, + "output": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "price": { "type": "number" }, + "category": { "type": "string" }, + "in_stock": { "type": "boolean" }, + "rating": { "type": "number" } + }, + "description": "Output schema.", + "required": [ + "name", + "price", + "category", + "in_stock", + "rating" + ] + } + } + ] +} diff --git a/packages/uipath/samples/list_target_output_key_test/evaluations/eval-sets/default.json b/packages/uipath/samples/list_target_output_key_test/evaluations/eval-sets/default.json new file mode 100644 index 000000000..87c49fdc0 --- /dev/null +++ b/packages/uipath/samples/list_target_output_key_test/evaluations/eval-sets/default.json @@ -0,0 +1,74 @@ +{ + "version": "1.0", + "id": "list-target-output-key-tests", + "name": "List Target Output Key Tests", + "evaluatorRefs": [ + "ListKeysExactMatch", + "ListKeysJsonSimilarity" + ], + "evaluations": [ + { + "id": "headphones-all-match", + "name": "Headphones - all keys match", + "inputs": { + "product_id": "p001" + }, + "evaluationCriterias": { + "ListKeysExactMatch": { + "expectedOutput": { + "name": "Wireless Headphones", + "price": 79.99 + } + }, + "ListKeysJsonSimilarity": { + "expectedOutput": { + "category": "Electronics", + "in_stock": true + } + } + } + }, + { + "id": "shoes-all-match", + "name": "Running Shoes - all keys match", + "inputs": { + "product_id": "p002" + }, + "evaluationCriterias": { + "ListKeysExactMatch": { + "expectedOutput": { + "name": "Running Shoes", + "price": 120.0 + } + }, + "ListKeysJsonSimilarity": { + "expectedOutput": { + "category": "Sports", + "in_stock": false + } + } + } + }, + { + "id": "headphones-wrong-price", + "name": "Headphones - wrong price (should fail)", + "inputs": { + "product_id": "p001" + }, + "evaluationCriterias": { + "ListKeysExactMatch": { + "expectedOutput": { + "name": "Wireless Headphones", + "price": 999.0 + } + }, + "ListKeysJsonSimilarity": { + "expectedOutput": { + "category": "Electronics", + "in_stock": true + } + } + } + } + ] +} diff --git a/packages/uipath/samples/list_target_output_key_test/evaluations/evaluators/list-keys-exact-match.json b/packages/uipath/samples/list_target_output_key_test/evaluations/evaluators/list-keys-exact-match.json new file mode 100644 index 000000000..65e77a8b1 --- /dev/null +++ b/packages/uipath/samples/list_target_output_key_test/evaluations/evaluators/list-keys-exact-match.json @@ -0,0 +1,10 @@ +{ + "version": "1.0", + "id": "ListKeysExactMatch", + "description": "Asserts 'name' and 'price' together using a list of target output keys", + "evaluatorTypeId": "uipath-exact-match", + "evaluatorConfig": { + "name": "ListKeysExactMatch", + "targetOutputKey": ["name", "price"] + } +} diff --git a/packages/uipath/samples/list_target_output_key_test/evaluations/evaluators/list-keys-json-similarity.json b/packages/uipath/samples/list_target_output_key_test/evaluations/evaluators/list-keys-json-similarity.json new file mode 100644 index 000000000..f36f1fdea --- /dev/null +++ b/packages/uipath/samples/list_target_output_key_test/evaluations/evaluators/list-keys-json-similarity.json @@ -0,0 +1,10 @@ +{ + "version": "1.0", + "id": "ListKeysJsonSimilarity", + "description": "Checks 'category' and 'in_stock' together using a list of target output keys", + "evaluatorTypeId": "uipath-json-similarity", + "evaluatorConfig": { + "name": "ListKeysJsonSimilarity", + "targetOutputKey": ["category", "in_stock"] + } +} diff --git a/packages/uipath/samples/list_target_output_key_test/main.py b/packages/uipath/samples/list_target_output_key_test/main.py new file mode 100644 index 000000000..ef7e56c73 --- /dev/null +++ b/packages/uipath/samples/list_target_output_key_test/main.py @@ -0,0 +1,74 @@ +"""Agent demonstrating list targetOutputKey evaluation. + +This agent simulates a product lookup: given a product ID it returns +a structured response with several fields. The evaluators use a list +of keys so that multiple fields can be asserted in a single evaluator +configuration, without comparing the entire output dict. +""" + +from pydantic import BaseModel + +CATALOG: dict[str, dict[str, object]] = { + "p001": { + "name": "Wireless Headphones", + "price": 79.99, + "category": "Electronics", + "in_stock": True, + "rating": 4.5, + }, + "p002": { + "name": "Running Shoes", + "price": 120.0, + "category": "Sports", + "in_stock": False, + "rating": 4.8, + }, + "p003": { + "name": "Coffee Maker", + "price": 49.99, + "category": "Kitchen", + "in_stock": True, + "rating": 4.2, + }, +} + + +class Input(BaseModel): + """Input schema.""" + + product_id: str + + +class Output(BaseModel): + """Output schema.""" + + name: str + price: float + category: str + in_stock: bool + rating: float + + +def main(input_data: Input) -> Output: + """Look up a product by ID and return its details. + + Args: + input_data: Input containing the product ID. + + Returns: + Output with product details. + + Raises: + ValueError: If the product ID is not found. + """ + product = CATALOG.get(input_data.product_id) + if product is None: + raise ValueError(f"Product '{input_data.product_id}' not found") + + return Output( + name=str(product["name"]), + price=float(product["price"]), # type: ignore[arg-type] + category=str(product["category"]), + in_stock=bool(product["in_stock"]), + rating=float(product["rating"]), # type: ignore[arg-type] + ) diff --git a/packages/uipath/samples/list_target_output_key_test/pyproject.toml b/packages/uipath/samples/list_target_output_key_test/pyproject.toml new file mode 100644 index 000000000..ef529b400 --- /dev/null +++ b/packages/uipath/samples/list_target_output_key_test/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "list-target-output-key-test" +version = "0.1.0" +description = "Sample agent demonstrating list targetOutputKey evaluation" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath" +] + +[tool.uv.sources] +uipath = { path = "../..", editable = true } diff --git a/packages/uipath/samples/list_target_output_key_test/uipath.json b/packages/uipath/samples/list_target_output_key_test/uipath.json new file mode 100644 index 000000000..e2a331e84 --- /dev/null +++ b/packages/uipath/samples/list_target_output_key_test/uipath.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://cloud.uipath.com/draft/2024-12/uipath", + "runtimeOptions": { + "isConversational": false + }, + "packOptions": { + "fileExtensionsIncluded": [], + "filesIncluded": [], + "filesExcluded": [], + "directoriesExcluded": [], + "includeUvLock": true + }, + "functions": { + "main": "main.py:main" + }, + "agents": {} +} diff --git a/packages/uipath/samples/multi-output-agent/evaluations/eval-sets/list-keys.json b/packages/uipath/samples/multi-output-agent/evaluations/eval-sets/list-keys.json new file mode 100644 index 000000000..d1f6e177b --- /dev/null +++ b/packages/uipath/samples/multi-output-agent/evaluations/eval-sets/list-keys.json @@ -0,0 +1,51 @@ +{ + "version": "1.0", + "id": "list-keys-eval-set", + "name": "List Target Output Key Tests", + "evaluatorRefs": [ + "list-keys-exact-match" + ], + "evaluations": [ + { + "id": "list-keys-basic", + "name": "Check multiple keys - order completed", + "inputs": { + "customer_name": "John Doe", + "items": [ + {"name": "Widget", "quantity": 2, "price": 9.99}, + {"name": "Gadget", "quantity": 1, "price": 24.99} + ] + }, + "evaluationCriterias": { + "list-keys-exact-match": { + "expectedOutput": { + "summary": { + "status": "completed", + "total": 44.97 + } + } + } + } + }, + { + "id": "list-keys-mismatch", + "name": "Check multiple keys - wrong total (should fail)", + "inputs": { + "customer_name": "Jane Smith", + "items": [ + {"name": "Book", "quantity": 1, "price": 15.0} + ] + }, + "evaluationCriterias": { + "list-keys-exact-match": { + "expectedOutput": { + "summary": { + "status": "completed", + "total": 999.0 + } + } + } + } + } + ] +} diff --git a/packages/uipath/samples/multi-output-agent/evaluations/evaluators/list-keys-exact-match.json b/packages/uipath/samples/multi-output-agent/evaluations/evaluators/list-keys-exact-match.json new file mode 100644 index 000000000..d2b685bb3 --- /dev/null +++ b/packages/uipath/samples/multi-output-agent/evaluations/evaluators/list-keys-exact-match.json @@ -0,0 +1,10 @@ +{ + "version": "1.0", + "id": "list-keys-exact-match", + "description": "Exact match on multiple output keys at once (summary.status and summary.total)", + "evaluatorTypeId": "uipath-exact-match", + "evaluatorConfig": { + "name": "ListKeysExactMatch", + "targetOutputKey": ["summary.status", "summary.total"] + } +} diff --git a/packages/uipath/src/uipath/eval/evaluators/output_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/output_evaluator.py index 27dd05d9f..5eb54e9e3 100644 --- a/packages/uipath/src/uipath/eval/evaluators/output_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/output_evaluator.py @@ -86,8 +86,9 @@ class OutputEvaluatorConfig(BaseEvaluatorConfig[T]): specific output evaluation criteria types while maintaining type safety. """ - target_output_key: str = Field( - default="*", description="Key to extract output from agent execution" + target_output_key: str | list[str] = Field( + default="*", + description="Key or list of keys to extract output from agent execution", ) line_by_line_evaluator: bool = Field( default=False, @@ -133,12 +134,35 @@ def _get_actual_output(self, agent_execution: AgentExecution) -> Any: If the output is a job attachment URI, downloads the attachment and returns its content as a string. """ - if self.evaluator_config.target_output_key != "*": - try: - result = resolve_output_path( - agent_execution.agent_output, - self.evaluator_config.target_output_key, + key = self.evaluator_config.target_output_key + + if isinstance(key, list): + if not isinstance(agent_execution.agent_output, dict): + raise UiPathEvaluationError( + code="INVALID_ACTUAL_OUTPUT", + title="When target output keys are specified, actual output must be a dictionary", + detail=f"Got {type(agent_execution.agent_output).__name__}", + category=UiPathEvaluationErrorCategory.USER, ) + try: + list_result: dict[str, Any] = { + k: resolve_output_path(agent_execution.agent_output, k) for k in key + } + except (KeyError, IndexError, TypeError) as e: + raise UiPathEvaluationError( + code="TARGET_OUTPUT_KEY_NOT_FOUND", + title="One or more target output keys not found in actual output", + detail=f"Error: {e}", + category=UiPathEvaluationErrorCategory.USER, + ) from e + for k, v in list_result.items(): + if is_job_attachment_uri(v): + attachment_id = extract_attachment_id(v) + list_result[k] = download_attachment_as_string(attachment_id) + return self._normalize_numbers(list_result) + elif key != "*": + try: + result = resolve_output_path(agent_execution.agent_output, key) except (KeyError, IndexError, TypeError) as e: raise UiPathEvaluationError( code="TARGET_OUTPUT_KEY_NOT_FOUND", @@ -149,7 +173,6 @@ def _get_actual_output(self, agent_execution: AgentExecution) -> Any: else: result = agent_execution.agent_output - # Check if result is a job attachment URI and download if so if is_job_attachment_uri(result): attachment_id = extract_attachment_id(result) result = download_attachment_as_string(attachment_id) @@ -165,32 +188,67 @@ def _get_full_expected_output(self, evaluation_criteria: T) -> Any: category=UiPathEvaluationErrorCategory.SYSTEM, ) - def _get_expected_output(self, evaluation_criteria: T) -> Any: - """Load the expected output from the evaluation criteria.""" - expected_output = self._get_full_expected_output(evaluation_criteria) - if self.evaluator_config.target_output_key != "*": - if isinstance(expected_output, str): - try: - expected_output = json.loads(expected_output) - except json.JSONDecodeError as e: - raise UiPathEvaluationError( - code="INVALID_EXPECTED_OUTPUT", - title="When target output key is not '*', expected output must be a dictionary or a valid JSON string", - detail=f"Error: {e}", - category=UiPathEvaluationErrorCategory.USER, - ) from e + def _resolve_list_key_expected( + self, expected_output: Any, keys: list[str] + ) -> dict[str, Any]: + """Parse and resolve expected output for a list of keys.""" + if isinstance(expected_output, str): try: - expected_output = resolve_output_path( - expected_output, - self.evaluator_config.target_output_key, - ) - except (KeyError, IndexError, TypeError) as e: + expected_output = json.loads(expected_output) + except json.JSONDecodeError as e: raise UiPathEvaluationError( - code="TARGET_OUTPUT_KEY_NOT_FOUND", - title="Target output key not found in expected output", + code="INVALID_EXPECTED_OUTPUT", + title="When target output keys are specified, expected output must be a dictionary or a valid JSON string", + detail=f"Error: {e}", + category=UiPathEvaluationErrorCategory.USER, + ) from e + if not isinstance(expected_output, dict): + raise UiPathEvaluationError( + code="INVALID_EXPECTED_OUTPUT", + title="When target output keys are specified, expected output must be a dictionary", + detail=f"Got {type(expected_output).__name__}", + category=UiPathEvaluationErrorCategory.USER, + ) + try: + return {k: resolve_output_path(expected_output, k) for k in keys} + except (KeyError, IndexError, TypeError) as e: + raise UiPathEvaluationError( + code="TARGET_OUTPUT_KEY_NOT_FOUND", + title="One or more target output keys not found in expected output", + detail=f"Error: {e}", + category=UiPathEvaluationErrorCategory.USER, + ) from e + + def _resolve_scalar_key_expected(self, expected_output: Any, key: str) -> Any: + """Parse and resolve expected output for a single key.""" + if isinstance(expected_output, str): + try: + expected_output = json.loads(expected_output) + except json.JSONDecodeError as e: + raise UiPathEvaluationError( + code="INVALID_EXPECTED_OUTPUT", + title="When target output key is not '*', expected output must be a dictionary or a valid JSON string", detail=f"Error: {e}", category=UiPathEvaluationErrorCategory.USER, ) from e + try: + return resolve_output_path(expected_output, key) + except (KeyError, IndexError, TypeError) as e: + raise UiPathEvaluationError( + code="TARGET_OUTPUT_KEY_NOT_FOUND", + title="Target output key not found in expected output", + detail=f"Error: {e}", + category=UiPathEvaluationErrorCategory.USER, + ) from e + + def _get_expected_output(self, evaluation_criteria: T) -> Any: + """Load the expected output from the evaluation criteria.""" + expected_output = self._get_full_expected_output(evaluation_criteria) + key = self.evaluator_config.target_output_key + if isinstance(key, list): + expected_output = self._resolve_list_key_expected(expected_output, key) + elif key != "*": + expected_output = self._resolve_scalar_key_expected(expected_output, key) return self._normalize_numbers(expected_output) async def validate_and_evaluate_criteria( @@ -225,7 +283,9 @@ async def validate_and_evaluate_criteria( validated_criteria = self.validate_evaluation_criteria(evaluation_criteria) # Check if line-by-line evaluation is enabled - if not self.evaluator_config.line_by_line_evaluator: + if not self.evaluator_config.line_by_line_evaluator or isinstance( + self.evaluator_config.target_output_key, list + ): # Standard evaluation return await self.evaluate(agent_execution, validated_criteria) @@ -248,51 +308,44 @@ async def _evaluate_line_by_line( """ from .line_by_line_utils import build_line_by_line_result, evaluate_lines - # Get the full actual and expected outputs before splitting + key_str = ( + self.evaluator_config.target_output_key + if isinstance(self.evaluator_config.target_output_key, str) + else "*" + ) + actual_output = self._get_actual_output(agent_execution) expected_output = self._get_expected_output(evaluation_criteria) - # Split into lines using utility function actual_lines = split_into_lines( - actual_output, - self.evaluator_config.line_delimiter, - self.evaluator_config.target_output_key, + actual_output, self.evaluator_config.line_delimiter, key_str ) expected_lines = split_into_lines( - expected_output, - self.evaluator_config.line_delimiter, - self.evaluator_config.target_output_key, + expected_output, self.evaluator_config.line_delimiter, key_str ) - # Store original agent execution data original_agent_output = agent_execution.agent_output - # Create function to build line criteria def create_line_criteria(expected_line: str) -> Any: from .line_by_line_utils import wrap_line_in_structure - line_expected_output = wrap_line_in_structure( - expected_line, self.evaluator_config.target_output_key - ) + line_expected_output = wrap_line_in_structure(expected_line, key_str) line_criteria_dict = evaluation_criteria.model_dump() if "expected_output" in line_criteria_dict: line_criteria_dict["expected_output"] = line_expected_output return type(evaluation_criteria).model_validate(line_criteria_dict) - # Evaluate all lines using utility function line_details, line_results = await evaluate_lines( actual_lines=actual_lines, expected_lines=expected_lines, - target_output_key=self.evaluator_config.target_output_key, + target_output_key=key_str, agent_execution=agent_execution, evaluate_fn=self.evaluate, create_line_criteria_fn=create_line_criteria, ) - # Restore original agent output agent_execution.agent_output = original_agent_output - # Build and return the aggregated result using utility function return build_line_by_line_result( line_details=line_details, line_results=line_results, diff --git a/packages/uipath/testcases/list-target-output-key-evals/pyproject.toml b/packages/uipath/testcases/list-target-output-key-evals/pyproject.toml new file mode 100644 index 000000000..b3a04339d --- /dev/null +++ b/packages/uipath/testcases/list-target-output-key-evals/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "list-target-output-key-evals" +version = "0.0.1" +description = "Tests for evaluating multiple output keys at once using a list targetOutputKey" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +dependencies = [ + "uipath", +] +requires-python = ">=3.11" + +[tool.uv.sources] +uipath = { path = "../../", editable = true } diff --git a/packages/uipath/testcases/list-target-output-key-evals/run.sh b/packages/uipath/testcases/list-target-output-key-evals/run.sh new file mode 100755 index 000000000..508bd9777 --- /dev/null +++ b/packages/uipath/testcases/list-target-output-key-evals/run.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -e + +echo "Syncing dependencies..." +uv sync + +echo "Authenticating with UiPath..." +uv run uipath auth --client-id="$CLIENT_ID" --client-secret="$CLIENT_SECRET" --base-url="$BASE_URL" + +echo "Running list targetOutputKey evaluations..." +uv run uipath eval main ../../samples/list_target_output_key_test/evaluations/eval-sets/default.json --no-report --output-file default.json + +echo "Test completed successfully!" diff --git a/packages/uipath/testcases/list-target-output-key-evals/src/assert.py b/packages/uipath/testcases/list-target-output-key-evals/src/assert.py new file mode 100644 index 000000000..80f22c195 --- /dev/null +++ b/packages/uipath/testcases/list-target-output-key-evals/src/assert.py @@ -0,0 +1,90 @@ +"""Assertions for list-target-output-key-evals testcase. + +Validates that evaluating multiple output fields at once (targetOutputKey as a +list) works correctly for both ExactMatch and JsonSimilarity evaluators. + +Expected outcomes +----------------- +- headphones-all-match : ListKeysExactMatch=1.0, ListKeysJsonSimilarity=1.0 +- shoes-all-match : ListKeysExactMatch=1.0, ListKeysJsonSimilarity=1.0 +- headphones-wrong-price: ListKeysExactMatch=0.0, ListKeysJsonSimilarity=1.0 +""" + +import json +import os + +# Maps evaluationName → evaluator ID → expected score (1.0 = pass, 0.0 = fail) +EXPECTED: dict[str, dict[str, float]] = { + "Headphones - all keys match": { + "ListKeysExactMatch": 1.0, + "ListKeysJsonSimilarity": 1.0, + }, + "Running Shoes - all keys match": { + "ListKeysExactMatch": 1.0, + "ListKeysJsonSimilarity": 1.0, + }, + "Headphones - wrong price (should fail)": { + "ListKeysExactMatch": 0.0, + "ListKeysJsonSimilarity": 1.0, + }, +} + + +def main() -> None: + output_file = "default.json" + assert os.path.isfile(output_file), f"Output file '{output_file}' not found" + print(f"Found output file: {output_file}") + + with open(output_file, "r", encoding="utf-8") as f: + output_data = json.load(f) + + assert "evaluationSetResults" in output_data, "Missing 'evaluationSetResults'" + evaluation_results = output_data["evaluationSetResults"] + assert len(evaluation_results) > 0, "No evaluation results found" + print(f"Found {len(evaluation_results)} evaluation result(s)") + + failures: list[str] = [] + + for eval_result in evaluation_results: + eval_name = eval_result.get("evaluationName", "") + expected_scores = EXPECTED.get(eval_name) + + if expected_scores is None: + print(f" [skip] '{eval_name}' not in EXPECTED map") + continue + + print(f"\n Validating: {eval_name}") + + run_results = eval_result.get("evaluationRunResults", []) + assert len(run_results) > 0, f"No run results for '{eval_name}'" + + for run in run_results: + evaluator_id = run.get("evaluatorId", run.get("evaluatorName", "")) + score = run.get("result", {}).get("score", None) + + if evaluator_id not in expected_scores: + print(f" [skip] unexpected evaluator '{evaluator_id}'") + continue + + expected = expected_scores[evaluator_id] + ok = score == expected + status = "pass" if ok else "FAIL" + print(f" {evaluator_id}: score={score} expected={expected} ({status})") + if not ok: + failures.append( + f"{eval_name} / {evaluator_id}: got {score}, expected {expected}" + ) + + print(f"\n{'=' * 60}") + if failures: + for f in failures: + print(f" FAIL: {f}") + print(f"{'=' * 60}") + assert False, f"{len(failures)} assertion(s) failed" + + print(" All assertions passed!") + print(f"{'=' * 60}") + + +if __name__ == "__main__": + main() diff --git a/packages/uipath/testcases/list-target-output-key-evals/uipath.json b/packages/uipath/testcases/list-target-output-key-evals/uipath.json new file mode 100644 index 000000000..a50f100df --- /dev/null +++ b/packages/uipath/testcases/list-target-output-key-evals/uipath.json @@ -0,0 +1,5 @@ +{ + "functions": { + "main": "../../samples/list_target_output_key_test/main.py:main" + } +} diff --git a/packages/uipath/tests/evaluators/test_documentation_examples.py b/packages/uipath/tests/evaluators/test_documentation_examples.py index c75d94329..559df3a01 100644 --- a/packages/uipath/tests/evaluators/test_documentation_examples.py +++ b/packages/uipath/tests/evaluators/test_documentation_examples.py @@ -340,6 +340,52 @@ async def test_using_default_criteria(self) -> None: assert result.score == 1.0 + @pytest.mark.asyncio + async def test_list_target_output_key(self) -> None: + """Test evaluating multiple output fields at once using a list of keys. + + Mirrors the multi-output-agent sample (list-keys-exact-match evaluator). + """ + # Agent returns a rich nested output; we only care about two summary fields. + agent_execution = AgentExecution( + agent_input={"customer_name": "John Doe", "items": []}, + agent_output={ + "order_id": "ORD-001", + "summary": {"status": "completed", "total": 44.97, "item_count": 3}, + "tags": ["priority", "express"], + }, + agent_trace=[], + ) + + evaluator = TypeAdapter(ExactMatchEvaluator).validate_python( + dict( + id="list-keys-exact-match", + evaluatorConfig={ + "name": "ListKeysExactMatch", + # Pass a list to assert multiple fields in a single evaluator run. + "target_output_key": ["summary.status", "summary.total"], + }, + ) + ) + + # Both keys match → score 1.0 + result = await evaluator.validate_and_evaluate_criteria( + agent_execution=agent_execution, + evaluation_criteria={ + "expected_output": {"summary": {"status": "completed", "total": 44.97}} + }, + ) + assert result.score == 1.0 + + # One key differs → score 0.0 + result = await evaluator.validate_and_evaluate_criteria( + agent_execution=agent_execution, + evaluation_criteria={ + "expected_output": {"summary": {"status": "completed", "total": 999.0}} + }, + ) + assert result.score == 0.0 + class TestJsonSimilarityExamples: """Test examples from docs/eval/json_similarity.md.""" diff --git a/packages/uipath/tests/evaluators/test_evaluator_methods.py b/packages/uipath/tests/evaluators/test_evaluator_methods.py index 22cfc980e..ec795499d 100644 --- a/packages/uipath/tests/evaluators/test_evaluator_methods.py +++ b/packages/uipath/tests/evaluators/test_evaluator_methods.py @@ -447,6 +447,448 @@ async def test_exact_match_line_by_line_has_individual_results(self) -> None: assert line3_result.score == 1.0 +class TestListTargetOutputKey: + """Test target_output_key as a list of keys.""" + + @pytest.mark.asyncio + async def test_exact_match_list_keys_all_match(self) -> None: + """All listed keys match → score 1.0.""" + execution = AgentExecution( + agent_input={}, + agent_output={"status": "ok", "total": 42, "extra": "ignored"}, + agent_trace=[], + ) + config = { + "name": "ExactMatchListKeys", + "target_output_key": ["status", "total"], + } + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output={"status": "ok", "total": 42} # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, NumericEvaluationResult) + assert result.score == 1.0 + + @pytest.mark.asyncio + async def test_exact_match_list_keys_value_mismatch(self) -> None: + """One key's value differs → score 0.0.""" + execution = AgentExecution( + agent_input={}, + agent_output={"status": "ok", "total": 99}, + agent_trace=[], + ) + config = { + "name": "ExactMatchListKeys", + "target_output_key": ["status", "total"], + } + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output={"status": "ok", "total": 42} # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, NumericEvaluationResult) + assert result.score == 0.0 + + @pytest.mark.asyncio + async def test_exact_match_list_keys_dot_notation(self) -> None: + """Nested dot-notation paths inside a list of keys.""" + execution = AgentExecution( + agent_input={}, + agent_output={"order": {"status": "shipped"}, "qty": 3}, + agent_trace=[], + ) + config = { + "name": "ExactMatchListDotKeys", + "target_output_key": ["order.status", "qty"], + } + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output={"order": {"status": "shipped"}, "qty": 3} # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, NumericEvaluationResult) + assert result.score == 1.0 + + @pytest.mark.asyncio + async def test_list_keys_missing_key_in_actual_raises(self) -> None: + """Missing key in actual output returns an ErrorEvaluationResult.""" + from uipath.eval.models.models import ErrorEvaluationResult + + execution = AgentExecution( + agent_input={}, + agent_output={"status": "ok"}, # 'total' is missing + agent_trace=[], + ) + config = { + "name": "ExactMatchListKeys", + "target_output_key": ["status", "total"], + } + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output={"status": "ok", "total": 42} # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, ErrorEvaluationResult) + assert result.score == 0.0 + + @pytest.mark.asyncio + async def test_list_keys_missing_key_in_expected_raises(self) -> None: + """Missing key in expected output returns an ErrorEvaluationResult.""" + from uipath.eval.models.models import ErrorEvaluationResult + + execution = AgentExecution( + agent_input={}, + agent_output={"status": "ok", "total": 42}, + agent_trace=[], + ) + config = { + "name": "ExactMatchListKeys", + "target_output_key": ["status", "total"], + } + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output={ + "status": "ok" + } # 'total' missing # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, ErrorEvaluationResult) + assert result.score == 0.0 + + @pytest.mark.asyncio + async def test_list_keys_expected_as_json_string(self) -> None: + """Expected output as a JSON string is parsed when key is a list.""" + import json + + execution = AgentExecution( + agent_input={}, + agent_output={"status": "ok", "total": 5}, + agent_trace=[], + ) + config = { + "name": "ExactMatchListKeys", + "target_output_key": ["status", "total"], + } + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output=json.dumps({"status": "ok", "total": 5}) # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, NumericEvaluationResult) + assert result.score == 1.0 + + @pytest.mark.asyncio + async def test_list_keys_invalid_json_string_expected_raises(self) -> None: + """Invalid JSON string for expected output returns an ErrorEvaluationResult.""" + from uipath.eval.models.models import ErrorEvaluationResult + + execution = AgentExecution( + agent_input={}, + agent_output={"status": "ok"}, + agent_trace=[], + ) + config = {"name": "ExactMatchListKeys", "target_output_key": ["status"]} + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output="not valid json" # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, ErrorEvaluationResult) + assert result.score == 0.0 + + @pytest.mark.asyncio + async def test_list_keys_disables_line_by_line(self) -> None: + """line_by_line_evaluator=True is ignored when target_output_key is a list.""" + execution = AgentExecution( + agent_input={}, + agent_output={"a": "x", "b": "y"}, + agent_trace=[], + ) + config = { + "name": "ExactMatchListLbl", + "target_output_key": ["a", "b"], + "line_by_line_evaluator": True, + } + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + raw_criteria = {"expected_output": {"a": "x", "b": "y"}} + # Should not raise or split into lines; returns a plain NumericEvaluationResult + result = await evaluator.validate_and_evaluate_criteria(execution, raw_criteria) + assert isinstance(result, NumericEvaluationResult) + assert result.score == 1.0 + # No line-by-line details attached + assert ( + not hasattr(result, "_line_by_line_results") + or result._line_by_line_results is None + ) + + @pytest.mark.asyncio + async def test_json_similarity_list_keys_perfect_match(self) -> None: + """JsonSimilarityEvaluator with list keys scores 1.0 on exact match.""" + from uipath.eval.evaluators.json_similarity_evaluator import ( + JsonSimilarityEvaluator, + ) + + execution = AgentExecution( + agent_input={}, + agent_output={"name": "Alice", "score": 100, "extra": "ignored"}, + agent_trace=[], + ) + config = { + "name": "JsonSimListKeys", + "target_output_key": ["name", "score"], + } + evaluator = JsonSimilarityEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output={"name": "Alice", "score": 100} # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, NumericEvaluationResult) + assert result.score == 1.0 + + @pytest.mark.asyncio + async def test_list_keys_non_dict_actual_raises(self) -> None: + """Non-dict agent_output with list key returns ErrorEvaluationResult.""" + from uipath.eval.models.models import ErrorEvaluationResult + + execution = AgentExecution( + agent_input={}, + agent_output="just a string", # pyright: ignore[reportArgumentType] + agent_trace=[], + ) + config = {"name": "ExactMatchListKeys", "target_output_key": ["status"]} + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output={"status": "ok"} # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, ErrorEvaluationResult) + assert result.score == 0.0 + + @pytest.mark.asyncio + async def test_list_keys_non_dict_json_expected_raises(self) -> None: + """Valid JSON that parses to a non-dict returns ErrorEvaluationResult.""" + import json + + from uipath.eval.models.models import ErrorEvaluationResult + + execution = AgentExecution( + agent_input={}, + agent_output={"status": "ok"}, + agent_trace=[], + ) + config = {"name": "ExactMatchListKeys", "target_output_key": ["status"]} + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + # Valid JSON but not an object — triggers the isinstance(expected_output, dict) guard + criteria = OutputEvaluationCriteria( + expected_output=json.dumps("just a string") # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, ErrorEvaluationResult) + assert result.score == 0.0 + + @pytest.mark.asyncio + async def test_list_keys_attachment_uri_downloaded( + self, mocker: MockerFixture + ) -> None: + """Attachment URIs within list-key actual output values are downloaded.""" + att_uri = ( + "urn:uipath:cas:file:orchestrator:00000000-0000-0000-0000-000000000001" + ) + mocker.patch( + "uipath.eval.evaluators.output_evaluator.download_attachment_as_string", + return_value="downloaded_content", + ) + execution = AgentExecution( + agent_input={}, + agent_output={"file": att_uri, "status": "ok"}, + agent_trace=[], + ) + config = {"name": "ExactMatchListKeys", "target_output_key": ["file", "status"]} + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output={"file": "downloaded_content", "status": "ok"} # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, NumericEvaluationResult) + assert result.score == 1.0 + + @pytest.mark.asyncio + async def test_scalar_key_attachment_uri_downloaded( + self, mocker: MockerFixture + ) -> None: + """Attachment URI in scalar-key actual output is downloaded.""" + att_uri = ( + "urn:uipath:cas:file:orchestrator:00000000-0000-0000-0000-000000000002" + ) + mocker.patch( + "uipath.eval.evaluators.output_evaluator.download_attachment_as_string", + return_value="file_content", + ) + execution = AgentExecution( + agent_input={}, + agent_output={"report": att_uri}, + agent_trace=[], + ) + config = {"name": "ExactMatchScalarAtt", "target_output_key": "report"} + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output={"report": "file_content"} # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, NumericEvaluationResult) + assert result.score == 1.0 + + @pytest.mark.asyncio + async def test_scalar_key_missing_in_actual_raises(self) -> None: + """Missing scalar target_output_key in actual output returns ErrorEvaluationResult.""" + from uipath.eval.models.models import ErrorEvaluationResult + + execution = AgentExecution( + agent_input={}, + agent_output={"other": "value"}, + agent_trace=[], + ) + config = {"name": "ExactMatchMissingActual", "target_output_key": "missing_key"} + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output={"missing_key": "val"} # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, ErrorEvaluationResult) + assert result.score == 0.0 + + @pytest.mark.asyncio + async def test_scalar_key_missing_in_expected_raises(self) -> None: + """Missing scalar target_output_key in expected output returns ErrorEvaluationResult.""" + from uipath.eval.models.models import ErrorEvaluationResult + + execution = AgentExecution( + agent_input={}, + agent_output={"status": "ok"}, + agent_trace=[], + ) + config = {"name": "ExactMatchMissingExpected", "target_output_key": "status"} + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + # expected output dict doesn't contain the target key + criteria = OutputEvaluationCriteria( + expected_output={"other_key": "ok"} # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, ErrorEvaluationResult) + assert result.score == 0.0 + + @pytest.mark.asyncio + async def test_scalar_key_invalid_json_expected_raises(self) -> None: + """Invalid JSON string for expected output with scalar key returns ErrorEvaluationResult.""" + from uipath.eval.models.models import ErrorEvaluationResult + + execution = AgentExecution( + agent_input={}, + agent_output={"status": "ok"}, + agent_trace=[], + ) + config = {"name": "ExactMatchInvalidJson", "target_output_key": "status"} + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output="not valid json" # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, ErrorEvaluationResult) + assert result.score == 0.0 + + @pytest.mark.asyncio + async def test_validate_and_evaluate_criteria_none_raises(self) -> None: + """None criteria with no default configured raises UiPathEvaluationError.""" + execution = AgentExecution( + agent_input={}, + agent_output={"status": "ok"}, + agent_trace=[], + ) + config = {"name": "ExactMatchNoCriteria"} + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + with pytest.raises(UiPathEvaluationError) as exc_info: + await evaluator.validate_and_evaluate_criteria(execution, None) + assert "MISSING_EVALUATION_CRITERIA" in exc_info.value.error_info.code + + def test_base_output_evaluator_get_full_expected_output_raises(self) -> None: + """BaseOutputEvaluator._get_full_expected_output raises NOT_IMPLEMENTED.""" + from uipath.eval.evaluators.base_evaluator import BaseEvaluatorJustification + from uipath.eval.evaluators.output_evaluator import ( + BaseOutputEvaluator, + OutputEvaluationCriteria, + OutputEvaluatorConfig, + ) + from uipath.eval.models import EvaluationResult + + class _MinimalEvaluator( + BaseOutputEvaluator[ + OutputEvaluationCriteria, + OutputEvaluatorConfig[OutputEvaluationCriteria], + BaseEvaluatorJustification, + ] + ): + @classmethod + def get_evaluator_id(cls) -> str: + return "uipath-minimal-test" + + async def evaluate( + self, agent_execution: Any, evaluation_criteria: Any + ) -> EvaluationResult: + return None # type: ignore[return-value] + + def validate_evaluation_criteria( + self, raw: Any + ) -> OutputEvaluationCriteria: + return OutputEvaluationCriteria.model_validate(raw) + + evaluator = _MinimalEvaluator.model_validate( + { + "evaluatorConfig": {"name": "minimal"}, + "id": str(uuid.uuid4()), + } + ) + with pytest.raises(UiPathEvaluationError) as exc_info: + evaluator._get_full_expected_output( # pyright: ignore[reportArgumentType] + OutputEvaluationCriteria(expected_output={}) # pyright: ignore[reportCallIssue] + ) + assert "NOT_IMPLEMENTED" in exc_info.value.error_info.code + + class TestContainsEvaluator: """Test ContainsEvaluator.evaluate() method.""" diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index cf11b2a5b..fee751155 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.77" +version = "2.10.78" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 9a5d2bbbef4f049c36728bfb44c91a49a9bd69f0 Mon Sep 17 00:00:00 2001 From: yashwagle1 Date: Fri, 5 Jun 2026 14:47:20 -0700 Subject: [PATCH 088/191] move pii masking service to llmops (#1701) Co-authored-by: Claude Opus 4.8 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/_uipath.py | 5 + .../uipath/platform/pii_detection/__init__.py | 36 +++ .../pii_detection/_pii_detection_service.py | 80 ++++++ .../platform/pii_detection/pii_detection.py | 91 ++++++ .../platform/pii_detection/pii_utilities.py | 98 +++++++ .../platform/semantic_proxy/pii_utilities.py | 21 +- .../services/test_pii_detection_service.py | 264 ++++++++++++++++++ .../tests/services/test_pii_utilities.py | 11 +- packages/uipath-platform/uv.lock | 4 +- packages/uipath/uv.lock | 4 +- 11 files changed, 591 insertions(+), 25 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/pii_detection/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/pii_detection/_pii_detection_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/pii_detection/pii_detection.py create mode 100644 packages/uipath-platform/src/uipath/platform/pii_detection/pii_utilities.py create mode 100644 packages/uipath-platform/tests/services/test_pii_detection_service.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index dfc759f4d..aa3ef6b47 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.60" +version = "0.1.61" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index f81333862..8e0e23867 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -35,6 +35,7 @@ ProcessesService, QueuesService, ) +from .pii_detection import PiiDetectionService from .resource_catalog import ResourceCatalogService from .semantic_proxy import SemanticProxyService @@ -184,6 +185,10 @@ def orchestrator_setup(self) -> OrchestratorSetupService: def automation_ops(self) -> AutomationOpsService: return AutomationOpsService(self._config, self._execution_context) + @property + def pii_detection(self) -> PiiDetectionService: + return PiiDetectionService(self._config, self._execution_context) + @property def semantic_proxy(self) -> SemanticProxyService: return SemanticProxyService(self._config, self._execution_context) diff --git a/packages/uipath-platform/src/uipath/platform/pii_detection/__init__.py b/packages/uipath-platform/src/uipath/platform/pii_detection/__init__.py new file mode 100644 index 000000000..7ab3b9e26 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/pii_detection/__init__.py @@ -0,0 +1,36 @@ +"""PiiDetection service package. + +Provides the ``PiiDetectionService`` client, Pydantic request/response models for +the PII detection endpoint, and utilities for rehydrating masked text with +original PII values after LLM processing. +""" + +from ._pii_detection_service import PiiDetectionService +from .pii_detection import ( + PiiDetectionRequest, + PiiDetectionResponse, + PiiDocument, + PiiDocumentResult, + PiiEntity, + PiiEntityThreshold, + PiiFile, + PiiFileResult, +) +from .pii_utilities import ( + rehydrate_from_pii_entities, + rehydrate_from_pii_response, +) + +__all__ = [ + "PiiDetectionRequest", + "PiiDetectionResponse", + "PiiDetectionService", + "PiiDocument", + "PiiDocumentResult", + "PiiEntity", + "PiiEntityThreshold", + "PiiFile", + "PiiFileResult", + "rehydrate_from_pii_entities", + "rehydrate_from_pii_response", +] diff --git a/packages/uipath-platform/src/uipath/platform/pii_detection/_pii_detection_service.py b/packages/uipath-platform/src/uipath/platform/pii_detection/_pii_detection_service.py new file mode 100644 index 000000000..a39ed4196 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/pii_detection/_pii_detection_service.py @@ -0,0 +1,80 @@ +"""PiiDetection service for UiPath Platform. + +Provides methods for detecting PII in documents and files. +""" + +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._models import Endpoint, RequestSpec +from .pii_detection import PiiDetectionRequest, PiiDetectionResponse + +_PII_DETECTION_ENDPOINT = Endpoint("llmopstenant_/api/pii-detection") + +# PII detection over documents/files can be slow, so override the default +# httpx client timeout (30s) with a longer per-request timeout. +_PII_DETECTION_TIMEOUT = 290.0 + + +class PiiDetectionService(BaseService): + """Service for detecting PII via UiPath.""" + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + super().__init__(config=config, execution_context=execution_context) + + @traced(name="pii_detection_detect_pii", run_type="uipath") + def detect_pii(self, request: PiiDetectionRequest) -> PiiDetectionResponse: + """Detect PII in the provided documents and/or files. + + Args: + request: The PII detection request payload. + + Returns: + The PII detection response. + """ + spec = self._pii_detection_spec(request) + response = self.request( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + scoped="tenant", + timeout=_PII_DETECTION_TIMEOUT, + ) + return PiiDetectionResponse.model_validate(response.json()) + + @traced(name="pii_detection_detect_pii", run_type="uipath") + async def detect_pii_async( + self, request: PiiDetectionRequest + ) -> PiiDetectionResponse: + """Detect PII in the provided documents and/or files (async). + + Args: + request: The PII detection request payload. + + Returns: + The PII detection response. + """ + spec = self._pii_detection_spec(request) + response = await self.request_async( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + scoped="tenant", + timeout=_PII_DETECTION_TIMEOUT, + ) + return PiiDetectionResponse.model_validate(response.json()) + + def _pii_detection_spec(self, request: PiiDetectionRequest) -> RequestSpec: + return RequestSpec( + method="POST", + endpoint=_PII_DETECTION_ENDPOINT, + json=request.model_dump(by_alias=True, exclude_none=True), + ) diff --git a/packages/uipath-platform/src/uipath/platform/pii_detection/pii_detection.py b/packages/uipath-platform/src/uipath/platform/pii_detection/pii_detection.py new file mode 100644 index 000000000..94ac10fca --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/pii_detection/pii_detection.py @@ -0,0 +1,91 @@ +"""Public Pydantic models for the PiiDetection service.""" + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class PiiDocument(BaseModel): + """A text document to scan for PII.""" + + id: str + role: str + document: str + + +class PiiFile(BaseModel): + """A file reference to scan for PII.""" + + model_config = ConfigDict(populate_by_name=True) + + file_name: str = Field(alias="fileName") + file_url: str = Field(alias="fileUrl") + file_type: str = Field(alias="fileType") + + +class PiiEntityThreshold(BaseModel): + """Per-entity confidence threshold override.""" + + model_config = ConfigDict(populate_by_name=True) + + category: str = Field(alias="pii-entity-category") + confidence_threshold: float = Field(alias="pii-entity-confidence-threshold") + + +class PiiDetectionRequest(BaseModel): + """Request payload for the PII detection endpoint.""" + + model_config = ConfigDict(populate_by_name=True) + + documents: Optional[list[PiiDocument]] = None + files: Optional[list[PiiFile]] = None + language_code: Optional[str] = Field(default=None, alias="languageCode") + confidence_threshold: Optional[float] = Field( + default=None, alias="confidenceThreshold" + ) + entity_thresholds: Optional[list[PiiEntityThreshold]] = Field( + default=None, alias="entityThresholds" + ) + + +class PiiEntity(BaseModel): + """A single detected PII entity.""" + + model_config = ConfigDict(populate_by_name=True) + + pii_text: str = Field(alias="piiText") + replacement_text: str = Field(alias="replacementText") + pii_type: str = Field(alias="piiType") + offset: int + confidence_score: float = Field(alias="confidenceScore") + + +class PiiDocumentResult(BaseModel): + """PII detection result for a single document.""" + + model_config = ConfigDict(populate_by_name=True) + + id: str + role: str + masked_document: str = Field(alias="maskedDocument") + initial_document: str = Field(alias="initialDocument") + pii_entities: list[PiiEntity] = Field(default_factory=list, alias="piiEntities") + + +class PiiFileResult(BaseModel): + """PII detection result for a single file (fileUrl is the redacted URL).""" + + model_config = ConfigDict(populate_by_name=True) + + file_name: str = Field(alias="fileName") + file_url: str = Field(alias="fileUrl") + pii_entities: list[PiiEntity] = Field(default_factory=list, alias="piiEntities") + + +class PiiDetectionResponse(BaseModel): + """Response payload from the PII detection endpoint.""" + + model_config = ConfigDict(populate_by_name=True) + + response: list[PiiDocumentResult] = Field(default_factory=list) + files: list[PiiFileResult] = Field(default_factory=list) diff --git a/packages/uipath-platform/src/uipath/platform/pii_detection/pii_utilities.py b/packages/uipath-platform/src/uipath/platform/pii_detection/pii_utilities.py new file mode 100644 index 000000000..b2fa482d0 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/pii_detection/pii_utilities.py @@ -0,0 +1,98 @@ +"""Utility methods for working with PII data. + +Python port of UiPath.SemanticProxy.Client.PiiUtilities (C#). +""" + +import re +from typing import Callable, Iterable + +from .pii_detection import PiiDetectionResponse, PiiEntity + + +def rehydrate_from_pii_entities( + masked_text: str, pii_entities: Iterable[PiiEntity] +) -> str: + """Rehydrate masked text by replacing PII placeholders with original values. + + Placeholders (e.g. ``[Person-1]``) are matched case-insensitively and replaced + with the corresponding original PII text. The function also replaces variants + without the surrounding brackets (e.g. ``Person-1``) in case the LLM stripped + them in its output. + + Args: + masked_text: The masked text with PII placeholders. + pii_entities: The PII entities containing the original values. + + Returns: + The rehydrated text with original PII values. + """ + if not masked_text: + return masked_text + + entities = [e for e in pii_entities if e.replacement_text] + if not entities: + return masked_text + + # Sort by replacement text length descending to avoid substring collisions + # (e.g. "[Person-10]" must be replaced before "[Person-1]"). + entities.sort(key=lambda e: len(e.replacement_text), reverse=True) + + rehydrated = masked_text + for entity in entities: + if not entity.replacement_text or not entity.pii_text: + continue + # Replace the full placeholder (with brackets) case-insensitively. + # ``_literal_replacer`` bypasses regex backreference interpretation in the + # replacement string. + rehydrated = re.sub( + re.escape(entity.replacement_text), + _literal_replacer(entity.pii_text), + rehydrated, + flags=re.IGNORECASE, + ) + # Also replace the content without brackets (in case the LLM dropped them). + if entity.replacement_text.startswith("[") and entity.replacement_text.endswith( + "]" + ): + no_brackets = entity.replacement_text[1:-1] + rehydrated = re.sub( + re.escape(no_brackets), + _literal_replacer(entity.pii_text), + rehydrated, + flags=re.IGNORECASE, + ) + + return rehydrated + + +def _literal_replacer(replacement: str) -> Callable[[re.Match[str]], str]: + """Return a replacement function that ignores regex backreference syntax.""" + + def replace(_match: re.Match[str]) -> str: + return replacement + + return replace + + +def rehydrate_from_pii_response( + masked_text: str, response: PiiDetectionResponse +) -> str: + """Rehydrate masked text using all PII entities from a detection response. + + Merges entities from both ``response.response`` (detected in documents/prompts) + and ``response.files`` (detected in files), so placeholders originating from + either source are rehydrated. + + Args: + masked_text: The masked text with PII placeholders. + response: The PII detection response containing entities to rehydrate. + + Returns: + The rehydrated text with original PII values. + """ + entities: list[PiiEntity] = [] + for doc in response.response: + entities.extend(doc.pii_entities) + for file in response.files: + entities.extend(file.pii_entities) + return rehydrate_from_pii_entities(masked_text, entities) diff --git a/packages/uipath-platform/src/uipath/platform/semantic_proxy/pii_utilities.py b/packages/uipath-platform/src/uipath/platform/semantic_proxy/pii_utilities.py index c480a5fb7..0f031a19a 100644 --- a/packages/uipath-platform/src/uipath/platform/semantic_proxy/pii_utilities.py +++ b/packages/uipath-platform/src/uipath/platform/semantic_proxy/pii_utilities.py @@ -3,7 +3,6 @@ Python port of UiPath.SemanticProxy.Client.PiiUtilities (C#). """ -import json import re from typing import Callable, Iterable @@ -42,13 +41,12 @@ def rehydrate_from_pii_entities( for entity in entities: if not entity.replacement_text or not entity.pii_text: continue - escaped_pii = _add_escape_characters(entity.pii_text) # Replace the full placeholder (with brackets) case-insensitively. # ``_literal_replacer`` bypasses regex backreference interpretation in the # replacement string. rehydrated = re.sub( re.escape(entity.replacement_text), - _literal_replacer(escaped_pii), + _literal_replacer(entity.pii_text), rehydrated, flags=re.IGNORECASE, ) @@ -59,7 +57,7 @@ def rehydrate_from_pii_entities( no_brackets = entity.replacement_text[1:-1] rehydrated = re.sub( re.escape(no_brackets), - _literal_replacer(escaped_pii), + _literal_replacer(entity.pii_text), rehydrated, flags=re.IGNORECASE, ) @@ -98,18 +96,3 @@ def rehydrate_from_pii_response( for file in response.files: entities.extend(file.pii_entities) return rehydrate_from_pii_entities(masked_text, entities) - - -def _add_escape_characters(text: str) -> str: - """Escape special characters in text using JSON serialization. - - Mirrors C# ``AddEscapeCharacters`` — serializes as JSON then strips the - surrounding quotes to get the escaped content. - """ - if not text: - return "" - try: - serialized = json.dumps(text, ensure_ascii=False) - return serialized[1:-1] - except (TypeError, ValueError): - return text diff --git a/packages/uipath-platform/tests/services/test_pii_detection_service.py b/packages/uipath-platform/tests/services/test_pii_detection_service.py new file mode 100644 index 000000000..2bb424607 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_pii_detection_service.py @@ -0,0 +1,264 @@ +"""Tests for PiiDetectionService.""" + +import json +from typing import Any + +import httpx +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.pii_detection import ( + PiiDetectionRequest, + PiiDetectionResponse, + PiiDetectionService, + PiiDocument, + PiiEntityThreshold, + PiiFile, +) + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, +) -> PiiDetectionService: + return PiiDetectionService(config=config, execution_context=execution_context) + + +@pytest.fixture +def sample_response_json() -> dict[str, Any]: + return { + "response": [ + { + "id": "user-prompt", + "role": "user", + "maskedDocument": "Contact [Person-1]", + "initialDocument": "Contact Alison", + "piiEntities": [ + { + "piiText": "Alison", + "replacementText": "[Person-1]", + "piiType": "Person", + "offset": 8, + "confidenceScore": 0.99, + } + ], + } + ], + "files": [ + { + "fileName": "doc.pdf", + "fileUrl": "https://blob.example.com/redacted/doc.pdf", + "piiEntities": [ + { + "piiText": "alice@example.com", + "replacementText": "[Email-1]", + "piiType": "Email", + "offset": 100, + "confidenceScore": 0.88, + } + ], + } + ], + } + + +class TestPiiDetectionService: + """Test PiiDetectionService functionality.""" + + class TestDetectPii: + """Test detect_pii (sync).""" + + def test_returns_typed_response( + self, + httpx_mock: HTTPXMock, + service: PiiDetectionService, + base_url: str, + org: str, + tenant: str, + sample_response_json: dict[str, Any], + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/pii-detection", + status_code=200, + json=sample_response_json, + ) + + request = PiiDetectionRequest( + documents=[ + PiiDocument( + id="user-prompt", role="user", document="Contact Alison" + ) + ] + ) + result = service.detect_pii(request) + + assert isinstance(result, PiiDetectionResponse) + assert len(result.response) == 1 + assert result.response[0].masked_document == "Contact [Person-1]" + assert len(result.files) == 1 + assert result.files[0].file_name == "doc.pdf" + assert result.files[0].pii_entities[0].replacement_text == "[Email-1]" + + class TestDetectPiiAsync: + """Test detect_pii_async.""" + + async def test_returns_typed_response( + self, + httpx_mock: HTTPXMock, + service: PiiDetectionService, + base_url: str, + org: str, + tenant: str, + sample_response_json: dict[str, Any], + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/pii-detection", + status_code=200, + json=sample_response_json, + ) + + request = PiiDetectionRequest( + files=[ + PiiFile( + file_name="doc.pdf", + file_url="https://input.example.com/doc.pdf", + file_type="pdf", + ) + ] + ) + result = await service.detect_pii_async(request) + + assert isinstance(result, PiiDetectionResponse) + assert ( + result.files[0].file_url == "https://blob.example.com/redacted/doc.pdf" + ) + + async def test_request_payload_uses_aliases( + self, + httpx_mock: HTTPXMock, + service: PiiDetectionService, + base_url: str, + org: str, + tenant: str, + sample_response_json: dict[str, Any], + ) -> None: + captured_request: httpx.Request | None = None + + def capture(request: httpx.Request) -> httpx.Response: + nonlocal captured_request + captured_request = request + return httpx.Response(status_code=200, json=sample_response_json) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/llmopstenant_/api/pii-detection", + callback=capture, + ) + + request = PiiDetectionRequest( + documents=[ + PiiDocument(id="user-prompt", role="user", document="Hello") + ], + files=[ + PiiFile( + file_name="doc.pdf", + file_url="https://input.example.com/doc.pdf", + file_type="pdf", + ) + ], + language_code="en", + confidence_threshold=0.5, + entity_thresholds=[ + PiiEntityThreshold(category="Person", confidence_threshold=0.7), + ], + ) + await service.detect_pii_async(request) + + assert captured_request is not None + payload = json.loads(captured_request.content) + + # Top-level uses camelCase aliases + assert "documents" in payload + assert "files" in payload + assert "languageCode" in payload + assert "confidenceThreshold" in payload + assert "entityThresholds" in payload + + # File uses camelCase aliases + assert payload["files"][0]["fileName"] == "doc.pdf" + assert payload["files"][0]["fileUrl"] == "https://input.example.com/doc.pdf" + assert payload["files"][0]["fileType"] == "pdf" + + # Entity threshold uses kebab-case aliases + threshold = payload["entityThresholds"][0] + assert threshold["pii-entity-category"] == "Person" + assert threshold["pii-entity-confidence-threshold"] == 0.7 + + async def test_request_excludes_none_fields( + self, + httpx_mock: HTTPXMock, + service: PiiDetectionService, + base_url: str, + org: str, + tenant: str, + sample_response_json: dict[str, Any], + ) -> None: + captured_request: httpx.Request | None = None + + def capture(request: httpx.Request) -> httpx.Response: + nonlocal captured_request + captured_request = request + return httpx.Response(status_code=200, json=sample_response_json) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/llmopstenant_/api/pii-detection", + callback=capture, + ) + + # Only documents set; other optional fields should be omitted + request = PiiDetectionRequest( + documents=[PiiDocument(id="user-prompt", role="user", document="Hello")] + ) + await service.detect_pii_async(request) + + assert captured_request is not None + payload = json.loads(captured_request.content) + assert "files" not in payload + assert "languageCode" not in payload + assert "confidenceThreshold" not in payload + assert "entityThresholds" not in payload + + async def test_url_is_tenant_scoped( + self, + httpx_mock: HTTPXMock, + service: PiiDetectionService, + base_url: str, + org: str, + tenant: str, + sample_response_json: dict[str, Any], + ) -> None: + captured_request: httpx.Request | None = None + + def capture(request: httpx.Request) -> httpx.Response: + nonlocal captured_request + captured_request = request + return httpx.Response(status_code=200, json=sample_response_json) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/llmopstenant_/api/pii-detection", + callback=capture, + ) + + request = PiiDetectionRequest( + documents=[PiiDocument(id="user-prompt", role="user", document="Hello")] + ) + await service.detect_pii_async(request) + + assert captured_request is not None + assert org.strip("/") in captured_request.url.path + assert tenant.strip("/") in captured_request.url.path + assert "/llmopstenant_/api/pii-detection" in captured_request.url.path diff --git a/packages/uipath-platform/tests/services/test_pii_utilities.py b/packages/uipath-platform/tests/services/test_pii_utilities.py index 844985a90..751897609 100644 --- a/packages/uipath-platform/tests/services/test_pii_utilities.py +++ b/packages/uipath-platform/tests/services/test_pii_utilities.py @@ -1,6 +1,6 @@ """Tests for PII rehydration utilities.""" -from uipath.platform.semantic_proxy import ( +from uipath.platform.pii_detection import ( PiiDetectionResponse, PiiDocumentResult, PiiEntity, @@ -116,6 +116,15 @@ def test_regex_special_chars_in_replacement_text(self) -> None: ) assert result == "Hello Alice" + def test_pii_text_inserted_verbatim_not_json_escaped(self) -> None: + """PII values are plain text: quotes, newlines and backslashes are + inserted as-is, never JSON-escaped.""" + pii = 'Bob "The Boss"\nC:\\Users\\bob' + result = rehydrate_from_pii_entities( + "Name: [Person-1]", [_entity(pii, "[Person-1]")] + ) + assert result == f"Name: {pii}" + class TestRehydrateFromPiiResponse: """Test rehydrate_from_pii_response.""" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 084f3efb8..34dc9d413 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-05-25T20:56:22.964456Z" +exclude-newer = "2026-06-03T20:09:03.1179056Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.60" +version = "0.1.61" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index fee751155..657a4cf7d 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-05-27T15:26:21.545236Z" +exclude-newer = "2026-06-03T20:09:04.4644787Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.60" +version = "0.1.61" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 4a1489b5070cbd34ab258cfaba6f6c9afb65f3e7 Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan <84002867+viswa-uipath@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:33:51 +0530 Subject: [PATCH 089/191] feat[PLT-105095]: changes for governance (#1700) --- packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/adapters/__init__.py | 35 ++ .../src/uipath/core/adapters/base.py | 116 +++++ .../src/uipath/core/adapters/evaluator.py | 99 ++++ .../src/uipath/core/adapters/registry.py | 176 +++++++ .../src/uipath/core/governance/__init__.py | 37 ++ .../src/uipath/core/governance/config.py | 34 ++ .../src/uipath/core/governance/exceptions.py | 114 ++++ .../src/uipath/core/governance/models.py | 68 +++ .../uipath-core/tests/adapters/__init__.py | 0 .../uipath-core/tests/adapters/test_base.py | 163 ++++++ .../tests/adapters/test_evaluator.py | 104 ++++ .../tests/adapters/test_registry.py | 492 ++++++++++++++++++ .../uipath-core/tests/governance/__init__.py | 0 .../tests/governance/test_config.py | 54 ++ .../tests/governance/test_exceptions.py | 205 ++++++++ packages/uipath-core/uv.lock | 4 +- packages/uipath-platform/uv.lock | 4 +- packages/uipath/uv.lock | 4 +- 19 files changed, 1704 insertions(+), 7 deletions(-) create mode 100644 packages/uipath-core/src/uipath/core/adapters/__init__.py create mode 100644 packages/uipath-core/src/uipath/core/adapters/base.py create mode 100644 packages/uipath-core/src/uipath/core/adapters/evaluator.py create mode 100644 packages/uipath-core/src/uipath/core/adapters/registry.py create mode 100644 packages/uipath-core/src/uipath/core/governance/__init__.py create mode 100644 packages/uipath-core/src/uipath/core/governance/config.py create mode 100644 packages/uipath-core/src/uipath/core/governance/exceptions.py create mode 100644 packages/uipath-core/src/uipath/core/governance/models.py create mode 100644 packages/uipath-core/tests/adapters/__init__.py create mode 100644 packages/uipath-core/tests/adapters/test_base.py create mode 100644 packages/uipath-core/tests/adapters/test_evaluator.py create mode 100644 packages/uipath-core/tests/adapters/test_registry.py create mode 100644 packages/uipath-core/tests/governance/__init__.py create mode 100644 packages/uipath-core/tests/governance/test_config.py create mode 100644 packages/uipath-core/tests/governance/test_exceptions.py diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index c07e4c3b7..eef3ab27e 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.17" +version = "0.5.18" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/adapters/__init__.py b/packages/uipath-core/src/uipath/core/adapters/__init__.py new file mode 100644 index 000000000..5906b1b39 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/adapters/__init__.py @@ -0,0 +1,35 @@ +"""Generic adapter contracts for framework integrations. + +This package holds only the abstract contracts — concrete adapter +implementations live in framework-specific plugin packages (e.g. +``uipath-langchain``, ``uipath-openai``) that target the framework they +integrate with. Plugin packages register their concrete adapters with +the global :class:`AdapterRegistry` via the +``uipath.governance.adapters`` entry-point group. + +Public surface: + +- :class:`BaseAdapter` – abstract base every adapter inherits from. +- :class:`GovernedAgentBase` – proxy base for governed agent wrappers. +- :class:`EvaluatorProtocol` – structural protocol the adapter expects + from any policy evaluator. +- :class:`AdapterRegistry` – ordered list of adapters that resolves + the first match for a given agent. +""" + +from .base import BaseAdapter, GovernedAgentBase +from .evaluator import EvaluatorProtocol +from .registry import ( + AdapterRegistry, + get_adapter_registry, + reset_adapter_registry, +) + +__all__ = [ + "BaseAdapter", + "GovernedAgentBase", + "EvaluatorProtocol", + "AdapterRegistry", + "get_adapter_registry", + "reset_adapter_registry", +] diff --git a/packages/uipath-core/src/uipath/core/adapters/base.py b/packages/uipath-core/src/uipath/core/adapters/base.py new file mode 100644 index 000000000..3afaad6a7 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/adapters/base.py @@ -0,0 +1,116 @@ +"""Base adapter contracts for framework-specific integrations. + +An adapter's job: + +1. Detect whether it can handle a given agent object. +2. Attach hooks to that agent (framework-specific). +3. Publish events to a policy evaluator when those hooks fire. + +The evaluator subscribes to events and runs policy checks; it never +knows or cares which adapter fired the event. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any +from uuid import uuid4 + +from .evaluator import EvaluatorProtocol + + +class BaseAdapter(ABC): + """Base class for framework-specific governance adapters.""" + + #: Higher value = more specific = inserted earlier in the registry. + #: Plugin authors should set this above ``0`` on adapters that target + #: a narrower agent type than another already-registered adapter, so + #: the specific one wins ``can_handle`` resolution regardless of the + #: order in which plugins happen to be imported. Among adapters with + #: the same priority, registration order is preserved (stable). + priority: int = 0 + + #: Set to True on a catch-all adapter that should always sort last in + #: the registry. The registry uses this flag (not the class name or + #: :attr:`priority`) to keep the fallback in last position when new + #: adapters register. + is_fallback: bool = False + + @property + def name(self) -> str: + """Return adapter name for logging.""" + return self.__class__.__name__ + + @abstractmethod + def can_handle(self, agent: Any) -> bool: + """Return True if this adapter knows how to hook into this agent type.""" + + @abstractmethod + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + """Attach governance hooks to the agent. + + Args: + agent: The agent to govern. + agent_id: Unique identifier for the agent. + session_id: Session identifier for tracing. + evaluator: Policy evaluator implementing + :class:`EvaluatorProtocol`. + + Returns: + A governed proxy (or the original agent with hooks installed). + """ + + def detach(self, governed: Any) -> Any: + """Detach governance and return the original agent. + + Default implementation uses the public :attr:`GovernedAgentBase.unwrapped` + contract; non-proxy adapters that return the original agent from + :meth:`attach` get back ``governed`` unchanged. + """ + return getattr(governed, "unwrapped", governed) + + def _generate_trace_id(self) -> str: + """Generate a trace ID for governance events.""" + return str(uuid4()) + + +class GovernedAgentBase: + """Base class for governed agent proxies. + + Provides common functionality for all governed agents: + + - Stores reference to original agent + - Forwards unknown attributes to original agent + - Tracks governance metadata + """ + + def __init__( + self, + agent: Any, + adapter: BaseAdapter, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> None: + """Initialize with the wrapped agent and governance metadata.""" + self._agent = agent + self._adapter = adapter + self._agent_id = agent_id + self._session_id = session_id + self._evaluator = evaluator + self._trace_id = adapter._generate_trace_id() + + @property + def unwrapped(self) -> Any: + """Get the original unwrapped agent.""" + return self._agent + + def __getattr__(self, name: str) -> Any: + """Forward attribute access to the original agent.""" + return getattr(self._agent, name) diff --git a/packages/uipath-core/src/uipath/core/adapters/evaluator.py b/packages/uipath-core/src/uipath/core/adapters/evaluator.py new file mode 100644 index 000000000..ee5b92dad --- /dev/null +++ b/packages/uipath-core/src/uipath/core/adapters/evaluator.py @@ -0,0 +1,99 @@ +"""Structural contract for the policy evaluator an adapter talks to. + +Framework adapters call into a policy evaluator at each lifecycle hook. +Concrete evaluator implementations (the native runtime evaluator, a +Microsoft AGT bridge, a composite, …) live in packages outside +``uipath-core`` — adapters depend only on this structural protocol so +they can be swapped against any of them without code change. + +``EvaluatorProtocol`` is a :class:`typing.Protocol` so any class whose +methods match the signatures below satisfies the contract without +inheritance. +""" + +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + + +@runtime_checkable +class EvaluatorProtocol(Protocol): + """Structural protocol an adapter expects from a policy evaluator. + + Return types are intentionally :class:`typing.Any`: the concrete + audit record shape lives in the plugin package that owns the + evaluator and the policy model. Adapters in that package cast the + return value back to the concrete type they know. + """ + + def evaluate_before_agent( + self, + agent_input: str, + agent_name: str, + runtime_id: str, + trace_id: str, + model_name: str = "", + **kwargs: Any, + ) -> Any: + """Evaluate BEFORE_AGENT rules.""" + ... + + def evaluate_after_agent( + self, + agent_output: str, + agent_name: str, + runtime_id: str, + trace_id: str, + **kwargs: Any, + ) -> Any: + """Evaluate AFTER_AGENT rules.""" + ... + + def evaluate_before_model( + self, + model_input: str, + agent_name: str, + runtime_id: str, + trace_id: str, + messages: list[dict[str, Any]] | None = None, + model_name: str = "", + **kwargs: Any, + ) -> Any: + """Evaluate BEFORE_MODEL rules.""" + ... + + def evaluate_after_model( + self, + model_output: str, + agent_name: str, + runtime_id: str, + trace_id: str, + **kwargs: Any, + ) -> Any: + """Evaluate AFTER_MODEL rules.""" + ... + + def evaluate_tool_call( + self, + tool_name: str, + tool_args: dict[str, Any], + agent_name: str, + runtime_id: str, + trace_id: str, + session_state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Any: + """Evaluate TOOL_CALL rules.""" + ... + + def evaluate_after_tool( + self, + tool_name: str, + tool_result: str, + agent_name: str, + runtime_id: str, + trace_id: str, + **kwargs: Any, + ) -> Any: + """Evaluate AFTER_TOOL rules.""" + ... diff --git a/packages/uipath-core/src/uipath/core/adapters/registry.py b/packages/uipath-core/src/uipath/core/adapters/registry.py new file mode 100644 index 000000000..adebe780a --- /dev/null +++ b/packages/uipath-core/src/uipath/core/adapters/registry.py @@ -0,0 +1,176 @@ +"""Ordered registry of framework adapters. + +The registry is a pure, implementation-agnostic container — it does +**not** know about any concrete adapter. Plugin packages (e.g. +``uipath-langchain``) populate it by either: + +1. Declaring a ``uipath.governance.adapters`` entry point whose value + is a zero-arg callable that calls :meth:`AdapterRegistry.register`. + These are auto-discovered on first call to + :func:`get_adapter_registry`. +2. Calling :meth:`AdapterRegistry.register` directly at import time + (e.g. side-effect on importing the plugin's governance submodule). + +Adapters are checked in priority order (highest first): more specific +adapters get a higher :attr:`BaseAdapter.priority` so they win +``can_handle`` resolution over generic ones, regardless of the order in +which plugin packages happen to be imported. Among adapters with the +same priority, registration order is preserved. Adapters with +``is_fallback=True`` sort last when registered without an explicit +``position`` — passing ``position`` to :meth:`AdapterRegistry.register` +is an escape hatch that bypasses both priority and fallback ordering, +so callers using it own the resulting list order. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from .base import BaseAdapter + +logger = logging.getLogger(__name__) + +ENTRY_POINT_GROUP = "uipath.governance.adapters" + + +class AdapterRegistry: + """Ordered list of adapters; resolves the first match for an agent.""" + + def __init__(self) -> None: + """Initialize an empty registry.""" + self._adapters: list[BaseAdapter] = [] + + def register(self, adapter: BaseAdapter, position: int | None = None) -> None: + """Register an adapter. + + Args: + adapter: The adapter to register. + position: Explicit insertion index (``0`` = highest priority) + that bypasses both priority-based ordering AND fallback + semantics — the adapter is inserted blindly at the given + index, so callers using ``position`` are responsible for + not placing a fallback before a specific adapter (or a + specific adapter after an existing fallback). When + ``None`` the adapter is inserted by + :attr:`BaseAdapter.priority` (higher first, stable on + ties) and before any adapter marked + :attr:`BaseAdapter.is_fallback`; adapters whose own + ``is_fallback`` is set are appended last. + """ + if position is not None: + self._adapters.insert(position, adapter) + elif adapter.is_fallback: + self._adapters.append(adapter) + else: + insert_at = len(self._adapters) + for i, existing in enumerate(self._adapters): + if existing.is_fallback or existing.priority < adapter.priority: + insert_at = i + break + self._adapters.insert(insert_at, adapter) + logger.debug("Registered adapter: %s", adapter.name) + + def resolve(self, agent: Any) -> BaseAdapter | None: + """Return the first adapter that can handle ``agent`` (or ``None``).""" + for adapter in self._adapters: + try: + if adapter.can_handle(agent): + logger.debug( + "AdapterRegistry: %s -> %s", + type(agent).__name__, + adapter.name, + ) + return adapter + except Exception as exc: + logger.warning( + "Adapter %s.can_handle() failed: %s", + adapter.name, + exc, + ) + continue + return None + + def get_all(self) -> list[BaseAdapter]: + """Return a copy of the registered adapters in priority order.""" + return self._adapters.copy() + + def clear(self) -> None: + """Remove all registered adapters.""" + self._adapters.clear() + + +_registry: AdapterRegistry | None = None + + +def _discover_entry_point_adapters() -> None: + """Load every adapter advertised under the ``uipath.governance.adapters`` group. + + Each entry-point value must be a zero-arg callable (typically a + ``register_*`` function in the plugin package) that calls + :meth:`AdapterRegistry.register`. A failure to load or invoke any + one entry point is logged and skipped — a single broken plugin + must never block governance startup. + """ + try: + from importlib.metadata import entry_points + except ImportError: # pragma: no cover - importlib.metadata is stdlib in py3.11+ + return + + try: + eps = entry_points(group=ENTRY_POINT_GROUP) + except Exception as exc: # noqa: BLE001 - discovery failures must never raise + logger.debug("Adapter entry-point discovery failed: %s", exc, exc_info=True) + return + + for ep in eps: + try: + registrar = ep.load() + except Exception as exc: # noqa: BLE001 - one broken plugin must not block others + logger.debug( + "Failed to load governance adapter entry point '%s' (%s): %s", + ep.name, + ep.value, + exc, + exc_info=True, + ) + continue + if not callable(registrar): + logger.warning( + "Governance adapter entry point '%s' is not callable: %r", + ep.name, + registrar, + ) + continue + try: + registrar() + except Exception as exc: # noqa: BLE001 - one broken plugin must not block others + logger.debug( + "Governance adapter '%s' register call failed: %s", + ep.name, + exc, + exc_info=True, + ) + + +def get_adapter_registry() -> AdapterRegistry: + """Return the process-wide adapter registry singleton. + + On first call, discovers and registers every adapter declared under + the ``uipath.governance.adapters`` entry-point group, so framework + SDKs (``uipath-langchain``, ``uipath-openai``, …) just need to be + installed — no explicit import is required. + """ + global _registry + if _registry is None: + _registry = AdapterRegistry() + _discover_entry_point_adapters() + return _registry + + +def reset_adapter_registry() -> None: + """Drop the singleton registry (intended for tests).""" + global _registry + if _registry is not None: + _registry.clear() + _registry = None diff --git a/packages/uipath-core/src/uipath/core/governance/__init__.py b/packages/uipath-core/src/uipath/core/governance/__init__.py new file mode 100644 index 000000000..dd32228ed --- /dev/null +++ b/packages/uipath-core/src/uipath/core/governance/__init__.py @@ -0,0 +1,37 @@ +"""UiPath governance shared contracts. + +Evaluator-agnostic types every governance consumer references — +adapter packages (``uipath-langchain``, ``uipath-openai``, …), the +runtime layer (``uipath.runtime.governance``), and customer code that +catches :class:`GovernanceBlockException`. The full runtime / audit / +native-evaluator implementation lives in ``uipath.runtime.governance``; +this core surface is just the contracts. +""" + +from .config import ( + GOVERNANCE_FEATURE_FLAG, + is_governance_enabled, +) +from .exceptions import ( + GovernanceBlockException, + GovernanceConfigError, + GovernanceViolation, + Severity, +) +from .models import Action, AuditRecord, LifecycleHook, RuleEvaluation + +__all__ = [ + # Output models (cross adapter boundary) + "Action", + "AuditRecord", + "LifecycleHook", + "RuleEvaluation", + # Config + "GOVERNANCE_FEATURE_FLAG", + "is_governance_enabled", + # Exceptions + "GovernanceBlockException", + "GovernanceConfigError", + "GovernanceViolation", + "Severity", +] diff --git a/packages/uipath-core/src/uipath/core/governance/config.py b/packages/uipath-core/src/uipath/core/governance/config.py new file mode 100644 index 000000000..0bb0a6042 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/governance/config.py @@ -0,0 +1,34 @@ +"""Governance configuration. + +Process-level feature-flag gate that decides whether the Python +governance checker runs at all. Enforcement mode is per-policy and +lives in the runtime package alongside the ``/runtime/policy`` client. +""" + +from __future__ import annotations + +from uipath.core.feature_flags import FeatureFlags + +# Feature flag name controlling whether governance runs. +# Mirrors the gate in ``uipath-runtime`` so the platform-injection path +# and direct callers (agents constructing an evaluator themselves) +# honour the same toggle. +GOVERNANCE_FEATURE_FLAG = "EnablePythonGovernanceChecker" + + +def is_governance_enabled() -> bool: + """Return whether the ``EnablePythonGovernanceChecker`` flag is enabled. + + Governance is **off by default** — the flag must be explicitly set + to ``true`` (programmatically via the ``FeatureFlags`` registry, or + via the ``UIPATH_FEATURE_EnablePythonGovernanceChecker`` env var) + for this function to return ``True``. + + Resolution order: + + 1. :meth:`uipath.core.feature_flags.FeatureFlagsManager.is_flag_enabled` - + the in-process programmatic registry (typically populated from + gitops) and its own ``UIPATH_FEATURE_`` env-var fallback. + 2. Default ``False`` (governance disabled). + """ + return FeatureFlags.is_flag_enabled(GOVERNANCE_FEATURE_FLAG, default=False) diff --git a/packages/uipath-core/src/uipath/core/governance/exceptions.py b/packages/uipath-core/src/uipath/core/governance/exceptions.py new file mode 100644 index 000000000..48f4b178a --- /dev/null +++ b/packages/uipath-core/src/uipath/core/governance/exceptions.py @@ -0,0 +1,114 @@ +"""Governance exception types.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from uipath.core.governance.models import AuditRecord + +_DEFAULT_RULE_ID = "POLICY" +_DEFAULT_RULE_NAME = "Governance Policy" +_MSG_PREFIX = "[Governance Policy Violation]" + + +class Severity(str, Enum): + """Severity classification for a governance violation.""" + + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +@dataclass +class GovernanceViolation: + """Details of a governance rule violation.""" + + rule_id: str + rule_name: str + detail: str + severity: Severity = Severity.HIGH + + +def _format_violation_message(rule_id: str, rule_name: str, detail: str) -> str: + return f"{_MSG_PREFIX} {rule_name} ({rule_id}): {detail}" + + +class GovernanceBlockException(Exception): + """Raised when a governance policy blocks an operation. + + This exception indicates that the AI agent's operation was blocked by + a configured governance policy, not an unexpected system error. + + Prefer the classmethod constructors (:meth:`from_violation`, + :meth:`from_audit_record`) when you have structured context — the + default constructor is for raw-message use only. + """ + + # Error code for Orchestrator categorization + error_code: str = "GOVERNANCE_POLICY_VIOLATION" + + def __init__( + self, + message: str | None = None, + *, + violation: GovernanceViolation | None = None, + audit_record: AuditRecord | None = None, + rule_id: str = _DEFAULT_RULE_ID, + rule_name: str = _DEFAULT_RULE_NAME, + ) -> None: + """Construct from a pre-formatted message and optional structured context. + + Most callers should use :meth:`from_violation` or + :meth:`from_audit_record` instead of passing structured context + directly. + """ + self.violation = violation + self.audit_record = audit_record + self.rule_id = rule_id + self.rule_name = rule_name + super().__init__( + message or f"{_MSG_PREFIX} Operation blocked by governance policy." + ) + + @classmethod + def from_violation( + cls, violation: GovernanceViolation + ) -> "GovernanceBlockException": + """Build from a structured :class:`GovernanceViolation`.""" + return cls( + message=_format_violation_message( + violation.rule_id, violation.rule_name, violation.detail + ), + violation=violation, + rule_id=violation.rule_id, + rule_name=violation.rule_name, + ) + + @classmethod + def from_audit_record(cls, audit_record: AuditRecord) -> "GovernanceBlockException": + """Build from an :class:`AuditRecord` — first matched rule wins.""" + matched_rules = [e for e in audit_record.evaluations if e.matched] + if matched_rules: + rule = matched_rules[0] + message = _format_violation_message( + rule.rule_id, rule.rule_name, rule.detail or "Policy violation detected" + ) + return cls( + message=message, + audit_record=audit_record, + rule_id=rule.rule_id, + rule_name=rule.rule_name, + ) + return cls( + message=( + f"{_MSG_PREFIX} Operation blocked. " + f"Rules evaluated: {len(audit_record.evaluations)}" + ), + audit_record=audit_record, + ) + + +class GovernanceConfigError(RuntimeError): + """Raised when governance is misconfigured.""" diff --git a/packages/uipath-core/src/uipath/core/governance/models.py b/packages/uipath-core/src/uipath/core/governance/models.py new file mode 100644 index 000000000..6b3993639 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/governance/models.py @@ -0,0 +1,68 @@ +"""Shared governance output types. + +These dataclasses cross the adapter boundary — every evaluator +implementation (native, AGT, composite, …) produces them, and every +adapter consumes them. They are kept free of policy-input concepts +(``Rule``/``Check``/``Condition``) so the adapter packages don't +inherit the native policy model. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Any + + +class Action(str, Enum): + """Actions that can be taken when a rule matches.""" + + ALLOW = "allow" + DENY = "deny" + AUDIT = "audit" + ESCALATE = "escalate" + + +class LifecycleHook(str, Enum): + """Agent lifecycle hooks where rules can be evaluated.""" + + BEFORE_AGENT = "before_agent" + AFTER_AGENT = "after_agent" + BEFORE_MODEL = "before_model" + AFTER_MODEL = "after_model" + TOOL_CALL = "tool_call" + AFTER_TOOL = "after_tool" + + +@dataclass +class RuleEvaluation: + """Result of evaluating a single rule.""" + + rule_id: str + rule_name: str + matched: bool + detail: str = "" + pack_name: str = "" + action: Action = Action.ALLOW + description: str = "" + check_results: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass +class AuditRecord: + """Complete audit record for a governance evaluation.""" + + timestamp: datetime + agent_name: str + runtime_id: str + trace_id: str + hook: LifecycleHook + evaluations: list[RuleEvaluation] + final_action: Action + metadata: dict[str, Any] = field(default_factory=dict) + rules_matched: int = field(init=False) + + def __post_init__(self) -> None: + """Derive rules_matched from the evaluations list.""" + self.rules_matched = sum(1 for e in self.evaluations if e.matched) diff --git a/packages/uipath-core/tests/adapters/__init__.py b/packages/uipath-core/tests/adapters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/uipath-core/tests/adapters/test_base.py b/packages/uipath-core/tests/adapters/test_base.py new file mode 100644 index 000000000..9be6346ed --- /dev/null +++ b/packages/uipath-core/tests/adapters/test_base.py @@ -0,0 +1,163 @@ +"""Tests for BaseAdapter defaults and GovernedAgentBase proxy behavior.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from uipath.core.adapters import BaseAdapter, EvaluatorProtocol +from uipath.core.adapters.base import GovernedAgentBase + + +class _StubEvaluator: + """No-op evaluator that structurally matches EvaluatorProtocol.""" + + def evaluate_before_agent(self, *args: Any, **kwargs: Any) -> Any: + return None + + def evaluate_after_agent(self, *args: Any, **kwargs: Any) -> Any: + return None + + def evaluate_before_model(self, *args: Any, **kwargs: Any) -> Any: + return None + + def evaluate_after_model(self, *args: Any, **kwargs: Any) -> Any: + return None + + def evaluate_tool_call(self, *args: Any, **kwargs: Any) -> Any: + return None + + def evaluate_after_tool(self, *args: Any, **kwargs: Any) -> Any: + return None + + +class _MinimalAdapter(BaseAdapter): + """Concrete adapter that does NOT override ``name`` — exercises the default.""" + + def can_handle(self, agent: Any) -> bool: + return True + + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + return agent + + +class _Agent: + """Simple stand-in for a framework agent with one attribute and one method.""" + + foo = "bar" + + def greet(self) -> str: + return "hello" + + +# --------------------------------------------------------------------------- +# BaseAdapter defaults +# --------------------------------------------------------------------------- + + +def test_default_name_is_class_name(): + """The default ``name`` property returns the class name.""" + assert _MinimalAdapter().name == "_MinimalAdapter" + + +def test_detach_returns_unwrapped_when_present(): + """``detach`` honours the ``unwrapped`` contract on a governed proxy.""" + adapter = _MinimalAdapter() + original = object() + + class _Proxy: + unwrapped = original + + assert adapter.detach(_Proxy()) is original + + +def test_detach_returns_input_when_no_unwrapped_attribute(): + """For non-proxy adapters, ``detach`` returns the input unchanged.""" + adapter = _MinimalAdapter() + raw = object() + assert adapter.detach(raw) is raw + + +def test_generate_trace_id_returns_unique_uuid_string(): + """``_generate_trace_id`` returns a string UUID; consecutive calls differ.""" + adapter = _MinimalAdapter() + a = adapter._generate_trace_id() + b = adapter._generate_trace_id() + assert isinstance(a, str) + assert a != b + assert len(a) == 36 # canonical UUID4 form: 32 hex + 4 dashes + + +# --------------------------------------------------------------------------- +# GovernedAgentBase proxy +# --------------------------------------------------------------------------- + + +def test_governed_agent_base_stores_metadata_and_generates_trace_id(): + """Constructor wires every governance field and pulls a trace id from the adapter.""" + agent = _Agent() + adapter = _MinimalAdapter() + evaluator = _StubEvaluator() + + governed = GovernedAgentBase( + agent=agent, + adapter=adapter, + agent_id="agent-123", + session_id="session-abc", + evaluator=evaluator, + ) + + assert governed._agent is agent + assert governed._adapter is adapter + assert governed._agent_id == "agent-123" + assert governed._session_id == "session-abc" + assert governed._evaluator is evaluator + assert isinstance(governed._trace_id, str) + assert len(governed._trace_id) == 36 + + +def test_governed_agent_base_unwrapped_returns_original_agent(): + agent = _Agent() + governed = GovernedAgentBase( + agent=agent, + adapter=_MinimalAdapter(), + agent_id="a", + session_id="s", + evaluator=_StubEvaluator(), + ) + assert governed.unwrapped is agent + + +def test_governed_agent_base_forwards_attribute_access_to_agent(): + """Unknown attributes fall through to the wrapped agent via __getattr__.""" + governed = GovernedAgentBase( + agent=_Agent(), + adapter=_MinimalAdapter(), + agent_id="a", + session_id="s", + evaluator=_StubEvaluator(), + ) + + assert governed.foo == "bar" + assert governed.greet() == "hello" + + +def test_governed_agent_base_attribute_miss_raises_attribute_error(): + """If the wrapped agent also lacks the attribute, AttributeError surfaces.""" + governed = GovernedAgentBase( + agent=_Agent(), + adapter=_MinimalAdapter(), + agent_id="a", + session_id="s", + evaluator=_StubEvaluator(), + ) + + with pytest.raises(AttributeError): + _ = governed.does_not_exist diff --git a/packages/uipath-core/tests/adapters/test_evaluator.py b/packages/uipath-core/tests/adapters/test_evaluator.py new file mode 100644 index 000000000..5c9e5c9e5 --- /dev/null +++ b/packages/uipath-core/tests/adapters/test_evaluator.py @@ -0,0 +1,104 @@ +"""Tests for EvaluatorProtocol. + +The protocol is a structural type. These tests verify two things: + +1. A class whose method shapes match the protocol passes ``isinstance`` + against the ``runtime_checkable`` Protocol. +2. Subclassing the Protocol and calling ``super().`` actually + executes the stub bodies — this both documents that the stubs are + safely callable (they return ``None``) and brings the contract module + to full line coverage. +""" + +from __future__ import annotations + +from typing import Any + +from uipath.core.adapters import EvaluatorProtocol + + +class _MissingMethodEvaluator: + """Only implements one method — fails the structural check.""" + + def evaluate_before_agent(self, *args: Any, **kwargs: Any) -> Any: + return None + + +class _CompleteEvaluator: + """All six methods present with the expected names — passes ``isinstance``.""" + + def evaluate_before_agent(self, *args: Any, **kwargs: Any) -> Any: + return "before-agent" + + def evaluate_after_agent(self, *args: Any, **kwargs: Any) -> Any: + return "after-agent" + + def evaluate_before_model(self, *args: Any, **kwargs: Any) -> Any: + return "before-model" + + def evaluate_after_model(self, *args: Any, **kwargs: Any) -> Any: + return "after-model" + + def evaluate_tool_call(self, *args: Any, **kwargs: Any) -> Any: + return "tool-call" + + def evaluate_after_tool(self, *args: Any, **kwargs: Any) -> Any: + return "after-tool" + + +class _ProtocolSubclass(EvaluatorProtocol): + """Subclass that delegates to ``super()`` — exercises the stub bodies. + + Each override calls ``super().(...)`` so the ``...`` body of + the Protocol method actually executes (returns ``None``). + """ + + def evaluate_before_agent(self, *args: Any, **kwargs: Any) -> Any: + return super().evaluate_before_agent(*args, **kwargs) # type: ignore[safe-super] + + def evaluate_after_agent(self, *args: Any, **kwargs: Any) -> Any: + return super().evaluate_after_agent(*args, **kwargs) # type: ignore[safe-super] + + def evaluate_before_model(self, *args: Any, **kwargs: Any) -> Any: + return super().evaluate_before_model(*args, **kwargs) # type: ignore[safe-super] + + def evaluate_after_model(self, *args: Any, **kwargs: Any) -> Any: + return super().evaluate_after_model(*args, **kwargs) # type: ignore[safe-super] + + def evaluate_tool_call(self, *args: Any, **kwargs: Any) -> Any: + return super().evaluate_tool_call(*args, **kwargs) # type: ignore[safe-super] + + def evaluate_after_tool(self, *args: Any, **kwargs: Any) -> Any: + return super().evaluate_after_tool(*args, **kwargs) # type: ignore[safe-super] + + +# --------------------------------------------------------------------------- +# Structural conformance +# --------------------------------------------------------------------------- + + +def test_complete_evaluator_is_recognized_by_runtime_check(): + """A class with all six methods passes ``isinstance`` against the protocol.""" + assert isinstance(_CompleteEvaluator(), EvaluatorProtocol) + + +def test_partial_evaluator_is_rejected_by_runtime_check(): + """A class missing methods does NOT pass the structural check.""" + assert not isinstance(_MissingMethodEvaluator(), EvaluatorProtocol) + + +# --------------------------------------------------------------------------- +# Stub-body execution (line coverage for the ``...`` placeholders) +# --------------------------------------------------------------------------- + + +def test_protocol_subclass_methods_execute_stub_bodies(): + """Calling each method via ``super()`` executes the stub body and returns None.""" + e = _ProtocolSubclass() + + assert e.evaluate_before_agent("input", "agent", "rt", "trace") is None + assert e.evaluate_after_agent("output", "agent", "rt", "trace") is None + assert e.evaluate_before_model("input", "agent", "rt", "trace") is None + assert e.evaluate_after_model("output", "agent", "rt", "trace") is None + assert e.evaluate_tool_call("tool", {"arg": 1}, "agent", "rt", "trace") is None + assert e.evaluate_after_tool("tool", "result", "agent", "rt", "trace") is None diff --git a/packages/uipath-core/tests/adapters/test_registry.py b/packages/uipath-core/tests/adapters/test_registry.py new file mode 100644 index 000000000..b16b29b1e --- /dev/null +++ b/packages/uipath-core/tests/adapters/test_registry.py @@ -0,0 +1,492 @@ +"""Tests for AdapterRegistry — ordering, resolution, entry-point discovery.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from uipath.core.adapters import BaseAdapter, EvaluatorProtocol +from uipath.core.adapters.registry import ( + AdapterRegistry, + _discover_entry_point_adapters, + get_adapter_registry, + reset_adapter_registry, +) + +# --------------------------------------------------------------------------- +# Test adapters +# --------------------------------------------------------------------------- + + +class _SpecificAdapter(BaseAdapter): + """Matches only objects with a ``__specific__`` marker.""" + + @property + def name(self) -> str: + return "specific" + + def can_handle(self, agent: Any) -> bool: + return hasattr(agent, "__specific__") + + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + return agent + + +class _FallbackAdapter(BaseAdapter): + """Matches anything — must always sort last.""" + + is_fallback = True + + @property + def name(self) -> str: + return "fallback" + + def can_handle(self, agent: Any) -> bool: + return True + + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + return agent + + +class _SecondaryAdapter(BaseAdapter): + """Another specific adapter, used to test ordering between two specifics.""" + + @property + def name(self) -> str: + return "secondary" + + def can_handle(self, agent: Any) -> bool: + return hasattr(agent, "__secondary__") + + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + return agent + + +class _HighPriorityAdapter(BaseAdapter): + """Specific adapter with an elevated priority.""" + + priority = 100 + + @property + def name(self) -> str: + return "high" + + def can_handle(self, agent: Any) -> bool: + return True + + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + return agent + + +class _LowPriorityAdapter(BaseAdapter): + """Generic adapter that should yield to higher-priority specifics.""" + + priority = -10 + + @property + def name(self) -> str: + return "low" + + def can_handle(self, agent: Any) -> bool: + return True + + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + return agent + + +class _BrokenAdapter(BaseAdapter): + """``can_handle`` raises — must be skipped, not crash resolution.""" + + @property + def name(self) -> str: + return "broken" + + def can_handle(self, agent: Any) -> bool: + raise RuntimeError("can_handle exploded") + + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + raise RuntimeError("attach exploded") + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _isolate_global_registry(): + """Each test starts with no singleton registry.""" + reset_adapter_registry() + yield + reset_adapter_registry() + + +# --------------------------------------------------------------------------- +# register / resolve / get_all / clear +# --------------------------------------------------------------------------- + + +def test_empty_registry_resolves_to_none(): + reg = AdapterRegistry() + assert reg.resolve(object()) is None + assert reg.get_all() == [] + + +def test_register_appends_in_order(): + reg = AdapterRegistry() + a, b = _SpecificAdapter(), _SecondaryAdapter() + reg.register(a) + reg.register(b) + assert reg.get_all() == [a, b] + + +def test_resolve_returns_first_matching_adapter(): + reg = AdapterRegistry() + reg.register(_SpecificAdapter()) + reg.register(_SecondaryAdapter()) + + agent = MagicMock() + agent.__secondary__ = True # only secondary should match + resolved = reg.resolve(agent) + assert resolved is not None + assert resolved.name == "secondary" + + +def test_resolve_skips_broken_can_handle_and_continues(): + """A can_handle() that raises must not break the whole resolve loop.""" + reg = AdapterRegistry() + reg.register(_BrokenAdapter()) + reg.register(_SpecificAdapter()) + + agent = MagicMock() + agent.__specific__ = True + resolved = reg.resolve(agent) + assert resolved is not None + assert resolved.name == "specific" + + +def test_register_position_inserts_at_index(): + reg = AdapterRegistry() + a, b, c = _SpecificAdapter(), _SecondaryAdapter(), _SpecificAdapter() + reg.register(a) + reg.register(b) + reg.register(c, position=0) # c jumps to head + assert reg.get_all()[0] is c + assert reg.get_all()[1:] == [a, b] + + +def test_higher_priority_adapter_inserted_before_lower_priority(): + """A specific (higher-priority) adapter must sort before a generic one + even when the generic one was registered first.""" + reg = AdapterRegistry() + generic = _LowPriorityAdapter() + specific = _HighPriorityAdapter() + reg.register(generic) + reg.register(specific) # registered later, but higher priority + + adapters = reg.get_all() + assert adapters[0] is specific + assert adapters[1] is generic + + +def test_same_priority_preserves_registration_order(): + """Adapters with equal priority should fall back to insertion order.""" + reg = AdapterRegistry() + a, b = _SpecificAdapter(), _SecondaryAdapter() # both priority=0 + reg.register(a) + reg.register(b) + assert reg.get_all() == [a, b] + + +def test_higher_priority_adapter_inserted_before_fallback(): + """High-priority adapter goes in front of an already-registered fallback.""" + reg = AdapterRegistry() + fallback = _FallbackAdapter() + reg.register(fallback) + reg.register(_HighPriorityAdapter()) + + adapters = reg.get_all() + assert adapters[0].name == "high" + assert adapters[-1] is fallback + + +def test_lower_priority_adapter_inserted_before_fallback_after_specifics(): + """Negative-priority adapter sorts after default-priority specifics but + still before the fallback.""" + reg = AdapterRegistry() + reg.register(_SpecificAdapter()) # priority=0 + reg.register(_FallbackAdapter()) + reg.register(_LowPriorityAdapter()) # priority=-10 + + adapters = reg.get_all() + assert adapters[0].name == "specific" + assert adapters[1].name == "low" + assert adapters[-1].name == "fallback" + + +def test_priority_overrides_registration_order_in_resolve(): + """Resolution must follow priority ordering, not registration order.""" + reg = AdapterRegistry() + reg.register(_LowPriorityAdapter()) # both adapters match every agent, + reg.register(_HighPriorityAdapter()) # so priority decides which wins. + + resolved = reg.resolve(object()) + assert resolved is not None + assert resolved.name == "high" + + +def test_fallback_stays_last_when_new_adapter_registered(): + """When the last entry has ``is_fallback`` set, new adapters insert before it.""" + reg = AdapterRegistry() + fallback = _FallbackAdapter() + reg.register(fallback) + reg.register(_SpecificAdapter()) # this should insert BEFORE fallback + + adapters = reg.get_all() + assert adapters[-1] is fallback + assert adapters[0].name == "specific" + + +def test_fallback_resolves_only_when_no_specific_matches(): + reg = AdapterRegistry() + reg.register(_SpecificAdapter()) + reg.register(_FallbackAdapter()) + + # Agent without the __specific__ marker → fallback wins. + resolved = reg.resolve(object()) + assert resolved is not None + assert resolved.name == "fallback" + + +def test_clear_removes_all_adapters(): + reg = AdapterRegistry() + reg.register(_SpecificAdapter()) + reg.register(_SecondaryAdapter()) + reg.clear() + assert reg.get_all() == [] + assert reg.resolve(object()) is None + + +def test_get_all_returns_copy_not_internal_list(): + """Callers must not be able to mutate the registry through get_all().""" + reg = AdapterRegistry() + reg.register(_SpecificAdapter()) + snapshot = reg.get_all() + snapshot.clear() + assert len(reg.get_all()) == 1 # unaffected + + +# --------------------------------------------------------------------------- +# Singleton + entry-point discovery +# --------------------------------------------------------------------------- + + +def test_get_adapter_registry_returns_singleton(): + reg1 = get_adapter_registry() + reg2 = get_adapter_registry() + assert reg1 is reg2 + + +def test_reset_adapter_registry_drops_singleton(): + first = get_adapter_registry() + reset_adapter_registry() + second = get_adapter_registry() + assert first is not second + + +def test_entry_point_discovery_invokes_registrars(monkeypatch): + """Each entry-point's zero-arg callable must be loaded and called.""" + called: list[str] = [] + + def make_registrar(name: str): + def _register() -> None: + called.append(name) + + return _register + + ep_a = MagicMock() + ep_a.name = "a" + ep_a.value = "pkg_a:register" + ep_a.load.return_value = make_registrar("a") + + ep_b = MagicMock() + ep_b.name = "b" + ep_b.value = "pkg_b:register" + ep_b.load.return_value = make_registrar("b") + + monkeypatch.setattr( + "uipath.core.adapters.registry.entry_points", + lambda group: [ep_a, ep_b] if group == "uipath.governance.adapters" else [], + raising=False, + ) + + # entry_points lives in importlib.metadata; the registry imports it + # lazily inside the function. Patch the import target directly. + import importlib.metadata as importlib_metadata + + monkeypatch.setattr( + importlib_metadata, + "entry_points", + lambda group=None: ( + [ep_a, ep_b] if group == "uipath.governance.adapters" else [] + ), + ) + + _discover_entry_point_adapters() + assert sorted(called) == ["a", "b"] + + +def test_entry_point_discovery_skips_broken_loader(monkeypatch): + """One broken entry-point must not stop the others from registering.""" + called: list[str] = [] + + ep_broken = MagicMock() + ep_broken.name = "broken" + ep_broken.value = "pkg_broken:register" + ep_broken.load.side_effect = ImportError("cannot import") + + ep_ok = MagicMock() + ep_ok.name = "ok" + ep_ok.value = "pkg_ok:register" + ep_ok.load.return_value = lambda: called.append("ok") + + import importlib.metadata as importlib_metadata + + monkeypatch.setattr( + importlib_metadata, + "entry_points", + lambda group=None: ( + [ep_broken, ep_ok] if group == "uipath.governance.adapters" else [] + ), + ) + + _discover_entry_point_adapters() # must not raise + assert called == ["ok"] + + +def test_entry_point_discovery_skips_non_callable(monkeypatch): + """An entry-point that resolves to a non-callable must be logged and skipped.""" + called: list[str] = [] + + ep_bad = MagicMock() + ep_bad.name = "bad" + ep_bad.value = "pkg_bad:NOT_A_FUNCTION" + ep_bad.load.return_value = "not callable" + + ep_ok = MagicMock() + ep_ok.name = "ok" + ep_ok.value = "pkg_ok:register" + ep_ok.load.return_value = lambda: called.append("ok") + + import importlib.metadata as importlib_metadata + + monkeypatch.setattr( + importlib_metadata, + "entry_points", + lambda group=None: ( + [ep_bad, ep_ok] if group == "uipath.governance.adapters" else [] + ), + ) + + _discover_entry_point_adapters() + assert called == ["ok"] + + +def test_entry_point_discovery_swallows_registrar_exception(monkeypatch): + """A registrar that raises mid-call must not stop subsequent registrars.""" + called: list[str] = [] + + def _raises() -> None: + raise RuntimeError("registrar exploded") + + ep_raising = MagicMock() + ep_raising.name = "raises" + ep_raising.value = "pkg:register" + ep_raising.load.return_value = _raises + + ep_ok = MagicMock() + ep_ok.name = "ok" + ep_ok.value = "pkg:register2" + ep_ok.load.return_value = lambda: called.append("ok") + + import importlib.metadata as importlib_metadata + + monkeypatch.setattr( + importlib_metadata, + "entry_points", + lambda group=None: ( + [ep_raising, ep_ok] if group == "uipath.governance.adapters" else [] + ), + ) + + _discover_entry_point_adapters() + assert called == ["ok"] + + +def test_entry_point_discovery_swallows_entry_points_failure(monkeypatch): + """If ``entry_points()`` itself raises, discovery must log and return cleanly.""" + import importlib.metadata as importlib_metadata + + def _boom(group=None): + raise RuntimeError("entry_points API exploded") + + monkeypatch.setattr(importlib_metadata, "entry_points", _boom) + + # Must not raise — and must not register anything. + _discover_entry_point_adapters() + reg = get_adapter_registry() + assert reg.get_all() == [] + + +# --------------------------------------------------------------------------- +# Protocol conformance smoke tests +# --------------------------------------------------------------------------- + + +def test_baseadapter_is_abc(): + """BaseAdapter must be abstract — direct instantiation must fail.""" + with pytest.raises(TypeError): + BaseAdapter() # type: ignore[abstract] + + +def test_concrete_adapter_is_baseadapter(): + """A concrete subclass must be recognized as a BaseAdapter.""" + assert isinstance(_SpecificAdapter(), BaseAdapter) diff --git a/packages/uipath-core/tests/governance/__init__.py b/packages/uipath-core/tests/governance/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/uipath-core/tests/governance/test_config.py b/packages/uipath-core/tests/governance/test_config.py new file mode 100644 index 000000000..54642a413 --- /dev/null +++ b/packages/uipath-core/tests/governance/test_config.py @@ -0,0 +1,54 @@ +"""Tests for the governance feature-flag gate.""" + +from __future__ import annotations + +import pytest + +from uipath.core.feature_flags import FeatureFlags +from uipath.core.governance.config import ( + GOVERNANCE_FEATURE_FLAG, + is_governance_enabled, +) + + +@pytest.fixture(autouse=True) +def _reset_flags(): + """Each test starts and ends with a clean flags registry.""" + FeatureFlags.reset_flags() + yield + FeatureFlags.reset_flags() + + +def test_governance_flag_name_is_stable(): + """The flag name is a public contract shared with the runtime layer.""" + assert GOVERNANCE_FEATURE_FLAG == "EnablePythonGovernanceChecker" + + +def test_is_governance_enabled_defaults_to_false(): + """With nothing configured, the gate defaults to disabled. + + The platform / host runtime must explicitly opt into governance + (programmatically via :class:`FeatureFlags`, via gitops, or via the + ``UIPATH_FEATURE_EnablePythonGovernanceChecker`` env var). This + keeps the SDK safe-by-default for callers that haven't yet + integrated with the governance backend. + """ + assert is_governance_enabled() is False + + +def test_is_governance_enabled_respects_programmatic_disable(): + """Programmatic ``False`` flips the gate off.""" + FeatureFlags.configure_flags({GOVERNANCE_FEATURE_FLAG: False}) + assert is_governance_enabled() is False + + +def test_is_governance_enabled_respects_programmatic_enable(): + """Programmatic ``True`` keeps the gate on.""" + FeatureFlags.configure_flags({GOVERNANCE_FEATURE_FLAG: True}) + assert is_governance_enabled() is True + + +def test_is_governance_enabled_reads_env_var_fallback(monkeypatch): + """When nothing is configured programmatically, the env-var fallback wins.""" + monkeypatch.setenv(f"UIPATH_FEATURE_{GOVERNANCE_FEATURE_FLAG}", "false") + assert is_governance_enabled() is False diff --git a/packages/uipath-core/tests/governance/test_exceptions.py b/packages/uipath-core/tests/governance/test_exceptions.py new file mode 100644 index 000000000..257feb3d4 --- /dev/null +++ b/packages/uipath-core/tests/governance/test_exceptions.py @@ -0,0 +1,205 @@ +"""Tests for GovernanceBlockException constructors. + +The classmethod constructors (:meth:`from_violation`, +:meth:`from_audit_record`) form the documented contract that the +evaluator and adapter packages depend on — the evaluator only ever +builds a block via ``from_audit_record``. These tests pin the message +format and attribute population so a future refactor cannot silently +drop the rule id, name, or detail. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from uipath.core.governance.exceptions import ( + GovernanceBlockException, + GovernanceViolation, + Severity, +) +from uipath.core.governance.models import ( + Action, + AuditRecord, + LifecycleHook, + RuleEvaluation, +) + +# --------------------------------------------------------------------------- +# GovernanceViolation +# --------------------------------------------------------------------------- + + +def test_violation_defaults_to_high_severity(): + v = GovernanceViolation(rule_id="A-1", rule_name="No PII", detail="ssn leaked") + assert v.severity == Severity.HIGH + + +def test_violation_severity_can_be_overridden(): + v = GovernanceViolation( + rule_id="A-1", + rule_name="No PII", + detail="ssn leaked", + severity=Severity.CRITICAL, + ) + assert v.severity == Severity.CRITICAL + + +# --------------------------------------------------------------------------- +# GovernanceBlockException base constructor +# --------------------------------------------------------------------------- + + +def test_default_constructor_emits_prefixed_message(): + exc = GovernanceBlockException() + assert "[Governance Policy Violation]" in str(exc) + assert exc.violation is None + assert exc.audit_record is None + + +def test_default_constructor_carries_default_rule_metadata(): + """Constructing without context still gives the documented fallback IDs.""" + exc = GovernanceBlockException() + assert exc.rule_id == "POLICY" + assert exc.rule_name == "Governance Policy" + + +def test_explicit_message_is_used_verbatim(): + exc = GovernanceBlockException("custom message") + assert str(exc) == "custom message" + + +def test_error_code_constant_for_orchestrator_categorization(): + """error_code is a class-level constant the Orchestrator UI reads.""" + assert GovernanceBlockException.error_code == "GOVERNANCE_POLICY_VIOLATION" + exc = GovernanceBlockException() + assert exc.error_code == "GOVERNANCE_POLICY_VIOLATION" + + +# --------------------------------------------------------------------------- +# from_violation +# --------------------------------------------------------------------------- + + +def test_from_violation_populates_rule_metadata(): + v = GovernanceViolation(rule_id="A-1", rule_name="No PII", detail="ssn leaked") + exc = GovernanceBlockException.from_violation(v) + assert exc.rule_id == "A-1" + assert exc.rule_name == "No PII" + assert exc.violation is v + + +def test_from_violation_message_includes_rule_id_name_detail(): + v = GovernanceViolation(rule_id="A-1", rule_name="No PII", detail="ssn leaked") + msg = str(GovernanceBlockException.from_violation(v)) + assert "A-1" in msg + assert "No PII" in msg + assert "ssn leaked" in msg + assert "[Governance Policy Violation]" in msg + + +# --------------------------------------------------------------------------- +# from_audit_record +# --------------------------------------------------------------------------- + + +def _audit_record_with(*evaluations: RuleEvaluation) -> AuditRecord: + return AuditRecord( + timestamp=datetime.now(timezone.utc), + agent_name="agent", + runtime_id="run-1", + trace_id="trace-1", + hook=LifecycleHook.BEFORE_AGENT, + evaluations=list(evaluations), + final_action=Action.DENY, + ) + + +def test_from_audit_record_picks_first_matched_rule(): + """Even when later evaluations matched, the first matched wins the message.""" + audit = _audit_record_with( + RuleEvaluation( + rule_id="UNMATCHED", + rule_name="Did not fire", + matched=False, + detail="", + action=Action.ALLOW, + ), + RuleEvaluation( + rule_id="MATCHED-FIRST", + rule_name="First match", + matched=True, + detail="bad input", + action=Action.DENY, + ), + RuleEvaluation( + rule_id="MATCHED-SECOND", + rule_name="Second match", + matched=True, + detail="also bad", + action=Action.DENY, + ), + ) + + exc = GovernanceBlockException.from_audit_record(audit) + assert exc.rule_id == "MATCHED-FIRST" + assert exc.rule_name == "First match" + assert "bad input" in str(exc) + assert exc.audit_record is audit + + +def test_from_audit_record_falls_back_when_no_match(): + """When the audit has no matches, the exception is still constructible.""" + audit = _audit_record_with( + RuleEvaluation( + rule_id="UNMATCHED", + rule_name="Did not fire", + matched=False, + detail="", + action=Action.ALLOW, + ) + ) + + exc = GovernanceBlockException.from_audit_record(audit) + assert "Rules evaluated: 1" in str(exc) + assert exc.audit_record is audit + + +def test_from_audit_record_matched_detail_default_when_empty(): + """A matched evaluation with empty detail still produces a sensible message.""" + audit = _audit_record_with( + RuleEvaluation( + rule_id="A-1", + rule_name="No PII", + matched=True, + detail="", # empty + action=Action.DENY, + ) + ) + + msg = str(GovernanceBlockException.from_audit_record(audit)) + assert "A-1" in msg + assert "No PII" in msg + # Falls back to a non-empty detail string. + assert "Policy violation detected" in msg + + +# --------------------------------------------------------------------------- +# Exception identity — must be a real Exception so callers can catch broadly +# --------------------------------------------------------------------------- + + +def test_block_exception_is_exception_subclass(): + assert issubclass(GovernanceBlockException, Exception) + + +def test_block_exception_can_be_caught_via_base_exception(): + try: + raise GovernanceBlockException.from_violation( + GovernanceViolation(rule_id="A-1", rule_name="X", detail="d") + ) + except Exception as e: # noqa: BLE001 - intentional broad catch + assert isinstance(e, GovernanceBlockException) + else: + pytest.fail("Did not raise") diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index 9aa9417f4..0ef8204e9 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-05-25T19:32:11.835974Z" +exclude-newer = "2026-06-06T13:38:31.678016Z" exclude-newer-span = "P2D" [[package]] @@ -1011,7 +1011,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.17" +version = "0.5.18" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 34dc9d413..c78924106 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-06-03T20:09:03.1179056Z" +exclude-newer = "2026-06-06T13:38:31.678016Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1063,7 +1063,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.17" +version = "0.5.18" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 657a4cf7d..87b11124e 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-06-03T20:09:04.4644787Z" +exclude-newer = "2026-06-06T13:38:31.678016Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2659,7 +2659,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.17" +version = "0.5.18" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, From 3825955b4093a36cc9828cf1e4a2a888c860d45e Mon Sep 17 00:00:00 2001 From: Cristian Cotovanu <87022468+cotovanu-cristian@users.noreply.github.com> Date: Tue, 9 Jun 2026 09:48:46 +0300 Subject: [PATCH 090/191] feat(agent): QuickForm escalation channel models (#1686) Co-authored-by: Claude Opus 4.8 (1M context) --- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/agent/models/agent.py | 95 ++++++++++++------- .../uipath/tests/agent/models/test_agent.py | 74 +++++++++++++++ packages/uipath/uv.lock | 2 +- 4 files changed, 138 insertions(+), 35 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index c4da7e297..2e490ade3 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.78" +version = "2.10.79" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 5aa959e8f..385a37e7c 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -142,6 +142,13 @@ class AgentEscalationRecipientType(str, CaseInsensitiveEnum): ARGUMENT_GROUP_NAME = "ArgumentGroupName" +class AgentEscalationChannelType(str, CaseInsensitiveEnum): + """Agent escalation channel type enumeration.""" + + ACTION_CENTER = "actionCenter" + ACTION_CENTER_QUICK_FORM = "actionCenterQuickForm" + + class AgentContextRetrievalMode(str, CaseInsensitiveEnum): """Agent context retrieval mode enumeration.""" @@ -691,13 +698,9 @@ def _resolve_task_title(v: Any) -> Any: return v -class AgentEscalationChannelProperties(BaseResourceProperties): - """Agent escalation channel properties model.""" +class BaseEscalationChannelProperties(BaseResourceProperties): + """Fields shared by every escalation channel's properties.""" - app_name: str | None = Field(default=None, alias="appName") - app_version: int = Field(..., alias="appVersion") - folder_name: Optional[str] = Field(None, alias="folderName") - resource_key: str | None = Field(default=None, alias="resourceKey") is_actionable_message_enabled: Optional[bool] = Field( None, alias="isActionableMessageEnabled" ) @@ -706,12 +709,31 @@ class AgentEscalationChannelProperties(BaseResourceProperties): ) -class AgentEscalationChannel(BaseCfg): - """Agent escalation channel model.""" +class AgentEscalationChannelProperties(BaseEscalationChannelProperties): + """Action Center app-task channel properties (channel type ``actionCenter``).""" + + app_name: str | None = Field(default=None, alias="appName") + app_version: int = Field(..., alias="appVersion") + folder_name: Optional[str] = Field(None, alias="folderName") + resource_key: str | None = Field(default=None, alias="resourceKey") + + +class AgentQuickFormChannelProperties(BaseEscalationChannelProperties): + """Quick Form channel properties (channel type ``actionCenterQuickForm``).""" + + form_schema: Dict[str, Any] = Field(..., alias="schema") + + @property + def schema_id(self) -> str | None: + """Return the schema id nested inside the form schema body.""" + return self.form_schema.get("schemaId") + + +class BaseAgentEscalationChannel(BaseCfg): + """Fields shared by every escalation channel variant.""" id: Optional[str] = Field(None, alias="id") name: str = Field(..., alias="name") - type: str = Field(alias="type") description: str = Field(..., alias="description") input_schema: Dict[str, Any] = Field(..., alias="inputSchema") output_schema: Dict[str, Any] = Field(EMPTY_SCHEMA, alias="outputSchema") @@ -719,16 +741,12 @@ class AgentEscalationChannel(BaseCfg): {}, alias="argumentProperties" ) outcome_mapping: Optional[Dict[str, str]] = Field(None, alias="outcomeMapping") - properties: AgentEscalationChannelProperties = Field(..., alias="properties") recipients: List[AgentEscalationRecipient] = Field(..., alias="recipients") task_title: Optional[Union[str, TaskTitle]] = Field( default="Escalation Task", alias="taskTitle" ) priority: Optional[str] = None labels: List[str] = Field(default_factory=list) - # schema_body avoids shadowing pydantic.BaseModel.schema(); JSON alias stays "schema". - schema_id: Optional[str] = Field(None, alias="schemaId") - schema_body: Optional[Dict[str, Any]] = Field(None, alias="schema") @model_validator(mode="before") @classmethod @@ -737,6 +755,34 @@ def _apply_task_title_resolution(cls, v: Any) -> Any: return _resolve_task_title(v) +class AgentEscalationChannel(BaseAgentEscalationChannel): + """Action Center app-task escalation channel (channel type ``actionCenter``).""" + + type: Literal[AgentEscalationChannelType.ACTION_CENTER] = Field( + default=AgentEscalationChannelType.ACTION_CENTER, alias="type" + ) + properties: AgentEscalationChannelProperties = Field(..., alias="properties") + + +class AgentQuickFormEscalationChannel(BaseAgentEscalationChannel): + """Quick Form escalation channel; FormLib schema lives in ``properties.form_schema``.""" + + type: Literal[AgentEscalationChannelType.ACTION_CENTER_QUICK_FORM] = Field( + default=AgentEscalationChannelType.ACTION_CENTER_QUICK_FORM, alias="type" + ) + properties: AgentQuickFormChannelProperties = Field(..., alias="properties") + + +EscalationChannel = Annotated[ + Union[ + AgentEscalationChannel, + AgentQuickFormEscalationChannel, + ], + Field(discriminator="type"), + _case_insensitive_enum_validator("type", AgentEscalationChannelType), +] + + class AgentEscalationResourceConfig(BaseAgentResourceConfig): """Agent escalation resource configuration model.""" @@ -744,7 +790,7 @@ class AgentEscalationResourceConfig(BaseAgentResourceConfig): resource_type: Literal[AgentResourceType.ESCALATION] = Field( alias="$resourceType", default=AgentResourceType.ESCALATION, frozen=True ) - channels: List[AgentEscalationChannel] = Field(alias="channels") + channels: List[EscalationChannel] = Field(alias="channels") is_agent_memory_enabled: bool = Field(default=False, alias="isAgentMemoryEnabled") escalation_type: Literal[0] = Field(default=0, alias="escalationType") @@ -764,7 +810,7 @@ class AgentIxpVsEscalationResourceConfig(BaseAgentResourceConfig): resource_type: Literal[AgentResourceType.ESCALATION] = Field( alias="$resourceType", default=AgentResourceType.ESCALATION, frozen=True ) - channels: List[AgentEscalationChannel] = Field(alias="channels") + channels: List[EscalationChannel] = Field(alias="channels") is_agent_memory_enabled: bool = Field(default=False, alias="isAgentMemoryEnabled") escalation_type: Literal[1] = Field(default=1, alias="escalationType") vs_escalation_properties: AgentIxpVsEscalationProperties = Field( @@ -772,23 +818,6 @@ class AgentIxpVsEscalationResourceConfig(BaseAgentResourceConfig): ) -class AgentQuickFormEscalationResourceConfig(BaseAgentResourceConfig): - """Quick Form Agent escalation resource configuration model (escalationType=2). - - Quick Form escalations render a schema-first HITL task in Action Center via FormLib. - The schema (and its key) live on the channel (see AgentEscalationChannel.schema_id / - schema) and are sent inline to Orchestrator's GenericTasks/CreateTask endpoint. - """ - - id: Optional[str] = Field(None, alias="id") - resource_type: Literal[AgentResourceType.ESCALATION] = Field( - alias="$resourceType", default=AgentResourceType.ESCALATION, frozen=True - ) - channels: List[AgentEscalationChannel] = Field(alias="channels") - is_agent_memory_enabled: bool = Field(default=False, alias="isAgentMemoryEnabled") - escalation_type: Literal[2] = Field(default=2, alias="escalationType") - - class BaseAgentToolResourceConfig(BaseAgentResourceConfig): """Base agent tool resource configuration model.""" @@ -994,11 +1023,11 @@ class AgentUnknownToolResourceConfig(BaseAgentToolResourceConfig): Field(discriminator="type"), ] + EscalationResourceConfig = Annotated[ Union[ Annotated[AgentEscalationResourceConfig, Tag(0)], Annotated[AgentIxpVsEscalationResourceConfig, Tag(1)], - Annotated[AgentQuickFormEscalationResourceConfig, Tag(2)], ], Discriminator(lambda v: v.get("escalation_type") or v.get("escalationType") or 0), ] diff --git a/packages/uipath/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py index fcfefa946..c324d4e7e 100644 --- a/packages/uipath/tests/agent/models/test_agent.py +++ b/packages/uipath/tests/agent/models/test_agent.py @@ -36,6 +36,7 @@ AgentNumberOperator, AgentNumberRule, AgentProcessToolResourceConfig, + AgentQuickFormChannelProperties, AgentResourceType, AgentToolArgumentPropertiesVariant, AgentToolType, @@ -2607,11 +2608,84 @@ def test_agent_with_ixp_vs_escalation(self): assert len(channel.recipients) == 0 # Validate channel properties + assert isinstance(channel, AgentEscalationChannel) assert channel.properties.app_name is None assert channel.properties.app_version == 1 assert channel.properties.folder_name is None assert channel.properties.resource_key is None + def test_quick_form_channel_properties_derive_schema_id_from_body(self): + """schema_id reads the schemaId nested inside the schema body.""" + + props = AgentQuickFormChannelProperties.model_validate( + { + "schema": { + "schemaId": "e74ebb74-80ba-47b9-a370-532a1ba4c41e", + "fields": [], + "outcomes": [], + }, + } + ) + assert props.schema_id == "e74ebb74-80ba-47b9-a370-532a1ba4c41e" + + def test_quick_form_channel_properties_schema_id_none_when_absent(self): + """schema_id is None when the schema body carries no schemaId.""" + + props = AgentQuickFormChannelProperties.model_validate( + {"schema": {"fields": [], "outcomes": []}} + ) + assert props.schema_id is None + + def test_quick_form_channel_properties_require_schema(self): + with pytest.raises(ValidationError): + AgentQuickFormChannelProperties.model_validate( + {"isActionableMessageEnabled": False} + ) + + def test_quick_form_channel_requires_schema(self): + """A quick-form channel without a schema fails to parse.""" + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationResourceConfig).validate_python( + { + "$resourceType": "escalation", + "name": "Escalation", + "description": "", + "channels": [ + { + "name": "c", + "description": "", + "inputSchema": {"type": "object", "properties": {}}, + "type": "actionCenterQuickForm", + "recipients": [], + "properties": {"isActionableMessageEnabled": False}, + } + ], + "isAgentMemoryEnabled": False, + } + ) + + def test_unknown_escalation_channel_type_is_rejected(self): + """An unrecognized channel type fails to parse; the runtime cannot handle it.""" + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationResourceConfig).validate_python( + { + "$resourceType": "escalation", + "name": "Escalation", + "description": "", + "channels": [ + { + "name": "c", + "description": "", + "inputSchema": {"type": "object", "properties": {}}, + "type": "someFutureChannel", + "recipients": [], + "properties": {}, + } + ], + "isAgentMemoryEnabled": False, + } + ) + def test_task_title_text_builder_type(self): """Test TextBuilderTaskTitle with tokens.""" from uipath.agent.models.agent import ( diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 87b11124e..8acee32f9 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.78" +version = "2.10.79" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From d31ef4237b76da90ed432b4a0101b95a939dc0fc Mon Sep 17 00:00:00 2001 From: msbhavana <91954125+msbhavana@users.noreply.github.com> Date: Tue, 9 Jun 2026 20:37:26 +0530 Subject: [PATCH 091/191] feat(resource-catalog): add ENTITY to ResourceType enum (#1705) Co-authored-by: Claude Opus 4.6 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../resource_catalog/resource_catalog.py | 1 + packages/uipath-platform/uv.lock | 4 +- .../uipath/tests/cli/test_create_resources.py | 48 +++++++++++++++++-- packages/uipath/uv.lock | 4 +- 5 files changed, 51 insertions(+), 8 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index aa3ef6b47..4a5c0b83c 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.61" +version = "0.1.62" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/resource_catalog/resource_catalog.py b/packages/uipath-platform/src/uipath/platform/resource_catalog/resource_catalog.py index bedf6525d..67fdf52f6 100644 --- a/packages/uipath-platform/src/uipath/platform/resource_catalog/resource_catalog.py +++ b/packages/uipath-platform/src/uipath/platform/resource_catalog/resource_catalog.py @@ -22,6 +22,7 @@ class ResourceType(str, Enum): CONNECTOR = "connector" MCP_SERVER = "mcpserver" QUEUE = "queue" + ENTITY = "entity" @classmethod def from_string(cls, value: str) -> "ResourceType": diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index c78924106..ac4761377 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-06-06T13:38:31.678016Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.61" +version = "0.1.62" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/tests/cli/test_create_resources.py b/packages/uipath/tests/cli/test_create_resources.py index 08012dccc..aff1c7cab 100644 --- a/packages/uipath/tests/cli/test_create_resources.py +++ b/packages/uipath/tests/cli/test_create_resources.py @@ -14,6 +14,7 @@ VirtualResourceResult, ) from uipath.platform.errors import EnrichedException, FolderNotFoundException +from uipath.platform.resource_catalog import ResourceType def _enriched_exc( @@ -270,8 +271,39 @@ async def test_unsupported_virtual_kind_is_skipped_with_warning( bindings_file, mock_uipath, studio_client ): """Bindings whose kind the virtual endpoint cannot materialize (e.g. - 'entity', 'choiceSet', 'webhook') should be skipped with a warning and + 'choiceSet', 'webhook') should be skipped with a warning and never reach create_virtual_resource.""" + choiceset_binding = { + "resource": "choiceSet", + "key": "live.good.choiceset.Shared", + "value": { + "name": { + "defaultValue": "live.good.choiceset", + "isExpression": False, + "displayName": "Name", + }, + "folderPath": { + "defaultValue": "Shared", + "isExpression": False, + "displayName": "Folder Path", + }, + }, + "metadata": None, + } + bindings_file(_make_bindings([choiceset_binding])) + + await _run_create_resources(studio_client) + + mock_uipath.resource_catalog.list_by_type_async.assert_not_called() + studio_client.create_virtual_resource.assert_not_awaited() + studio_client.create_referenced_resource.assert_not_awaited() + + +async def test_entity_binding_catalog_hit_creates_reference( + bindings_file, mock_uipath, studio_client +): + """Entity bindings should go through the resource catalog lookup. + When found, a referenced resource should be created.""" entity_binding = { "resource": "entity", "key": "live.good.entity.Shared", @@ -290,12 +322,22 @@ async def test_unsupported_virtual_kind_is_skipped_with_warning( "metadata": None, } bindings_file(_make_bindings([entity_binding])) + mock_uipath.resource_catalog.list_by_type_async.return_value = _AsyncIterator( + [_found_resource(resource_type="entity", resource_sub_type="Native")] + ) + studio_client.create_referenced_resource.return_value = SimpleNamespace( + status=Status.ADDED + ) await _run_create_resources(studio_client) - mock_uipath.resource_catalog.list_by_type_async.assert_not_called() + mock_uipath.resource_catalog.list_by_type_async.assert_called_once_with( + resource_type=ResourceType.ENTITY, + name="live.good.entity", + folder_path="Shared", + ) + studio_client.create_referenced_resource.assert_awaited_once() studio_client.create_virtual_resource.assert_not_awaited() - studio_client.create_referenced_resource.assert_not_awaited() async def test_folder_not_found_falls_back_to_virtual( diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 8acee32f9..476c94d05 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-06-06T13:38:31.678016Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.61" +version = "0.1.62" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From cf3e35f05c67bed621379a15efc68100916e3abf Mon Sep 17 00:00:00 2001 From: Chibi Vikramathithan Date: Wed, 10 Jun 2026 17:48:43 -0700 Subject: [PATCH 092/191] fix(eval): normalize uppercase GUID eval ids at ingestion (PC-4688) (#1702) Co-authored-by: Claude Opus 4.8 (1M context) --- packages/uipath/pyproject.toml | 2 +- .../src/uipath/eval/models/evaluation_set.py | 34 ++++++++- .../tests/cli/eval/test_eval_id_casing.py | 69 +++++++++++++++++++ packages/uipath/uv.lock | 2 +- 4 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 packages/uipath/tests/cli/eval/test_eval_id_casing.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 2e490ade3..79881bbc5 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.79" +version = "2.10.80" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/eval/models/evaluation_set.py b/packages/uipath/src/uipath/eval/models/evaluation_set.py index 22e6ce244..c80da8e14 100644 --- a/packages/uipath/src/uipath/eval/models/evaluation_set.py +++ b/packages/uipath/src/uipath/eval/models/evaluation_set.py @@ -1,8 +1,9 @@ """Evaluation set models.""" +import re from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic.alias_generators import to_camel from ..mocks._types import ( @@ -15,6 +16,21 @@ LegacyConversationalEvalOutput, ) +_GUID_RE = re.compile( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" +) + + +def normalize_eval_id(value: str) -> str: + """Canonicalize a GUID id to lowercase; leave non-GUID ids unchanged. + + GUIDs are case-insensitive, but downstream correlation (selection, + span/cache keying) compares ids as plain strings, so a mixed-case id + must be normalized at ingestion to stay matchable. + """ + return value.lower() if isinstance(value, str) and _GUID_RE.match(value) else value + class EvaluatorReference(BaseModel): """Reference to an evaluator with optional weight. @@ -96,6 +112,12 @@ class EvaluationItem(BaseModel): alias="inputMockingStrategy", ) + @field_validator("id") + @classmethod + def _normalize_id(cls, value: str) -> str: + """Normalize GUID ids to canonical lowercase.""" + return normalize_eval_id(value) + class LegacyEvaluationItem(BaseModel): """Individual evaluation item within an evaluation set.""" @@ -130,6 +152,12 @@ class LegacyEvaluationItem(BaseModel): default=None, alias="conversationalExpectedOutput" ) + @field_validator("id") + @classmethod + def _normalize_id(cls, value: str) -> str: + """Normalize GUID ids to canonical lowercase.""" + return normalize_eval_id(value) + class EvaluationSet(BaseModel): """Complete evaluation set model.""" @@ -153,7 +181,7 @@ class EvaluationSet(BaseModel): def extract_selected_evals(self, eval_ids) -> None: """Filter evaluations to only include those with specified IDs.""" selected_evals: list[EvaluationItem] = [] - remaining_ids = set(eval_ids) + remaining_ids = {normalize_eval_id(eval_id) for eval_id in eval_ids} for evaluation in self.evaluations: if evaluation.id in remaining_ids: selected_evals.append(evaluation) @@ -187,7 +215,7 @@ class LegacyEvaluationSet(BaseModel): def extract_selected_evals(self, eval_ids) -> None: """Filter evaluations to only include those with specified IDs.""" selected_evals: list[LegacyEvaluationItem] = [] - remaining_ids = set(eval_ids) + remaining_ids = {normalize_eval_id(eval_id) for eval_id in eval_ids} for evaluation in self.evaluations: if evaluation.id in remaining_ids: selected_evals.append(evaluation) diff --git a/packages/uipath/tests/cli/eval/test_eval_id_casing.py b/packages/uipath/tests/cli/eval/test_eval_id_casing.py new file mode 100644 index 000000000..e14a88a68 --- /dev/null +++ b/packages/uipath/tests/cli/eval/test_eval_id_casing.py @@ -0,0 +1,69 @@ +"""Tests for case-insensitive eval id handling (PC-4688). + +Eval sets exported by some tools emit uppercase GUID ids. The backend +canonicalizes GUIDs to lowercase, so any case-sensitive correlation on the +runtime side (selection, span/cache keying) silently fails to match. These +tests pin the fix: GUID ids are normalized to lowercase at ingestion and +selection is casing-agnostic. +""" + +from typing import Any + +from uipath.eval.models.evaluation_set import ( + EvaluationItem, + EvaluationSet, + LegacyEvaluationItem, +) + +UPPER_GUID = "B063907C-76AB-4B0A-88A3-EC0FB40698B8" +LOWER_GUID = "b063907c-76ab-4b0a-88a3-ec0fb40698b8" + + +def _make_item(eval_id: str) -> dict[str, Any]: + return { + "id": eval_id, + "name": "item", + "inputs": {"x": 1}, + "evaluationCriterias": {}, + } + + +def test_evaluation_item_normalizes_uppercase_guid_id(): + """An uppercase GUID id is stored in canonical lowercase form.""" + item = EvaluationItem.model_validate(_make_item(UPPER_GUID)) + assert item.id == LOWER_GUID + + +def test_legacy_evaluation_item_normalizes_uppercase_guid_id(): + """LegacyEvaluationItem also normalizes uppercase GUID ids.""" + item = LegacyEvaluationItem.model_validate( + { + "id": UPPER_GUID, + "name": "item", + "inputs": {"x": 1}, + "expectedOutput": {}, + "evalSetId": "set-1", + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-01-01T00:00:00.000Z", + } + ) + assert item.id == LOWER_GUID + + +def test_non_guid_id_is_left_unchanged(): + """Non-GUID ids (e.g. slugs) keep their original value and casing.""" + item = EvaluationItem.model_validate(_make_item("Test-Eval-1")) + assert item.id == "Test-Eval-1" + + +def test_extract_selected_evals_matches_regardless_of_caller_casing(): + """Selecting by an uppercase GUID matches a normalized stored id.""" + eval_set = EvaluationSet.model_validate( + { + "id": "set-1", + "name": "set", + "evaluations": [_make_item(LOWER_GUID), _make_item("other-id")], + } + ) + eval_set.extract_selected_evals([UPPER_GUID]) + assert [e.id for e in eval_set.evaluations] == [LOWER_GUID] diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 476c94d05..f78dd4cbe 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.79" +version = "2.10.80" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 13bc71dd3962abc8c2373925c85e18a85b7f2021 Mon Sep 17 00:00:00 2001 From: Chibi Vikramathithan Date: Wed, 10 Jun 2026 21:41:51 -0700 Subject: [PATCH 093/191] fix(eval): use function calling for tool/input mocking so non-OpenAI models work (AE-1646) (#1692) --- packages/uipath-platform/pyproject.toml | 2 +- .../platform/chat/_llm_gateway_service.py | 19 +- .../services/test_uipath_llm_integration.py | 82 +++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 4 +- .../src/uipath/eval/mocks/_input_mocker.py | 28 +- .../src/uipath/eval/mocks/_llm_mocker.py | 34 +- .../uipath/eval/mocks/_structured_output.py | 259 +++++++++++++++ .../tests/cli/eval/mocks/test_input_mocker.py | 7 + .../cli/eval/mocks/test_input_mocker_span.py | 8 + .../uipath/tests/cli/eval/mocks/test_mocks.py | 128 +++++++- .../cli/eval/mocks/test_structured_output.py | 295 ++++++++++++++++++ packages/uipath/uv.lock | 4 +- 13 files changed, 808 insertions(+), 64 deletions(-) create mode 100644 packages/uipath/src/uipath/eval/mocks/_structured_output.py create mode 100644 packages/uipath/tests/cli/eval/mocks/test_structured_output.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 4a5c0b83c..50dd25553 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.62" +version = "0.1.63" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py b/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py index ffe0bff99..cb02d8af3 100644 --- a/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py +++ b/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py @@ -401,7 +401,7 @@ async def chat_completions( presence_penalty: float = 0, top_p: float | None = 1, top_k: int | None = None, - tools: list[ToolDefinition] | None = None, + tools: list[ToolDefinition | dict[str, Any]] | None = None, tool_choice: ToolChoice | None = None, response_format: dict[str, Any] | type[BaseModel] | None = None, api_version: str = NORMALIZED_API_VERSION, @@ -436,9 +436,11 @@ async def chat_completions( Controls diversity by considering only the top p probability mass. Defaults to 1. top_k (int, optional): Nucleus sampling parameter. Controls diversity by considering only the top k most probable tokens. Defaults to None. - tools (Optional[List[ToolDefinition]], optional): List of tool definitions that the - model can call. Tools enable the model to perform actions or retrieve information - beyond text generation. Defaults to None. + tools (Optional[List[ToolDefinition | dict]], optional): List of tool definitions + that the model can call. Tools enable the model to perform actions or retrieve + information beyond text generation. A tool given as a dict must already be in + UiPath wire format and is forwarded unchanged, which allows arbitrary nested + JSON schemas in its parameters. Defaults to None. tool_choice (Optional[ToolChoice], optional): Controls which tools the model can call. Can be "auto" (model decides), "none" (no tools), or a specific tool choice. Defaults to None. @@ -583,10 +585,15 @@ class Country(BaseModel): # Use provided dictionary format directly request_body["response_format"] = response_format - # Add tools if provided - convert to UiPath format + # Add tools if provided. A tool already in UiPath wire format (a dict) is + # passed through unchanged so callers can supply an arbitrary JSON schema + # for the parameters; ToolDefinition objects are converted as before. if tools: request_body["tools"] = [ - self._convert_tool_to_uipath_format(tool) for tool in tools + tool + if isinstance(tool, dict) + else self._convert_tool_to_uipath_format(tool) + for tool in tools ] # Handle tool_choice diff --git a/packages/uipath-platform/tests/services/test_uipath_llm_integration.py b/packages/uipath-platform/tests/services/test_uipath_llm_integration.py index 124ccad8b..9e2292c60 100644 --- a/packages/uipath-platform/tests/services/test_uipath_llm_integration.py +++ b/packages/uipath-platform/tests/services/test_uipath_llm_integration.py @@ -7,6 +7,7 @@ from uipath.platform.chat import ( AutoToolChoice, ChatModels, + RequiredToolChoice, SpecificToolChoice, ToolDefinition, ToolFunctionDefinition, @@ -369,6 +370,87 @@ async def test_tool_call_required_mocked(self, mock_request, llm_service): assert result.choices[0].message.tool_calls[0].arguments["name"] == "John" assert result.choices[0].message.tool_calls[0].arguments["password"] == "1234" + @pytest.mark.asyncio + @patch.object(UiPathLlmChatService, "request_async") + async def test_raw_dict_tool_passthrough_mocked(self, mock_request, llm_service): + """A tool supplied as a raw dict is sent unchanged, preserving nested schema. + + ToolDefinition's converter only emits flat properties, so callers that need + an arbitrary nested JSON schema (e.g. the eval mockers) pass the tool as a + dict already in UiPath wire format. It must reach the gateway verbatim. + """ + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "chatcmpl-raw", + "object": "chat.completion", + "created": 1677858242, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_raw", + "name": "submit_tool_response", + "arguments": {"response": {"items": [{"sku": "A1"}]}}, + } + ], + }, + "finish_reason": "tool_calls", + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + "cache_read_input_tokens": None, + }, + } + mock_request.return_value = mock_response + + nested_tool = { + "name": "submit_tool_response", + "description": "Return the simulated response matching the schema.", + "parameters": { + "type": "object", + "properties": { + "response": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": {"sku": {"type": "string"}}, + }, + } + }, + } + }, + "required": ["response"], + }, + } + + result = await llm_service.chat_completions( + messages=[{"role": "user", "content": "go"}], + model=ChatModels.gpt_4_1_mini_2025_04_14, + tools=[nested_tool], + tool_choice=RequiredToolChoice(), + ) + + mock_request.assert_called_once() + _, kwargs = mock_request.call_args + body = kwargs["json"] + # The dict tool is forwarded byte-for-byte, nested array schema intact. + assert body["tools"] == [nested_tool] + assert body["tool_choice"] == {"type": "required"} + assert result.choices[0].message.tool_calls[0].arguments == { + "response": {"items": [{"sku": "A1"}]} + } + @pytest.mark.asyncio @patch.object(UiPathLlmChatService, "request_async") async def test_chat_with_conversation_history_mocked( diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index ac4761377..2f56c1df5 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.62" +version = "0.1.63" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 79881bbc5..ffaa7f881 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.10.80" +version = "2.10.81" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.17, <0.6.0", "uipath-runtime>=0.11.0, <0.12.0", - "uipath-platform>=0.1.60, <0.2.0", + "uipath-platform>=0.1.63, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/src/uipath/eval/mocks/_input_mocker.py b/packages/uipath/src/uipath/eval/mocks/_input_mocker.py index 57a727ec1..a542fc7ad 100644 --- a/packages/uipath/src/uipath/eval/mocks/_input_mocker.py +++ b/packages/uipath/src/uipath/eval/mocks/_input_mocker.py @@ -15,6 +15,7 @@ from .._execution_context import eval_set_run_id_context from ._mock_context import cache_manager_context from ._mocker import UiPathInputMockingError +from ._structured_output import generate_structured_output from ._types import ( InputMockingStrategy, ) @@ -105,15 +106,6 @@ async def generate_llm_input( prompt = get_input_mocking_prompt(**prompt_generation_args) - response_format = { - "type": "json_schema", - "json_schema": { - "name": "agent_input", - "strict": False, - "schema": input_schema, - }, - } - model_parameters = mocking_strategy.model if mocking_strategy else None completion_kwargs = ( model_parameters.model_dump(by_alias=False, exclude_none=True) @@ -128,7 +120,7 @@ async def generate_llm_input( if cache_manager is not None: cache_key_data = { - "response_format": response_format, + "input_schema": input_schema, "completion_kwargs": completion_kwargs, "prompt_generation_args": prompt_generation_args, } @@ -142,15 +134,15 @@ async def generate_llm_input( if cached_response is not None: return cached_response - response = await llm.chat_completions( + result = await generate_structured_output( + llm, [{"role": "user", "content": prompt}], - response_format=response_format, - **completion_kwargs, + schema=input_schema, + response_format_name="agent_input", + description="Return the simulated agent input matching the required schema.", + completion_kwargs=completion_kwargs, ) - generated_input_str = response.choices[0].message.content - result = json.loads(generated_input_str) - if cache_manager is not None: cache_manager.set( mocker_type="input_mocker", @@ -160,10 +152,6 @@ async def generate_llm_input( ) return result - except json.JSONDecodeError as e: - raise UiPathInputMockingError( - f"Failed to parse LLM response as JSON: {str(e)}" - ) from e except UiPathInputMockingError: raise except Exception as e: diff --git a/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py b/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py index d1fd2a1c9..a9ab7005e 100644 --- a/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py +++ b/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py @@ -28,6 +28,7 @@ UiPathMockResponseGenerationError, UiPathNoMockFoundError, ) +from ._structured_output import generate_structured_output from ._types import ( ExampleCall, LLMMockingStrategy, @@ -125,14 +126,7 @@ async def response( "output_schema", TypeAdapter(return_type).json_schema() ) - response_format = { - "type": "json_schema", - "json_schema": { - "name": "OutputSchema", - "strict": False, - "schema": _cleanup_schema(output_schema), - }, - } + cleaned_schema = _cleanup_schema(output_schema) try: # Safely pull examples from params. example_calls = params.get("example_calls", []) @@ -197,7 +191,7 @@ async def response( formatted_prompt = PROMPT.format(**prompt_generation_args) cache_key_data = { - "response_format": response_format, + "output_schema": cleaned_schema, "completion_kwargs": completion_kwargs, "prompt_generation_args": prompt_generation_args, } @@ -213,17 +207,17 @@ async def response( if cached_response is not None: return cached_response - response = await llm.chat_completions( - [ - { - "role": "user", - "content": formatted_prompt, - }, - ], - response_format=response_format, - **completion_kwargs, + result = await generate_structured_output( + llm, + [{"role": "user", "content": formatted_prompt}], + schema=cleaned_schema, + response_format_name="OutputSchema", + description=( + "Return the simulated response for tool " + f"'{function_name}' matching the required schema." + ), + completion_kwargs=completion_kwargs, ) - result = json.loads(response.choices[0].message.content) if cache_manager is not None: cache_manager.set( @@ -235,7 +229,7 @@ async def response( return result except Exception as e: - raise UiPathMockResponseGenerationError() from e + raise UiPathMockResponseGenerationError(str(e)) from e else: raise UiPathNoMockFoundError(f"Method '{function_name}' is not simulated.") diff --git a/packages/uipath/src/uipath/eval/mocks/_structured_output.py b/packages/uipath/src/uipath/eval/mocks/_structured_output.py new file mode 100644 index 000000000..599780353 --- /dev/null +++ b/packages/uipath/src/uipath/eval/mocks/_structured_output.py @@ -0,0 +1,259 @@ +"""Provider-aware structured output for the eval mockers. + +The normalized LLM Gateway handles OpenAI-style ``response_format`` +(json_schema) differently per provider — live-verified against the gateway: + +- **OpenAI**: honors ``response_format`` and returns valid JSON content, + including native ``$defs`` support. +- **Anthropic (Claude)**: ignores it and answers with plain prose content. +- **Gemini**: returns empty content. + +Forced function calling works across all three providers, so each provider +gets a small strategy class: OpenAI prefers ``response_format`` (more reliable +for it on some schemas) with a tool-call fallback; Claude and Gemini go +straight to the forced tool call; unknown providers try ``response_format`` +first and fall back. +""" + +import json +import logging +from typing import Any + +from uipath.platform.chat.llm_gateway import RequiredToolChoice + +RESPONSE_TOOL_NAME = "submit_tool_response" +RESPONSE_KEY = "response" +_DEFS_PREFIX = "#/$defs/" + +logger = logging.getLogger(__name__) + + +def _inline_defs( + schema: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, Any]]: + """Inline ``$defs``/``$ref`` into a self-contained schema. + + Nested Pydantic models and enums emit root ``$defs`` referenced by ``$ref``. + The normalized gateway accepts those in ``response_format`` but not inside a + tool's ``parameters``, so they are inlined here. Sibling keys on a ``$ref`` + node (e.g. a field ``description``) are merged over the inlined definition. + Self-referential definitions cannot be inlined without looping; any ``$ref`` + reached while its target is already on the current resolution path is left + untouched and its definitions are returned so the caller can keep them + reachable. + + Returns: + A tuple of (inlined schema, leftover ``$defs`` needed for cyclic refs). + """ + defs = schema.get("$defs", {}) + leftover: dict[str, Any] = {} + + def resolve(node: Any, active: frozenset[str]) -> Any: + if isinstance(node, dict): + ref = node.get("$ref") + if isinstance(ref, str) and ref.startswith(_DEFS_PREFIX): + name = ref[len(_DEFS_PREFIX) :] + if name in defs and name not in active: + resolved = resolve(defs[name], active | {name}) + siblings = { + key: resolve(value, active) + for key, value in node.items() + if key not in ("$ref", "$defs") + } + if isinstance(resolved, dict): + return {**resolved, **siblings} + return resolved + # Cyclic or unknown ref: keep it and preserve its definition. + if name in defs: + leftover[name] = defs[name] + return dict(node) + return { + key: resolve(value, active) + for key, value in node.items() + if key != "$defs" + } + if isinstance(node, list): + return [resolve(item, active) for item in node] + return node + + root = {key: value for key, value in schema.items() if key != "$defs"} + inlined = resolve(root, frozenset()) + return inlined, leftover + + +def build_response_tool(schema: dict[str, Any], description: str) -> dict[str, Any]: + """Build a normalized-API function tool that wraps ``schema`` under ``response``. + + Tool-call arguments are always a JSON object, so an arbitrary output schema + (which may be a scalar, array, or object) is nested under a single + ``response`` property and unwrapped after the call. ``$defs``/``$ref`` are + inlined so the tool parameters are self-contained, which the gateway requires + for tool schemas (unlike ``response_format``). + """ + response_schema, leftover_defs = _inline_defs(schema) + parameters: dict[str, Any] = { + "type": "object", + "properties": {RESPONSE_KEY: response_schema}, + "required": [RESPONSE_KEY], + } + if leftover_defs: + parameters["$defs"] = leftover_defs + + return { + "name": RESPONSE_TOOL_NAME, + "description": description, + "parameters": parameters, + } + + +def extract_response(response: Any) -> Any: + """Extract the wrapped value from the forced tool call. + + Raises: + ValueError: if the response carries no usable tool call or is missing the + wrapped ``response`` key. + """ + choices = getattr(response, "choices", None) + if not choices: + raise ValueError("LLM response contained no choices") + + message = choices[0].message + tool_calls = getattr(message, "tool_calls", None) + if not tool_calls: + raise ValueError( + f"LLM response contained no tool calls (content={message.content!r})" + ) + + arguments = tool_calls[0].arguments + if RESPONSE_KEY not in arguments: + raise ValueError( + f"Tool call arguments missing '{RESPONSE_KEY}' key: {arguments}" + ) + + return arguments[RESPONSE_KEY] + + +class ToolCallStructuredOutput: + """Structured output via a forced tool call — works on every provider.""" + + async def generate( + self, + llm: Any, + messages: list[dict[str, str]], + *, + schema: dict[str, Any], + response_format_name: str, + description: str, + completion_kwargs: dict[str, Any], + ) -> Any: + """Force a tool call wrapping ``schema`` and unwrap its arguments.""" + tool = build_response_tool(schema, description) + response = await llm.chat_completions( + messages, + tools=[tool], + tool_choice=RequiredToolChoice(), + **completion_kwargs, + ) + return extract_response(response) + + +class ResponseFormatStructuredOutput(ToolCallStructuredOutput): + """Prefer ``response_format`` (json_schema); fall back to a forced tool call. + + The fallback fires when the provider rejects the request, returns empty + content, or returns content that is not valid JSON (Claude's behavior on + the normalized gateway is to answer with plain prose). + """ + + async def generate( + self, + llm: Any, + messages: list[dict[str, str]], + *, + schema: dict[str, Any], + response_format_name: str, + description: str, + completion_kwargs: dict[str, Any], + ) -> Any: + """Try ``response_format`` first, falling back to a forced tool call.""" + response_format = { + "type": "json_schema", + "json_schema": { + "name": response_format_name, + "strict": False, + "schema": schema, + }, + } + + content: str | None = None + try: + response = await llm.chat_completions( + messages, response_format=response_format, **completion_kwargs + ) + choices = getattr(response, "choices", None) + if choices: + content = choices[0].message.content + except Exception as e: + logger.info("response_format path failed, falling back to tools: %s", e) + + if content: + try: + return json.loads(content) + except json.JSONDecodeError: + logger.info( + "response_format content was not JSON, falling back to tools" + ) + + return await super().generate( + llm, + messages, + schema=schema, + response_format_name=response_format_name, + description=description, + completion_kwargs=completion_kwargs, + ) + + +class OpenAIStructuredOutput(ResponseFormatStructuredOutput): + """OpenAI honors ``response_format`` natively (including ``$defs``).""" + + +class AnthropicStructuredOutput(ToolCallStructuredOutput): + """Claude answers ``response_format`` with prose; go straight to tools.""" + + +class GeminiStructuredOutput(ToolCallStructuredOutput): + """Gemini returns empty content for ``response_format``; go straight to tools.""" + + +def _strategy_for_model(model: str | None) -> ToolCallStructuredOutput: + name = (model or "").lower() + if "claude" in name or name.startswith("anthropic"): + return AnthropicStructuredOutput() + if "gemini" in name: + return GeminiStructuredOutput() + if name.startswith(("gpt", "o1", "o3", "o4")): + return OpenAIStructuredOutput() + # Unknown providers: try response_format, fall back to tools. + return ResponseFormatStructuredOutput() + + +async def generate_structured_output( + llm: Any, + messages: list[dict[str, str]], + *, + schema: dict[str, Any], + response_format_name: str, + description: str, + completion_kwargs: dict[str, Any], +) -> Any: + """Generate structured output using the strategy for the requested model.""" + strategy = _strategy_for_model(completion_kwargs.get("model")) + return await strategy.generate( + llm, + messages, + schema=schema, + response_format_name=response_format_name, + description=description, + completion_kwargs=completion_kwargs, + ) diff --git a/packages/uipath/tests/cli/eval/mocks/test_input_mocker.py b/packages/uipath/tests/cli/eval/mocks/test_input_mocker.py index 72b3765df..a8a8a64ec 100644 --- a/packages/uipath/tests/cli/eval/mocks/test_input_mocker.py +++ b/packages/uipath/tests/cli/eval/mocks/test_input_mocker.py @@ -112,3 +112,10 @@ async def test_generate_llm_input_with_model_settings( assert len(chat_completion_requests) == 1, ( "Expected exactly one chat completion request" ) + + # OpenAI returns content via response_format; no tool-call fallback needed. + import json + + body = json.loads(chat_completion_requests[0].content.decode("utf-8")) + assert "response_format" in body + assert "tools" not in body diff --git a/packages/uipath/tests/cli/eval/mocks/test_input_mocker_span.py b/packages/uipath/tests/cli/eval/mocks/test_input_mocker_span.py index 19a432fef..d02c5d242 100644 --- a/packages/uipath/tests/cli/eval/mocks/test_input_mocker_span.py +++ b/packages/uipath/tests/cli/eval/mocks/test_input_mocker_span.py @@ -212,6 +212,14 @@ async def test_simulate_input_span_on_error(httpx_mock: HTTPXMock, monkeypatch): }, }, ) + # The prose content above triggers the tool-call fallback; an empty + # response there fails the fallback too, producing the error span. + httpx_mock.add_response( + url="https://example.com/llm/api/chat/completions" + "?api-version=2024-08-01-preview", + status_code=200, + json={}, + ) mocking_strategy = InputMockingStrategy( prompt="Generate input", diff --git a/packages/uipath/tests/cli/eval/mocks/test_mocks.py b/packages/uipath/tests/cli/eval/mocks/test_mocks.py index c4bc26ee3..e59b07d2f 100644 --- a/packages/uipath/tests/cli/eval/mocks/test_mocks.py +++ b/packages/uipath/tests/cli/eval/mocks/test_mocks.py @@ -610,12 +610,14 @@ def foofoo(*args, **kwargs): with pytest.raises(NotImplementedError): assert foofoo() - httpx_mock.add_response( - url="https://example.com/llm/api/chat/completions" - "?api-version=2024-08-01-preview", - status_code=200, - json={}, - ) + # Two empty responses: the response_format attempt and the tool-call fallback. + for _ in range(2): + httpx_mock.add_response( + url="https://example.com/llm/api/chat/completions" + "?api-version=2024-08-01-preview", + status_code=200, + json={}, + ) with pytest.raises(UiPathMockResponseGenerationError): assert foo() @@ -720,12 +722,14 @@ async def foofoo(*args, **kwargs): with pytest.raises(NotImplementedError): assert await foofoo() - httpx_mock.add_response( - url="https://example.com/llm/api/chat/completions" - "?api-version=2024-08-01-preview", - status_code=200, - json={}, - ) + # Two empty responses: the response_format attempt and the tool-call fallback. + for _ in range(2): + httpx_mock.add_response( + url="https://example.com/llm/api/chat/completions" + "?api-version=2024-08-01-preview", + status_code=200, + json={}, + ) with pytest.raises(UiPathMockResponseGenerationError): assert await foo() @@ -931,6 +935,106 @@ async def foo(*args, **kwargs) -> dict[str, Any]: } +@pytest.mark.asyncio +@pytest.mark.httpx_mock(assert_all_responses_were_requested=False) +async def test_llm_mockable_uses_tool_call_directly_for_non_openai( + httpx_mock: HTTPXMock, monkeypatch: MonkeyPatch +): + """Tool simulation works for non-OpenAI providers (AE-1646). + + Non-OpenAI providers don't honor ``response_format`` on the normalized + gateway (Claude answers with prose, Gemini with empty content), so their + strategies go straight to a forced tool call — a single request. + """ + monkeypatch.setenv("UIPATH_URL", "https://example.com") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "1234567890") + monkeypatch.setattr(CacheManager, "get", lambda *args, **kwargs: None) + monkeypatch.setattr(CacheManager, "set", lambda *args, **kwargs: None) + + @mockable() + async def foo(*args, **kwargs) -> str: + raise NotImplementedError() + + evaluation_item: dict[str, Any] = { + "id": "evaluation-id", + "name": "Mock foo", + "inputs": {}, + "evaluationCriterias": { + "ExactMatchEvaluator": None, + }, + "mockingStrategy": { + "type": "llm", + "prompt": "response is 'bar1'", + "toolsToSimulate": [{"name": "foo"}], + "model": {"model": "anthropic.claude-sonnet-4-5-20250929-v1:0"}, + }, + } + evaluation = EvaluationItem(**evaluation_item) + assert isinstance(evaluation.mocking_strategy, LLMMockingStrategy) + httpx_mock.add_response( + url="https://example.com/agenthub_/llm/api/capabilities", + status_code=200, + json={}, + ) + httpx_mock.add_response( + url="https://example.com/orchestrator_/llm/api/capabilities", + status_code=200, + json={}, + ) + + def _completion(message: dict[str, Any]) -> dict[str, Any]: + return { + "id": "response-id", + "object": "", + "created": 0, + "model": "anthropic.claude-sonnet-4-5-20250929-v1:0", + "choices": [{"index": 0, "message": message, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + + # Claude goes straight to function calling: one request, one response. + httpx_mock.add_response( + url="https://example.com/llm/api/chat/completions" + "?api-version=2024-08-01-preview", + status_code=200, + json=_completion( + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "name": "submit_tool_response", + "arguments": {"response": "bar1"}, + } + ], + } + ), + ) + + set_execution_context( + MockingContext( + strategy=evaluation.mocking_strategy, + name=evaluation.name, + inputs=evaluation.inputs, + ), + _mock_span_collector, + "test-execution-id", + ) + + assert await foo() == "bar1" + + requests = [ + r for r in httpx_mock.get_requests() if "chat/completions" in str(r.url) + ] + assert len(requests) == 1 + body = json.loads(requests[0].content.decode("utf-8")) + # Non-OpenAI providers use a forced tool call directly — no response_format. + assert body["tool_choice"] == {"type": "required"} + assert body["tools"][0]["name"] == "submit_tool_response" + assert "response_format" not in body + + class TestUiPathMockRuntime: """Tests for UiPathMockRuntime execute/stream/get_schema paths.""" diff --git a/packages/uipath/tests/cli/eval/mocks/test_structured_output.py b/packages/uipath/tests/cli/eval/mocks/test_structured_output.py new file mode 100644 index 000000000..79ad31591 --- /dev/null +++ b/packages/uipath/tests/cli/eval/mocks/test_structured_output.py @@ -0,0 +1,295 @@ +"""Unit tests for the provider-agnostic structured-output helpers.""" + +import json +from types import SimpleNamespace +from typing import Any + +import pytest + +from uipath.eval.mocks._structured_output import ( + RESPONSE_KEY, + RESPONSE_TOOL_NAME, + build_response_tool, + extract_response, + generate_structured_output, +) + + +def _response(message: SimpleNamespace | None) -> SimpleNamespace: + choices = [] if message is None else [SimpleNamespace(message=message)] + return SimpleNamespace(choices=choices) + + +class _FakeLLM: + """Records chat_completions calls and replays queued responses in order.""" + + def __init__(self, responses: list[Any]): + self._responses = list(responses) + self.calls: list[dict[str, Any]] = [] + + async def chat_completions(self, messages: Any, **kwargs: Any) -> Any: + self.calls.append(kwargs) + nxt = self._responses.pop(0) + if isinstance(nxt, Exception): + raise nxt + return nxt + + +def test_build_response_tool_wraps_schema_under_response(): + tool = build_response_tool({"type": "string"}, description="desc") + assert tool["name"] == RESPONSE_TOOL_NAME + assert tool["description"] == "desc" + assert tool["parameters"]["properties"][RESPONSE_KEY] == {"type": "string"} + assert tool["parameters"]["required"] == [RESPONSE_KEY] + + +def test_build_response_tool_inlines_refs_into_self_contained_schema(): + # Nested Pydantic models / enums emit $defs + $ref. The normalized gateway + # accepts $ref/$defs in response_format but NOT in a tool's parameters, so the + # schema must be inlined into a self-contained form (no $ref/$defs anywhere). + operator_def = {"enum": ["+", "-", "*", "/"], "type": "string"} + item_def = {"type": "object", "properties": {"sku": {"type": "string"}}} + schema = { + "type": "object", + "properties": { + "operator": {"$ref": "#/$defs/Operator"}, + "items": {"type": "array", "items": {"$ref": "#/$defs/Item"}}, + }, + "required": ["operator"], + "$defs": {"Operator": operator_def, "Item": item_def}, + } + + tool = build_response_tool(schema, description="d") + params = tool["parameters"] + + blob = json.dumps(params) + assert "$ref" not in blob + assert "$defs" not in blob + + response = params["properties"][RESPONSE_KEY] + assert response["properties"]["operator"] == operator_def + assert response["properties"]["items"]["items"] == item_def + # caller's schema is not mutated + assert "$defs" in schema + + +def test_build_response_tool_keeps_defs_for_cyclic_refs(): + # Self-referential schemas can't be fully inlined; keep $defs hoisted so the + # remaining $ref still resolves rather than infinite-looping. + node_def = { + "type": "object", + "properties": {"child": {"$ref": "#/$defs/Node"}}, + } + schema = { + "type": "object", + "properties": {"root": {"$ref": "#/$defs/Node"}}, + "$defs": {"Node": node_def}, + } + + tool = build_response_tool(schema, description="d") + params = tool["parameters"] + + assert "$defs" in params + assert "$ref" in json.dumps(params) + # the caller's schema dict is not mutated + assert "$defs" in schema + + +def test_extract_response_returns_wrapped_value(): + message = SimpleNamespace( + content=None, + tool_calls=[SimpleNamespace(arguments={RESPONSE_KEY: {"a": 1}})], + ) + assert extract_response(_response(message)) == {"a": 1} + + +def test_extract_response_raises_when_no_choices(): + with pytest.raises(ValueError, match="no choices"): + extract_response(_response(None)) + + +def test_extract_response_raises_when_no_tool_calls(): + # Non-OpenAI text response without a tool call: surface a clear error. + message = SimpleNamespace(content="not a tool call", tool_calls=None) + with pytest.raises(ValueError, match="no tool calls"): + extract_response(_response(message)) + + +def test_extract_response_raises_when_response_key_missing(): + message = SimpleNamespace( + content=None, tool_calls=[SimpleNamespace(arguments={"other": 1})] + ) + with pytest.raises(ValueError, match=RESPONSE_KEY): + extract_response(_response(message)) + + +@pytest.mark.asyncio +async def test_generate_structured_output_prefers_response_format_content(): + # OpenAI returns content via response_format; no fallback call is made. + llm = _FakeLLM([_response(SimpleNamespace(content='{"a": 1}', tool_calls=None))]) + result = await generate_structured_output( + llm, + [{"role": "user", "content": "x"}], + schema={"type": "object"}, + response_format_name="OutputSchema", + description="d", + completion_kwargs={}, + ) + assert result == {"a": 1} + assert len(llm.calls) == 1 + assert "response_format" in llm.calls[0] + assert "tools" not in llm.calls[0] + + +@pytest.mark.asyncio +async def test_generate_structured_output_falls_back_on_prose_content(): + # Claude on the normalized gateway answers response_format requests with + # plain prose (e.g. "Tokyo") — truthy but not JSON. Must fall back to tools + # instead of raising JSONDecodeError (AE-1646). + llm = _FakeLLM( + [ + _response(SimpleNamespace(content="Tokyo", tool_calls=None)), + _response( + SimpleNamespace( + content=None, + tool_calls=[SimpleNamespace(arguments={RESPONSE_KEY: {"a": 1}})], + ) + ), + ] + ) + result = await generate_structured_output( + llm, + [{"role": "user", "content": "x"}], + schema={"type": "object"}, + response_format_name="OutputSchema", + description="d", + completion_kwargs={}, + ) + assert result == {"a": 1} + assert len(llm.calls) == 2 + assert "tools" in llm.calls[1] + + +@pytest.mark.asyncio +async def test_generate_structured_output_falls_back_on_empty_content(): + # Non-OpenAI: response_format yields empty content -> fall back to tool call. + llm = _FakeLLM( + [ + _response(SimpleNamespace(content=None, tool_calls=None)), + _response( + SimpleNamespace( + content=None, + tool_calls=[SimpleNamespace(arguments={RESPONSE_KEY: {"a": 1}})], + ) + ), + ] + ) + result = await generate_structured_output( + llm, + [{"role": "user", "content": "x"}], + schema={"type": "object"}, + response_format_name="OutputSchema", + description="d", + completion_kwargs={}, + ) + assert result == {"a": 1} + assert len(llm.calls) == 2 + assert "response_format" in llm.calls[0] + assert "tools" in llm.calls[1] and "tool_choice" in llm.calls[1] + + +@pytest.mark.asyncio +async def test_generate_structured_output_falls_back_when_response_format_raises(): + # A provider that rejects response_format outright still gets a tool fallback. + llm = _FakeLLM( + [ + RuntimeError("response_format unsupported"), + _response( + SimpleNamespace( + content=None, + tool_calls=[SimpleNamespace(arguments={RESPONSE_KEY: "ok"})], + ) + ), + ] + ) + result = await generate_structured_output( + llm, + [{"role": "user", "content": "x"}], + schema={"type": "string"}, + response_format_name="OutputSchema", + description="d", + completion_kwargs={}, + ) + assert result == "ok" + assert len(llm.calls) == 2 + + +def test_build_response_tool_merges_ref_sibling_keys(): + # Pydantic can emit sibling keys (e.g. description) next to $ref; they + # must survive inlining since they guide the LLM. + schema = { + "type": "object", + "properties": { + "op": {"$ref": "#/$defs/Op", "description": "the operator to use"} + }, + "$defs": {"Op": {"type": "string", "enum": ["+", "-"]}}, + } + tool = build_response_tool(schema, description="d") + op = tool["parameters"]["properties"][RESPONSE_KEY]["properties"]["op"] + assert op == { + "type": "string", + "enum": ["+", "-"], + "description": "the operator to use", + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "model", + [ + "anthropic.claude-sonnet-4-5-20250929-v1:0", + "claude-haiku-4-5", + "gemini-2.5-pro", + ], +) +async def test_non_openai_models_use_tool_call_directly(model: str): + # Claude/Gemini don't honor response_format on the normalized gateway, so + # their strategies skip it entirely: a single forced tool call. + llm = _FakeLLM( + [ + _response( + SimpleNamespace( + content=None, + tool_calls=[SimpleNamespace(arguments={RESPONSE_KEY: "ok"})], + ) + ) + ] + ) + result = await generate_structured_output( + llm, + [{"role": "user", "content": "x"}], + schema={"type": "string"}, + response_format_name="OutputSchema", + description="d", + completion_kwargs={"model": model}, + ) + assert result == "ok" + assert len(llm.calls) == 1 + assert "tools" in llm.calls[0] and "tool_choice" in llm.calls[0] + assert "response_format" not in llm.calls[0] + + +@pytest.mark.asyncio +async def test_openai_models_prefer_response_format(): + llm = _FakeLLM([_response(SimpleNamespace(content='{"a": 1}', tool_calls=None))]) + result = await generate_structured_output( + llm, + [{"role": "user", "content": "x"}], + schema={"type": "object"}, + response_format_name="OutputSchema", + description="d", + completion_kwargs={"model": "gpt-4.1-mini-2025-04-14"}, + ) + assert result == {"a": 1} + assert len(llm.calls) == 1 + assert "response_format" in llm.calls[0] diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index f78dd4cbe..89d6d082d 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.80" +version = "2.10.81" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.62" +version = "0.1.63" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From a93a2ce3b30fcf14bb559573db8d997ca9458fbd Mon Sep 17 00:00:00 2001 From: GabrielVasilescu04 Date: Thu, 11 Jun 2026 18:58:52 +0300 Subject: [PATCH 094/191] chore: add platform mcp type (#1690) --- packages/uipath-platform/pyproject.toml | 2 +- .../uipath-platform/src/uipath/platform/orchestrator/mcp.py | 1 + packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 50dd25553..a62398e5b 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.63" +version = "0.1.64" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/mcp.py b/packages/uipath-platform/src/uipath/platform/orchestrator/mcp.py index 9a811d876..dd96353a0 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/mcp.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/mcp.py @@ -17,6 +17,7 @@ class McpServerType(IntEnum): SelfHosted = 3 # tunnel to (externally) self-hosted server Remote = 4 # HTTP connection to remote MCP server ProcessAssistant = 5 # Dynamic user process assistant + Platform = 6 # Platform MCP server (e.g: Orchestrator, TestManager) class McpServerStatus(IntEnum): diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 2f56c1df5..3d9ac6c79 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.63" +version = "0.1.64" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 89d6d082d..62ecc13a0 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.63" +version = "0.1.64" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From dce87b22e87b7bb0b63d061b871679c2dcc6d78f Mon Sep 17 00:00:00 2001 From: AlexBizon <47317910+AlexBizon@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:39:05 +0300 Subject: [PATCH 095/191] =?UTF-8?q?docs(core):=20correct=20project-type=20?= =?UTF-8?q?marker=20and=20I/O=20typing=20for=20functions/=E2=80=A6=20(#170?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/uipath/docs/core/agents.md | 19 +++++++++---------- packages/uipath/docs/core/functions.md | 11 ++++------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/packages/uipath/docs/core/agents.md b/packages/uipath/docs/core/agents.md index 5076d43b6..21b812647 100644 --- a/packages/uipath/docs/core/agents.md +++ b/packages/uipath/docs/core/agents.md @@ -91,23 +91,25 @@ The example below uses LangChain. Swap `uipath-langchain` for the framework of y ``` my-agent/ -├── main.py # agent logic +├── main.py # agent graph +├── langgraph.json # graph entry points (framework-specific) ├── pyproject.toml # project metadata and dependencies -├── uipath.json # entry point declarations ├── entry-points.json # generated — I/O JSON Schema └── bindings.json # generated — resource binding overrides ``` -### `uipath.json` +### `langgraph.json` ```json { - "agents": { - "agent": "main.py:agent" + "graphs": { + "agent": "./main.py:graph" } } ``` +Declares the agent's graph entry points. The filename is framework-specific — `langgraph.json` for LangChain/LangGraph, `llamaindex.json` for LlamaIndex, and so on. Its presence, together with the framework dependency below, is what marks the project as a coded agent. + ### `pyproject.toml` ```toml @@ -118,18 +120,15 @@ description = "..." authors = [{ name = "Your Name", email = "you@example.com" }] requires-python = ">=3.11" dependencies = ["uipath>=2.0", "uipath-langchain>=2.0"] - -[tool.uipath] -type = "agent" ``` -`[tool.uipath] type = "agent"` is required — it identifies the project as an agent to the runtime and packaging tools. +Standard metadata plus the framework dependency (`uipath-langchain` here). The framework graph file and this dependency identify the project as a coded agent — `pyproject.toml` needs no UiPath-specific entries, and `uipath.json` carries no agent entry. --- ## Input & Output -Define `Input` and `Output` as Python dataclasses, the same way as [coded functions](./functions.md#input--output): +Define `Input` and `Output` the same way as [coded functions](./functions.md#input--output) — a stdlib `@dataclass`, a pydantic `BaseModel`, or `pydantic.dataclasses.dataclass`: ```python from dataclasses import dataclass diff --git a/packages/uipath/docs/core/functions.md b/packages/uipath/docs/core/functions.md index e367270e8..6c073f68f 100644 --- a/packages/uipath/docs/core/functions.md +++ b/packages/uipath/docs/core/functions.md @@ -107,29 +107,26 @@ description = "..." authors = [{ name = "Your Name", email = "you@example.com" }] requires-python = ">=3.11" dependencies = ["uipath>=2.0"] - -[tool.uipath] -type = "function" ``` -`[tool.uipath] type = "function"` is required — it identifies the project as a function to the runtime and packaging tools. +Standard project metadata and dependencies. The `functions` map in `uipath.json` (above) is what marks the project as a coded function — `pyproject.toml` needs no UiPath-specific entries. ### Generated files | File | Purpose | |------|---------| -| `entry-points.json` | Input/output JSON Schema derived from your dataclasses — used by Maestro for variable binding | +| `entry-points.json` | Input/output JSON Schema derived from your `Input`/`Output` models — used by Maestro for variable binding | | `bindings.json` | Resource binding overrides (assets, connections, buckets) for local development | /// warning -`uipath init` executes `main.py` to derive the I/O schema. Re-run it after every change to your `Input` or `Output` dataclasses. +`uipath init` executes your entrypoint Python file(s) (as declared in `uipath.json`, e.g., `main.py`) to derive the I/O schema. Re-run it after every change to your `Input` or `Output` models. /// --- ## Input & Output -Define `Input` and `Output` as Python dataclasses. The runtime validates against these at invocation time and exports them as JSON Schema for Maestro variable binding. +Define `Input` and `Output` as typed Python — a stdlib `@dataclass`, a pydantic `BaseModel`, or `pydantic.dataclasses.dataclass`. The runtime uses these type hints to parse the invocation payload and exports them as JSON Schema for Maestro variable binding. The entry point can be a sync `def` or an `async def` — both are supported. ```python from dataclasses import dataclass, field From 7ca1d83dca086fc260696a98f53ad49177f94871 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Mon, 15 Jun 2026 11:25:18 +0300 Subject: [PATCH 096/191] ci: make dev-build PR description copy-paste ready (#1715) --- .github/workflows/publish-dev.yml | 46 +++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml index 3c438f75c..d1b218f99 100644 --- a/.github/workflows/publish-dev.yml +++ b/.github/workflows/publish-dev.yml @@ -109,17 +109,53 @@ jobs: Write-Output "Package $PROJECT_NAME version set to $DEV_VERSION" + # Shared dev suffix for every package built in this run (same PR + run number) + $DEV_SUFFIX = "dev1$PADDED_PR$PADDED_RUN" + + # Intra-repo dependencies per package. A dev build of these is < the package's + # base version (PEP 440 pre-release), so it falls outside the published ">=base" + # constraint and must be forced in via [tool.uv] override-dependencies. + $internalDepsMap = @{ + "uipath" = @("uipath-platform", "uipath-core") + "uipath-platform" = @("uipath-core") + "uipath-core" = @() + } + + # Packages also published in this run (their dev builds exist on testpypi) + $changedPackages = '${{ needs.detect-changed-packages.outputs.packages }}' | ConvertFrom-Json + + $overrideDeps = @() + foreach ($dep in $internalDepsMap[$PROJECT_NAME]) { + if ($changedPackages -contains $dep) { + $depPyproj = Get-Content "../$dep/pyproject.toml" -Raw + $depBaseVersion = ($depPyproj | Select-String -Pattern '(?m)^\[(project|tool\.poetry)\][^\[]*?version\s*=\s*"([^"]*)"' -AllMatches).Matches[0].Groups[2].Value + $overrideDeps += [PSCustomObject]@{ Name = $dep; Version = "$depBaseVersion.$DEV_SUFFIX" } + } + } + + # [tool.uv.sources]: the package itself plus every overridden dep point at testpypi + $sourcesLines = @("$PROJECT_NAME = { index = `"testpypi`" }") + foreach ($d in $overrideDeps) { $sourcesLines += "$($d.Name) = { index = `"testpypi`" }" } + $sourcesBlock = $sourcesLines -join "`n" + + # Optional [tool.uv] override block (omitted when no intra-repo dep was published) + $overrideBlock = "" + if ($overrideDeps.Count -gt 0) { + $overrideItems = ($overrideDeps | ForEach-Object { "`"$($_.Name)==$($_.Version)`"" }) -join ", " + $overrideBlock = "`n[tool.uv]`noverride-dependencies = [$overrideItems]`n" + } + $dependencyMessage = @" ### $PROJECT_NAME ``````toml [project] dependencies = [ - # Exact version: + # Exact version (copy-paste ready): "$PROJECT_NAME==$DEV_VERSION", - # Any version from PR - "$PROJECT_NAME>=$MIN_VERSION,<$MAX_VERSION" + # Any version from this PR (uncomment to use a range instead): + # "$PROJECT_NAME>=$MIN_VERSION,<$MAX_VERSION", ] [[tool.uv.index]] @@ -129,8 +165,8 @@ jobs: explicit = true [tool.uv.sources] - $PROJECT_NAME = { index = "testpypi" } - `````` + $sourcesBlock + $overrideBlock`````` "@ # Get the owner and repo from the GitHub repository From 62751de723ccad14f653b463ae244e08cce3f353 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Mon, 15 Jun 2026 16:55:50 +0300 Subject: [PATCH 097/191] fix(auth): prevent oauth callback token injection (#1718) --- packages/uipath/pyproject.toml | 2 +- .../src/uipath/_cli/_auth/_auth_server.py | 88 +++++++---- .../uipath/src/uipath/_cli/_auth/index.html | 6 + packages/uipath/tests/cli/test_auth_server.py | 138 ++++++++++++++++++ packages/uipath/uv.lock | 2 +- 5 files changed, 208 insertions(+), 28 deletions(-) create mode 100644 packages/uipath/tests/cli/test_auth_server.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index ffaa7f881..5b63bd838 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.81" +version = "2.10.82" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/_auth/_auth_server.py b/packages/uipath/src/uipath/_cli/_auth/_auth_server.py index 4433b1c20..673d545b3 100644 --- a/packages/uipath/src/uipath/_cli/_auth/_auth_server.py +++ b/packages/uipath/src/uipath/_cli/_auth/_auth_server.py @@ -1,4 +1,5 @@ import asyncio +import hmac import http.server import json import os @@ -10,15 +11,6 @@ PORT = 6234 -# Custom exception for token received -class TokenReceivedSignal(Exception): - """Exception raised when a token is successfully received.""" - - def __init__(self, token_data): - self.token_data = token_data - super().__init__("Token received successfully") - - def make_request_handler_class( state, code_verifier, token_callback, domain, redirect_uri, client_id ): @@ -29,12 +21,60 @@ def log_message(self, format, *args) -> None: # do nothing pass + def _is_host_allowed(self) -> bool: + """Reject requests whose Host header is not loopback. + + Defends against DNS rebinding since the legitimate flow + always lands on localhost. + """ + host = self.headers.get("Host", "") + hostname = host.rsplit(":", 1)[0] + return hostname in ("localhost", "127.0.0.1") + + def _handle_host_error(self) -> bool: + """Return True if a host error was identified and handled (403).""" + if not self._is_host_allowed(): + self.send_error(403, "Invalid host") + return True + return False + + def _state_is_valid(self) -> bool: + """Validate the OAuth state supplied.""" + received = self.headers.get("X-Auth-State", "") + return hmac.compare_digest(received, state) + + def _handle_state_error(self) -> bool: + """Return True if a state error was identified and handled (403).""" + if not self._state_is_valid(): + self.send_error(403, "Invalid or missing state") + return True + return False + + def _read_json_body(self): + """Read and parse the JSON request body. + + Returns the decoded object, or None if the + expected headers are missing or body is malformed. + """ + try: + content_length = int(self.headers["Content-Length"]) + post_data = self.rfile.read(content_length) + return json.loads(post_data.decode("utf-8")) + except (KeyError, TypeError, ValueError): + self.send_error(400, "Invalid request") + return None + def do_POST(self): """Handle POST requests to /set_token.""" + if self._handle_host_error(): + return if self.path == "/set_token": - content_length = int(self.headers["Content-Length"]) - post_data = self.rfile.read(content_length) - token_data = json.loads(post_data.decode("utf-8")) + if self._handle_state_error(): + return + + token_data = self._read_json_body() + if token_data is None: + return self.send_response(200) self.end_headers() @@ -44,9 +84,13 @@ def do_POST(self): token_callback(token_data) elif self.path == "/log": - content_length = int(self.headers["Content-Length"]) - post_data = self.rfile.read(content_length) - logs = json.loads(post_data.decode("utf-8")) + if self._handle_state_error(): + return + + logs = self._read_json_body() + if logs is None: + return + # Write logs to .uipath/.error_log file uipath_dir = os.path.join(os.getcwd(), ".uipath") os.makedirs(uipath_dir, exist_ok=True) @@ -66,6 +110,8 @@ def do_POST(self): def do_GET(self): """Handle GET requests by serving index.html.""" + if self._handle_host_error(): + return # Always serve index.html regardless of the path try: index_path = os.path.join(os.path.dirname(__file__), "index.html") @@ -86,16 +132,6 @@ def do_GET(self): except FileNotFoundError: self.send_error(404, "File not found") - def end_headers(self): - self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - self.send_header("Access-Control-Allow-Headers", "Content-Type") - super().end_headers() - - def do_OPTIONS(self): - self.send_response(200) - self.end_headers() - return SimpleHTTPSRequestHandler @@ -149,7 +185,7 @@ def create_server(self, state, code_verifier, domain): self.redirect_uri, self.client_id, ) - self.httpd = socketserver.TCPServer(("", self.port), handler) + self.httpd = socketserver.TCPServer(("127.0.0.1", self.port), handler) return self.httpd def _run_server(self): diff --git a/packages/uipath/src/uipath/_cli/_auth/index.html b/packages/uipath/src/uipath/_cli/_auth/index.html index a361e73de..08f81d81b 100644 --- a/packages/uipath/src/uipath/_cli/_auth/index.html +++ b/packages/uipath/src/uipath/_cli/_auth/index.html @@ -519,6 +519,9 @@

    Authenticate CLI

    async function sendLogs(logs) { await fetch(`${baseUrl}/log`, { method: 'POST', + headers: { + 'X-Auth-State': "__PY_REPLACE_EXPECTED_STATE__" + }, body: JSON.stringify(logs) }); } @@ -559,6 +562,9 @@

    Authenticate CLI

    await sendLogs(logs); await fetch(`${baseUrl}/set_token`, { method: 'POST', + headers: { + 'X-Auth-State': state + }, body: JSON.stringify(tokenData) }); diff --git a/packages/uipath/tests/cli/test_auth_server.py b/packages/uipath/tests/cli/test_auth_server.py new file mode 100644 index 000000000..f81d2c08b --- /dev/null +++ b/packages/uipath/tests/cli/test_auth_server.py @@ -0,0 +1,138 @@ +"""Security tests for the OAuth local callback server. + +Covers GHSA-32xc-7x5c-8vmf: the `/set_token` and `/log` endpoints must reject +unauthenticated POSTs (no / wrong OAuth `state`), and the server must bind to +loopback only rather than all interfaces. +""" + +import json +import os +import threading +import urllib.error +import urllib.request + +from uipath._cli._auth._auth_server import HTTPServer + +STATE = "LEGITIMATE_OAUTH_STATE_ABCDE12345" +CODE_VERIFIER = "LEGITIMATE_PKCE_CODE_VERIFIER" +DOMAIN = "cloud.uipath.com" + +ATTACKER_PAYLOAD = { + "access_token": "attacker-token", + "refresh_token": "attacker-refresh", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "offline_access", +} + + +def _request(port, path, data, headers=None, method="POST"): + req = urllib.request.Request( + f"http://127.0.0.1:{port}{path}", + data=data, + headers=headers or {}, + method=method, + ) + try: + with urllib.request.urlopen(req, timeout=5) as resp: + return resp.status, resp.read().decode("utf-8") + except urllib.error.HTTPError as exc: + return exc.code, exc.read().decode("utf-8") + + +def _post(port, path, body, headers=None): + return _request( + port, + path, + json.dumps(body).encode("utf-8"), + {"Content-Type": "application/json", **(headers or {})}, + ) + + +async def test_endpoints_reject_unauthenticated_posts(tmp_path, monkeypatch): + """Only requests carrying the matching OAuth state are accepted. + + Exercises both /set_token and /log with missing, wrong, and valid state. + """ + monkeypatch.chdir(tmp_path) + + # Binding happens in create_server; the listen socket is up before the + # handler thread starts, so connections queue and no readiness sleep is + # needed. redirect_uri/client_id are required by the GET (index.html) path. + server = HTTPServer( + port=0, redirect_uri="http://localhost/callback", client_id="test-client" + ) + httpd = server.create_server(STATE, CODE_VERIFIER, DOMAIN) + port = httpd.server_address[1] + + results = {} + + def client(): + # DNS rebinding + results["rebind_get"] = _request( + port, "/", None, {"Host": "not-localhost.com"}, method="GET" + ) + results["rebind_post"] = _post( + port, + "/set_token", + ATTACKER_PAYLOAD, + {"X-Auth-State": STATE, "Host": "evil.com"}, + ) + # GET serves index.html with the OAuth params substituted in. + results["get"] = _request(port, "/anything", None, method="GET") + # /set_token: missing and wrong state are rejected. + results["set_missing"] = _post(port, "/set_token", ATTACKER_PAYLOAD) + results["set_wrong"] = _post( + port, "/set_token", ATTACKER_PAYLOAD, {"X-Auth-State": "not-the-state"} + ) + # Valid state but a non-JSON body -> graceful 400, not 500. + results["set_malformed"] = _request( + port, "/set_token", b"not json", {"X-Auth-State": STATE} + ) + # Unknown path -> 404. + results["unknown"] = _post(port, "/nope", {"x": 1}, {"X-Auth-State": STATE}) + # /log: missing and valid state. + results["log_missing"] = _post(port, "/log", {"msg": "x"}) + results["log_valid"] = _post( + port, "/log", {"msg": "x"}, {"X-Auth-State": STATE} + ) + # Valid /set_token last, to capture the token and unblock start(). + results["set_valid"] = _post( + port, "/set_token", {"access_token": "real"}, {"X-Auth-State": STATE} + ) + + t = threading.Thread(target=client, daemon=True) + t.start() + token_data = await server.start(STATE, CODE_VERIFIER, DOMAIN) + t.join(timeout=5) + + # DNS rebinding: forged Host is rejected on both GET and POST. + assert results["rebind_get"][0] == 403 + assert results["rebind_post"][0] == 403 + + # GET returns the page with the real state injected, placeholder gone. + assert results["get"][0] == 200 + assert STATE in results["get"][1] + assert "__PY_REPLACE_EXPECTED_STATE__" not in results["get"][1] + + assert results["set_missing"][0] == 403 + assert results["set_wrong"][0] == 403 + assert results["set_malformed"][0] == 400 + assert results["unknown"][0] == 404 + assert results["log_missing"][0] == 403 + assert results["log_valid"][0] == 200 + assert results["set_valid"][0] == 200 + + # Only the valid, state-bearing request was accepted. + assert token_data == {"access_token": "real"} + # The state-protected /log write happened for the valid request only. + assert os.path.exists(tmp_path / ".uipath" / ".error_log") + + +def test_server_binds_to_loopback_only(): + server = HTTPServer(port=0) + httpd = server.create_server(STATE, CODE_VERIFIER, DOMAIN) + try: + assert httpd.server_address[0] == "127.0.0.1" + finally: + httpd.server_close() diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 62ecc13a0..773d25d8a 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.81" +version = "2.10.82" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 1c469bcac21d0c746a15275f47133cc58fe58d48 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Tue, 16 Jun 2026 10:57:18 +0300 Subject: [PATCH 098/191] docs: restructure release notes into a linked catalog (#1721) --- packages/uipath/docs/core/release_notes.md | 137 +++------------------ packages/uipath/docs/index.md | 9 -- 2 files changed, 19 insertions(+), 127 deletions(-) diff --git a/packages/uipath/docs/core/release_notes.md b/packages/uipath/docs/core/release_notes.md index 325baad92..3bdbfb4a0 100644 --- a/packages/uipath/docs/core/release_notes.md +++ b/packages/uipath/docs/core/release_notes.md @@ -1,126 +1,27 @@ -# 🚨 Breaking Changes for UiPath Python SDK (v2.2.0+) - -**Release Date:** November 26, 2025 - -Version 2.2.0 of the **UiPath Python SDK** introduces several breaking changes affecting both the SDK and CLI. - -## Breaking Changes - -### 1. Minimum Python Version: 3.11+ Required - -**What's changing:** Python 3.10 is no longer supported for `uipath-python`, `uipath-langchain-python`, `uipath-llamaindex-python`. - -**Action required:** Upgrade to Python 3.11 or higher. - -### 2. Import Path Change - -**What's changing:** The `UiPath` class has moved from `uipath` to `uipath.platform`. - -**Action required:** Update your imports: - -```python -# Before -from uipath import UiPath -from uipath.models import Job, Asset, Queue -from uipath.models import Entity - -# After -from uipath.platform import UiPath, Job, Asset, Queue - -client = UiPath(...) -``` - -### 3. Transition to LangChain v1 (for `uipath-langchain` only) - -**What's changing:** Minimum required versions are now LangChain 1.0.0+ and LangGraph 1.0.0+ - -**Action required:** Review and update your code according to the [LangChain v1 Migration Guide](https://docs.langchain.com/oss/python/migrate/langchain-v1). - -**Note:** This only applies if you're using the `uipath-langchain` package. - -### 4. Configuration Architecture Redesign - -We've restructured how UiPath projects define and manage their resources: - -**`uipath.json` - Configuration File (Updated Purpose)** -- Previously contained entrypoints and bindings; now serves as a streamlined configuration file -- For **pure Python scripts**, define entrypoints in the `functions` section: - ```json - { - "functions": { - "entrypoint1": "src/main.py:", - "entrypoint2": "src/graph.py:runtime" - } - } - ``` -- For **LangGraph graphs**, define entrypoints in `langgraph.json` (same as before) -- For **LlamaIndex workflows**, define entrypoints in `llamaindex.json` (same as before) - -**`bindings.json` - Manual Binding Definitions (New)** -- Overridable resources (bindings) now stored in a separate file -- Bindings are **no longer automatically inferred** from code -- Must be manually defined by the user for now (we're working on an interactive configurator to simplify this process) - -**`entry-points.json` - I/O Schema (New)** -- Contains the input/output schema for your entrypoints -- Automatically inferred from code based on entrypoints defined in `llamaindex.json`/`langgraph.json`/`uipath.json` - -## Migration Guide - -### Stay on v2.1.x - -To avoid these breaking changes and keep your current setup, pin your dependency in `pyproject.toml`: - -```toml -"uipath>=2.1.x,<2.2.0" -``` - -**For `uipath-langchain` users:** To stay on the current version without LangChain v1: -```toml -"uipath-langchain>=0.0.x,<0.1.0" -``` - -### Migrate to v2.2.0+ +--- +title: Release Notes +--- -1. **Upgrade to v2.2.0+** - - Update the dependencies in `pyproject.toml` with: - ```toml - "uipath>=2.2.x,<2.3.0" - ``` +# Release Notes - Bounding the version to <2.3.0 prevents future breaking changes - - **For `uipath-langchain` users:** - To migrate to LangChain v1: - ```toml - "uipath-langchain>=0.1.0,<0.2.0" - ``` - **For `uipath-langchain`/`uipath-llamaindex` users:** - Make sure to also reference `uipath` in your `pyproject.toml` - future versions will no longer reference the main `uipath` CLI package as a dependency. +A catalog of the releases most relevant to UiPath Python SDK users (breaking changes and notable updates). Full details live in each GitHub release, linked below. -2. **Upgrade the Python version to 3.11+** - - In `pyproject.toml` specify the required Python version by adding or updating the following field: - ```toml - requires-python = ">=3.11" - ``` +## `uipath` (SDK & CLI) -3. **Update imports** - - Change `from uipath import UiPath` to `from uipath.platform import UiPath`. +| Release | Date | What's relevant | Notes | +|---------|------|-----------------|-------| +| [v2.10.0](https://github.com/UiPath/uipath-python/releases/tag/v2.10.0) | 2026-02-27 | Coded function schema `type` changed from `"agent"` to `"function"` | 🚨 Breaking | +| [v2.9.0](https://github.com/UiPath/uipath-python/releases/tag/v2.9.0) | 2026-02-23 | `platform` extracted to `uipath-platform`, context grounding contract changes, `uipath dev` defaults to `web` | 🚨 Breaking | +| [v2.2.0](https://github.com/UiPath/uipath-python/releases/tag/v2.2.0) | 2025-11-26 | Python 3.11+ required, `UiPath` import moved to `uipath.platform`, configuration architecture redesign | 🚨 Breaking | -4. **Review LangChain v1 changes (if using `uipath-langchain`)** - - Review the [LangChain v1 Migration Guide](https://docs.langchain.com/oss/python/migrate/langchain-v1) and update your code accordingly. +## `uipath-langchain` -5. **Update configuration files** - - - **Define your entrypoints** in `scripts` within `uipath.json` (not applicable if you already use `langgraph.json`/`llamaindex.json`) - - **Run `uipath init`** to automatically generate the `entry-points.json` I/O schema from your configuration - - **Create `bindings.json`** and manually define all overridable resources - - **Important:** If you update your script/agent code, run `uipath init` again to regenerate the I/O schema +| Release | Date | What's relevant | Notes | +|---------|------|-----------------|-------| +| [v0.10.0](https://github.com/UiPath/uipath-langchain-python/releases/tag/v0.10.0) | 2026-04-23 | Transport/auth split into new `uipath-llm-client` and `uipath-langchain-client` packages (legacy preserved) | Non-breaking | ---- +## `uipath-runtime` -For questions or issues, please open a ticket: [UiPath Python SDK Submit Issue](https://github.com/UiPath/uipath-python/issues) \ No newline at end of file +| Release | Date | What's relevant | Notes | +|---------|------|-----------------|-------| +| [v0.3.0](https://github.com/UiPath/uipath-runtime-python/releases/tag/v0.3.0) | 2025-12-18 | `UiPathDebugBridgeProtocol` renamed to `UiPathDebugProtocol` | 🚨 Breaking (protocol implementers only) | diff --git a/packages/uipath/docs/index.md b/packages/uipath/docs/index.md index 7ec047397..1a88e6b2a 100644 --- a/packages/uipath/docs/index.md +++ b/packages/uipath/docs/index.md @@ -2,15 +2,6 @@ title: Getting Started --- -
    -- __🚨 Breaking changes__ - - --- - - UiPath Python SDK v2.2.0+ will introduce **breaking changes** starting **November 26, 2025** - [See Details](./core/release_notes.md) -
    -

    What do you want to build?

    From 99b686b23cc322edaabc835f6eafa9f75752ce6c Mon Sep 17 00:00:00 2001 From: GabrielVasilescu04 Date: Tue, 16 Jun 2026 12:49:32 +0300 Subject: [PATCH 099/191] fix(platform): expose SecretValue on assets (#1719) --- packages/uipath-platform/pyproject.toml | 2 +- .../platform/orchestrator/_assets_service.py | 130 ++++++---- .../uipath/platform/orchestrator/assets.py | 2 + .../tests/services/test_assets_service.py | 100 ++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 4 +- .../src/uipath/_resources/SDK_REFERENCE.md | 228 ++++++++++++++++-- packages/uipath/uv.lock | 4 +- 8 files changed, 400 insertions(+), 72 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index a62398e5b..a0d332607 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.64" +version = "0.1.65" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_assets_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_assets_service.py index 7561dd4ea..c95ad1b49 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_assets_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_assets_service.py @@ -283,6 +283,14 @@ async def retrieve_async( else: return Asset.model_validate(response.json()["value"][0]) + def _ensure_robot_context(self) -> None: + try: + is_user = self._execution_context.robot_key is not None + except ValueError: + is_user = False + if not is_user: + raise ValueError("This method can only be used for robot assets.") + @resource_override(resource_type="asset") @traced( name="assets_credential", run_type="uipath", hide_input=True, hide_output=True @@ -294,11 +302,9 @@ def retrieve_credential( folder_key: Optional[str] = None, folder_path: Optional[str] = None, ) -> Optional[str]: - """Gets a specified Orchestrator credential. - - The robot id is retrieved from the execution context (`UIPATH_ROBOT_KEY` environment variable) + """Get the decrypted password of a Credential asset. - Related Activity: [Get Credential](https://docs.uipath.com/activities/other/latest/workflow/get-robot-credential) + The robot id is retrieved from the execution context (`UIPATH_ROBOT_KEY` environment variable). Args: name (str): The name of the credential asset. @@ -309,22 +315,10 @@ def retrieve_credential( Optional[str]: The decrypted credential password. Raises: - ValueError: If the method is called for a user asset. + ValueError: If called outside a robot context (no `UIPATH_ROBOT_KEY`). """ - try: - is_user = self._execution_context.robot_key is not None - except ValueError: - is_user = False - - if not is_user: - raise ValueError("This method can only be used for robot assets.") - - spec = self._retrieve_spec( - name, - folder_key=folder_key, - folder_path=folder_path, - ) - + self._ensure_robot_context() + spec = self._retrieve_spec(name, folder_key=folder_key, folder_path=folder_path) response = self.request( spec.method, url=spec.endpoint, @@ -333,10 +327,7 @@ def retrieve_credential( content=spec.content, headers=spec.headers, ) - - user_asset = UserAsset.model_validate(response.json()) - - return user_asset.credential_password + return UserAsset.model_validate(response.json()).credential_password @resource_override(resource_type="asset") @traced( @@ -349,11 +340,9 @@ async def retrieve_credential_async( folder_key: Optional[str] = None, folder_path: Optional[str] = None, ) -> Optional[str]: - """Asynchronously gets a specified Orchestrator credential. + """Asynchronously get the decrypted password of a Credential asset. - The robot id is retrieved from the execution context (`UIPATH_ROBOT_KEY` environment variable) - - Related Activity: [Get Credential](https://docs.uipath.com/activities/other/latest/workflow/get-robot-credential) + The robot id is retrieved from the execution context (`UIPATH_ROBOT_KEY` environment variable). Args: name (str): The name of the credential asset. @@ -364,23 +353,47 @@ async def retrieve_credential_async( Optional[str]: The decrypted credential password. Raises: - ValueError: If the method is called for a user asset. + ValueError: If called outside a robot context (no `UIPATH_ROBOT_KEY`). """ - try: - is_user = self._execution_context.robot_key is not None - except ValueError: - is_user = False + self._ensure_robot_context() + spec = self._retrieve_spec(name, folder_key=folder_key, folder_path=folder_path) + response = await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + json=spec.json, + content=spec.content, + headers=spec.headers, + ) + return UserAsset.model_validate(response.json()).credential_password - if not is_user: - raise ValueError("This method can only be used for robot assets.") + @resource_override(resource_type="asset") + @traced(name="assets_secret", run_type="uipath", hide_input=True, hide_output=True) + def retrieve_secret( + self, + name: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Optional[str]: + """Get the decrypted value of a Secret asset. - spec = self._retrieve_spec( - name, - folder_key=folder_key, - folder_path=folder_path, - ) + The robot id is retrieved from the execution context (`UIPATH_ROBOT_KEY` environment variable). - response = await self.request_async( + Args: + name (str): The name of the secret asset. + folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. + folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. + + Returns: + Optional[str]: The decrypted secret value. + + Raises: + ValueError: If called outside a robot context (no `UIPATH_ROBOT_KEY`). + """ + self._ensure_robot_context() + spec = self._retrieve_spec(name, folder_key=folder_key, folder_path=folder_path) + response = self.request( spec.method, url=spec.endpoint, params=spec.params, @@ -388,10 +401,43 @@ async def retrieve_credential_async( content=spec.content, headers=spec.headers, ) + return UserAsset.model_validate(response.json()).secret_value - user_asset = UserAsset.model_validate(response.json()) + @resource_override(resource_type="asset") + @traced(name="assets_secret", run_type="uipath", hide_input=True, hide_output=True) + async def retrieve_secret_async( + self, + name: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Optional[str]: + """Asynchronously get the decrypted value of a Secret asset. + + The robot id is retrieved from the execution context (`UIPATH_ROBOT_KEY` environment variable). + + Args: + name (str): The name of the secret asset. + folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. + folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. + + Returns: + Optional[str]: The decrypted secret value. - return user_asset.credential_password + Raises: + ValueError: If called outside a robot context (no `UIPATH_ROBOT_KEY`). + """ + self._ensure_robot_context() + spec = self._retrieve_spec(name, folder_key=folder_key, folder_path=folder_path) + response = await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + json=spec.json, + content=spec.content, + headers=spec.headers, + ) + return UserAsset.model_validate(response.json()).secret_value @traced(name="assets_update", run_type="uipath", hide_input=True, hide_output=True) def update( diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/assets.py b/packages/uipath-platform/src/uipath/platform/orchestrator/assets.py index 6ee89e806..056420225 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/assets.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/assets.py @@ -38,6 +38,7 @@ class UserAsset(BaseModel): int_value: Optional[int] = Field(default=None, alias="IntValue") credential_username: Optional[str] = Field(default=None, alias="CredentialUsername") credential_password: Optional[str] = Field(default=None, alias="CredentialPassword") + secret_value: Optional[str] = Field(default=None, alias="SecretValue") external_name: Optional[str] = Field(default=None, alias="ExternalName") credential_store_id: Optional[int] = Field(default=None, alias="CredentialStoreId") key_value_list: Optional[List[Dict[str, str]]] = Field( @@ -69,5 +70,6 @@ class Asset(BaseModel): int_value: Optional[int] = Field(default=None, alias="IntValue") credential_username: Optional[str] = Field(default=None, alias="CredentialUsername") credential_password: Optional[str] = Field(default=None, alias="CredentialPassword") + secret_value: Optional[str] = Field(default=None, alias="SecretValue") external_name: Optional[str] = Field(default=None, alias="ExternalName") credential_store_id: Optional[int] = Field(default=None, alias="CredentialStoreId") diff --git a/packages/uipath-platform/tests/services/test_assets_service.py b/packages/uipath-platform/tests/services/test_assets_service.py index 6e83c3b9d..bf28a6afc 100644 --- a/packages/uipath-platform/tests/services/test_assets_service.py +++ b/packages/uipath-platform/tests/services/test_assets_service.py @@ -417,6 +417,106 @@ async def test_retrieve_credential_async( == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AssetsService.retrieve_credential_async/{version}" ) + def test_retrieve_secret( + self, + httpx_mock: HTTPXMock, + service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """retrieve_secret returns SecretValue for Secret-type assets.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey", + status_code=200, + json={ + "Id": 1, + "Name": "Test Secret", + "ValueType": "Secret", + "SecretValue": "super-secret-value", + }, + ) + + secret = service.retrieve_secret(name="Test Secret") + + assert secret == "super-secret-value" + + async def test_retrieve_secret_async( + self, + httpx_mock: HTTPXMock, + service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """retrieve_secret_async returns SecretValue for Secret-type assets.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey", + status_code=200, + json={ + "Id": 1, + "Name": "Test Secret", + "ValueType": "Secret", + "SecretValue": "super-secret-value", + }, + ) + + secret = await service.retrieve_secret_async(name="Test Secret") + + assert secret == "super-secret-value" + + def test_retrieve_robot_asset_exposes_secret_value( + self, + httpx_mock: HTTPXMock, + service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """`retrieve` must expose SecretValue on UserAsset for Secret-type assets.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey", + status_code=200, + json={ + "Id": 1, + "Name": "Test Secret", + "ValueType": "Secret", + "SecretValue": "super-secret-value", + }, + ) + + asset = service.retrieve(name="Test Secret") + + assert isinstance(asset, UserAsset) + assert asset.value_type == "Secret" + assert asset.secret_value == "super-secret-value" + + async def test_retrieve_async_robot_asset_exposes_secret_value( + self, + httpx_mock: HTTPXMock, + service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """`retrieve_async` must expose SecretValue on UserAsset for Secret-type assets.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey", + status_code=200, + json={ + "Id": 1, + "Name": "Test Secret", + "ValueType": "Secret", + "SecretValue": "super-secret-value", + }, + ) + + asset = await service.retrieve_async(name="Test Secret") + + assert isinstance(asset, UserAsset) + assert asset.value_type == "Secret" + assert asset.secret_value == "super-secret-value" + def test_update( self, httpx_mock: HTTPXMock, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 3d9ac6c79..689662d4d 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.64" +version = "0.1.65" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 5b63bd838..c04ca9d6c 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.10.82" +version = "2.10.83" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.17, <0.6.0", "uipath-runtime>=0.11.0, <0.12.0", - "uipath-platform>=0.1.63, <0.2.0", + "uipath-platform>=0.1.65, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md b/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md index d92dcfded..02e9c0676 100644 --- a/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md +++ b/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md @@ -62,12 +62,18 @@ sdk.assets.retrieve(name: str, folder_key: Optional[str]=None, folder_path: Opti # Asynchronously retrieve an asset by its name. sdk.assets.retrieve_async(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.orchestrator.assets.UserAsset | uipath.platform.orchestrator.assets.Asset -# Gets a specified Orchestrator credential. +# Get the decrypted value of a Secret asset. sdk.assets.retrieve_credential(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> typing.Optional[str] -# Asynchronously gets a specified Orchestrator credential. +# Asynchronously get the decrypted value of a Secret asset. sdk.assets.retrieve_credential_async(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> typing.Optional[str] +# Get the decrypted value of a Secret asset. +sdk.assets.retrieve_secret(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> typing.Optional[str] + +# Asynchronously get the decrypted value of a Secret asset. +sdk.assets.retrieve_secret_async(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> typing.Optional[str] + # Update an asset's value. sdk.assets.update(robot_asset: uipath.platform.orchestrator.assets.UserAsset, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> httpx.Response @@ -113,6 +119,19 @@ sdk.attachments.upload_async(name: str, content: str | bytes | None=None, source ``` +### Automation Ops + +Automation Ops service + +```python +# Retrieve the deployed policy. +sdk.automation_ops.get_deployed_policy() -> dict[str, typing.Any] + +# Retrieve the deployed policy (async). +sdk.automation_ops.get_deployed_policy_async() -> dict[str, typing.Any] + +``` + ### Automation Tracker Automation Tracker service @@ -272,10 +291,10 @@ sdk.context_grounding.add_to_index(name: str, blob_file_path: str, content_type: sdk.context_grounding.add_to_index_async(name: str, blob_file_path: str, content_type: Optional[str]=None, content: Union[str, bytes, NoneType]=None, source_path: Optional[str]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None, ingest_data: bool=True) -> None # Create a new ephemeral context grounding index. -sdk.context_grounding.create_ephemeral_index(usage: uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex +sdk.context_grounding.create_ephemeral_index(usage: uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex # Create a new ephemeral context grounding index. -sdk.context_grounding.create_ephemeral_index_async(usage: uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex +sdk.context_grounding.create_ephemeral_index_async(usage: uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex # Create a new context grounding index. sdk.context_grounding.create_index(name: str, source: Union[uipath.platform.context_grounding.context_grounding_payloads.BucketSourceConfig, uipath.platform.context_grounding.context_grounding_payloads.GoogleDriveSourceConfig, uipath.platform.context_grounding.context_grounding_payloads.DropboxSourceConfig, uipath.platform.context_grounding.context_grounding_payloads.OneDriveSourceConfig, uipath.platform.context_grounding.context_grounding_payloads.ConfluenceSourceConfig], description: Optional[str]=None, extraction_strategy: Optional[str]=None, embeddings_enabled: Optional[bool]=None, is_encrypted: Optional[bool]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex @@ -326,7 +345,7 @@ sdk.context_grounding.list_indexes(folder_key: Optional[str]=None, folder_path: sdk.context_grounding.list_indexes_async(folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> typing.List[uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex] # Retrieve context grounding index information by its name. -sdk.context_grounding.retrieve(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex +sdk.context_grounding.retrieve(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None, include_system_indexes: bool=False) -> uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex # Retrieve all context grounding indexes across all folders. sdk.context_grounding.retrieve_across_folders(name: Optional[str]=None) -> typing.List[uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex] @@ -335,7 +354,7 @@ sdk.context_grounding.retrieve_across_folders(name: Optional[str]=None) -> typin sdk.context_grounding.retrieve_across_folders_async(name: Optional[str]=None) -> typing.List[uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex] # Asynchronously retrieve context grounding index information by its name. -sdk.context_grounding.retrieve_async(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex +sdk.context_grounding.retrieve_async(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None, include_system_indexes: bool=False) -> uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex # Retrieves a Batch Transform task status. sdk.context_grounding.retrieve_batch_transform(id: str, index_name: str | None=None) -> uipath.platform.context_grounding.context_grounding.BatchTransformResponse @@ -386,10 +405,10 @@ sdk.context_grounding.start_deep_rag_ephemeral(name: str, prompt: Annotated[str, sdk.context_grounding.start_deep_rag_ephemeral_async(name: str, prompt: Annotated[str, FieldInfo(annotation=NoneType, required=True, metadata=[MaxLen(max_length=250000)])], glob_pattern: Annotated[str, FieldInfo(annotation=NoneType, required=False, default='*', metadata=[MaxLen(max_length=512)])]="**", citation_mode: uipath.platform.context_grounding.context_grounding.DeepRagCreationResponse # Perform a unified search on a context grounding index. -sdk.context_grounding.unified_search(name: str, query: str, search_mode: uipath.platform.context_grounding.context_grounding.UnifiedQueryResult +sdk.context_grounding.unified_search(name: str, query: str, search_mode: uipath.platform.context_grounding.context_grounding.UnifiedQueryResult # Asynchronously perform a unified search on a context grounding index. -sdk.context_grounding.unified_search_async(name: str, query: str, search_mode: uipath.platform.context_grounding.context_grounding.UnifiedQueryResult +sdk.context_grounding.unified_search_async(name: str, query: str, search_mode: uipath.platform.context_grounding.context_grounding.UnifiedQueryResult ``` @@ -475,17 +494,77 @@ sdk.documents.start_ixp_extraction_validation_async(extraction_response: uipath. Entities service ```python +# Create a new entity with the given schema and return its id. +sdk.entities.create_entity(name: str, fields: List[uipath.platform.entities.entities.EntityCreateFieldOptions], options: Optional[uipath.platform.entities.entities.EntityCreateOptions]=None) -> str + +# Asynchronously create a new entity with the given schema. +sdk.entities.create_entity_async(name: str, fields: List[uipath.platform.entities.entities.EntityCreateFieldOptions], options: Optional[uipath.platform.entities.entities.EntityCreateOptions]=None) -> str + +# Remove the file attached to a File-type field on a record. +sdk.entities.delete_attachment(entity_id: str, record_id: str, field_name: str, expansion_level: Optional[int]=None) -> typing.Dict[str, typing.Any] + +# Asynchronously remove the file attached to a File-type field. +sdk.entities.delete_attachment_async(entity_id: str, record_id: str, field_name: str, expansion_level: Optional[int]=None) -> typing.Dict[str, typing.Any] + +# Delete an entity and all of its records. +sdk.entities.delete_entity(entity_id: str) -> None + +# Asynchronously delete an entity and all of its records. +sdk.entities.delete_entity_async(entity_id: str) -> None + +# Delete a single record by id. +sdk.entities.delete_record(entity_key: str, record_id: str) -> None + +# Asynchronously delete a single record by id. +sdk.entities.delete_record_async(entity_key: str, record_id: str) -> None + # Delete multiple records from an entity in a single batch operation. -sdk.entities.delete_records(entity_key: str, record_ids: List[str]) -> uipath.platform.entities.entities.EntityRecordsBatchResponse +sdk.entities.delete_records(entity_key: str, record_ids: List[str], fail_on_first: Optional[bool]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse # Asynchronously delete multiple records from an entity in a single batch operation. -sdk.entities.delete_records_async(entity_key: str, record_ids: List[str]) -> uipath.platform.entities.entities.EntityRecordsBatchResponse +sdk.entities.delete_records_async(entity_key: str, record_ids: List[str], fail_on_first: Optional[bool]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse + +# Download a file attached to a record and return its raw bytes. +sdk.entities.download_attachment(entity_id: str, record_id: str, field_name: str) -> bytes + +# Asynchronously download a file attached to a record. +sdk.entities.download_attachment_async(entity_id: str, record_id: str, field_name: str) -> bytes + +# Get the values of a choice set by its ID. +sdk.entities.get_choiceset_values(choiceset_id: str, start: Optional[int]=None, limit: Optional[int]=None) -> typing.List[uipath.platform.entities.entities.ChoiceSetValue] + +# Asynchronously get the values of a choice set by its ID. +sdk.entities.get_choiceset_values_async(choiceset_id: str, start: Optional[int]=None, limit: Optional[int]=None) -> typing.List[uipath.platform.entities.entities.ChoiceSetValue] + +# Fetch a single entity record by its id. +sdk.entities.get_record(entity_key: str, record_id: str, expansion_level: Optional[int]=None) -> uipath.platform.entities.entities.EntityRecord + +# Asynchronously fetch a single entity record by its id. +sdk.entities.get_record_async(entity_key: str, record_id: str, expansion_level: Optional[int]=None) -> uipath.platform.entities.entities.EntityRecord + +# Bulk-import records into an entity from a CSV file. +sdk.entities.import_records(entity_id: str, file: Union[bytes, bytearray, memoryview, NoneType]=None, file_path: Optional[str]=None) -> uipath.platform.entities.entities.EntityImportRecordsResponse + +# Asynchronously bulk-import records into an entity from a CSV file. +sdk.entities.import_records_async(entity_id: str, file: Union[bytes, bytearray, memoryview, NoneType]=None, file_path: Optional[str]=None) -> uipath.platform.entities.entities.EntityImportRecordsResponse + +# Insert a single record into an entity and return the inserted row. +sdk.entities.insert_record(entity_key: str, data: Any, expansion_level: Optional[int]=None) -> uipath.platform.entities.entities.EntityRecord + +# Asynchronously insert a single record into an entity. +sdk.entities.insert_record_async(entity_key: str, data: Any, expansion_level: Optional[int]=None) -> uipath.platform.entities.entities.EntityRecord # Insert multiple records into an entity in a single batch operation. -sdk.entities.insert_records(entity_key: str, records: List[Any], schema: Optional[Type[Any]]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse +sdk.entities.insert_records(entity_key: str, records: List[Any], schema: Optional[Type[Any]]=None, expansion_level: Optional[int]=None, fail_on_first: Optional[bool]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse # Asynchronously insert multiple records into an entity in a single batch operation. -sdk.entities.insert_records_async(entity_key: str, records: List[Any], schema: Optional[Type[Any]]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse +sdk.entities.insert_records_async(entity_key: str, records: List[Any], schema: Optional[Type[Any]]=None, expansion_level: Optional[int]=None, fail_on_first: Optional[bool]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse + +# List all choice sets in Data Service. +sdk.entities.list_choicesets() -> typing.List[uipath.platform.entities.entities.Entity] + +# Asynchronously list all choice sets in Data Service. +sdk.entities.list_choicesets_async() -> typing.List[uipath.platform.entities.entities.Entity] # List all entities in Data Service. sdk.entities.list_entities() -> typing.List[uipath.platform.entities.entities.Entity] @@ -494,10 +573,10 @@ sdk.entities.list_entities() -> typing.List[uipath.platform.entities.entities.En sdk.entities.list_entities_async() -> typing.List[uipath.platform.entities.entities.Entity] # List records from an entity with optional pagination and schema validation. -sdk.entities.list_records(entity_key: str, schema: Optional[Type[Any]]=None, start: Optional[int]=None, limit: Optional[int]=None) -> typing.List[uipath.platform.entities.entities.EntityRecord] +sdk.entities.list_records(entity_key: str, schema: Optional[Type[Any]]=None, start: Optional[int]=None, limit: Optional[int]=None, expansion_level: Optional[int]=None, filter: Optional[str]=None, orderby: Optional[str]=None, select: Optional[List[str]]=None, expand: Optional[List[str]]=None) -> uipath.platform.entities.entities.EntityRecordsListResponse # Asynchronously list records from an entity with optional pagination and schema validation. -sdk.entities.list_records_async(entity_key: str, schema: Optional[Type[Any]]=None, start: Optional[int]=None, limit: Optional[int]=None) -> typing.List[uipath.platform.entities.entities.EntityRecord] +sdk.entities.list_records_async(entity_key: str, schema: Optional[Type[Any]]=None, start: Optional[int]=None, limit: Optional[int]=None, expansion_level: Optional[int]=None, filter: Optional[str]=None, orderby: Optional[str]=None, select: Optional[List[str]]=None, expand: Optional[List[str]]=None) -> uipath.platform.entities.entities.EntityRecordsListResponse # Query entity records using a validated SQL query. sdk.entities.query_entity_records(sql_query: str) -> typing.List[typing.Dict[str, typing.Any]] @@ -506,10 +585,10 @@ sdk.entities.query_entity_records(sql_query: str) -> typing.List[typing.Dict[str sdk.entities.query_entity_records_async(sql_query: str) -> typing.List[typing.Dict[str, typing.Any]] # Resolve an agent entity set, applying resource overwrites. -sdk.entities.resolve_entity_set(items: list[uipath.platform.entities.entities.DataFabricEntityItem]) -> uipath.platform.entities.entities.EntitySetResolution +sdk.entities.resolve_entity_set(items: List[uipath.platform.entities.entities.DataFabricEntityItem]) -> uipath.platform.entities.entities.EntitySetResolution # Resolve an agent entity set, applying resource overwrites. -sdk.entities.resolve_entity_set_async(items: list[uipath.platform.entities.entities.DataFabricEntityItem]) -> uipath.platform.entities.entities.EntitySetResolution +sdk.entities.resolve_entity_set_async(items: List[uipath.platform.entities.entities.DataFabricEntityItem]) -> uipath.platform.entities.entities.EntitySetResolution # Retrieve an entity by its key. sdk.entities.retrieve(entity_key: str) -> uipath.platform.entities.entities.Entity @@ -523,12 +602,38 @@ sdk.entities.retrieve_by_name(entity_name: str, folder_key: Optional[str]=None) # Asynchronously retrieve an entity by its name. sdk.entities.retrieve_by_name_async(entity_name: str, folder_key: Optional[str]=None) -> uipath.platform.entities.entities.Entity +# Retrieve records with structured filters, sorting, expansion, joins, and aggregates. +sdk.entities.retrieve_records(entity_key: str, filter_group: Optional[uipath.platform.entities.entities.EntityQueryFilterGroup]=None, sort_options: Optional[List[uipath.platform.entities.entities.EntityQuerySortOption]]=None, selected_fields: Optional[List[str]]=None, expansions: Optional[List[Any]]=None, expansion_level: Optional[int]=None, aggregates: Optional[List[uipath.platform.entities.entities.EntityAggregate]]=None, group_by: Optional[List[str]]=None, joins: Optional[List[uipath.platform.entities.entities.EntityJoin]]=None, binnings: Optional[List[uipath.platform.entities.entities.EntityBinning]]=None, start: Optional[int]=None, limit: Optional[int]=None) -> uipath.platform.entities.entities.RetrieveEntityRecordsResponse + +# Asynchronously retrieve records with structured filters, sorting, expansion, joins, and aggregates. +sdk.entities.retrieve_records_async(entity_key: str, filter_group: Optional[uipath.platform.entities.entities.EntityQueryFilterGroup]=None, sort_options: Optional[List[uipath.platform.entities.entities.EntityQuerySortOption]]=None, selected_fields: Optional[List[str]]=None, expansions: Optional[List[Any]]=None, expansion_level: Optional[int]=None, aggregates: Optional[List[uipath.platform.entities.entities.EntityAggregate]]=None, group_by: Optional[List[str]]=None, joins: Optional[List[uipath.platform.entities.entities.EntityJoin]]=None, binnings: Optional[List[uipath.platform.entities.entities.EntityBinning]]=None, start: Optional[int]=None, limit: Optional[int]=None) -> uipath.platform.entities.entities.RetrieveEntityRecordsResponse + +# Update an entity's display name, description, and/or RBAC flag. +sdk.entities.update_entity_metadata(entity_id: str, metadata: Union[uipath.platform.entities.entities.EntityMetadataUpdateOptions, Dict[str, Any]]) -> None + +# Asynchronously update an entity's display name, description, and/or RBAC flag. +sdk.entities.update_entity_metadata_async(entity_id: str, metadata: Union[uipath.platform.entities.entities.EntityMetadataUpdateOptions, Dict[str, Any]]) -> None + +# Update a single record by id and return the updated row. +sdk.entities.update_record(entity_key: str, record_id: str, data: Any, expansion_level: Optional[int]=None) -> uipath.platform.entities.entities.EntityRecord + +# Asynchronously update a single record by id. +sdk.entities.update_record_async(entity_key: str, record_id: str, data: Any, expansion_level: Optional[int]=None) -> uipath.platform.entities.entities.EntityRecord + # Update multiple records in an entity in a single batch operation. -sdk.entities.update_records(entity_key: str, records: List[Any], schema: Optional[Type[Any]]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse +sdk.entities.update_records(entity_key: str, records: List[Any], schema: Optional[Type[Any]]=None, expansion_level: Optional[int]=None, fail_on_first: Optional[bool]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse # Asynchronously update multiple records in an entity in a single batch operation. -sdk.entities.update_records_async(entity_key: str, records: List[Any], schema: Optional[Type[Any]]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse +sdk.entities.update_records_async(entity_key: str, records: List[Any], schema: Optional[Type[Any]]=None, expansion_level: Optional[int]=None, fail_on_first: Optional[bool]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse +# Upload a file attachment to a File-type field on a record. +sdk.entities.upload_attachment(entity_id: str, record_id: str, field_name: str, file: Union[bytes, bytearray, memoryview, NoneType]=None, file_path: Optional[str]=None, expansion_level: Optional[int]=None) -> typing.Dict[str, typing.Any] + +# Asynchronously upload a file attachment to a File-type field on a record. +sdk.entities.upload_attachment_async(entity_id: str, record_id: str, field_name: str, file: Union[bytes, bytearray, memoryview, NoneType]=None, file_path: Optional[str]=None, expansion_level: Optional[int]=None) -> typing.Dict[str, typing.Any] + +# Parse a batch response, optionally validating success records against ``schema``. +sdk.entities.validate_entity_batch(batch_response: httpx.Response, schema: Optional[Type[Any]]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse ``` @@ -632,6 +737,12 @@ sdk.jobs.retrieve_api_payload_async(inbox_id: str) -> typing.Any # Asynchronously retrieve a job identified by its key. sdk.jobs.retrieve_async(job_key: str, folder_key: str | None=None, folder_path: str | None=None, process_name: str | None=None) -> uipath.platform.orchestrator.job.Job +# Fetch payload data for Integration Services (Inbox) triggers. +sdk.jobs.retrieve_inbox_payload(inbox_id: str) -> typing.Any + +# Asynchronously fetch payload data for Integration Services (Inbox) triggers. +sdk.jobs.retrieve_inbox_payload_async(inbox_id: str) -> typing.Any + # Stop one or more jobs with specified strategy. sdk.jobs.stop(job_keys: List[str], strategy: str="SoftStop", folder_path: Optional[str]=None, folder_key: Optional[str]=None) -> None @@ -646,7 +757,7 @@ Llm service ```python # Generate chat completions using UiPath's normalized LLM Gateway API. -sdk.llm.chat_completions(messages: list[dict[str, str]] | list[tuple[str, str]], model: str="gpt-4.1-mini-2025-04-14", max_tokens: int=4096, temperature: float=0, n: int=1, frequency_penalty: float=0, presence_penalty: float=0, top_p: float | None=1, top_k: int | None=None, tools: list[uipath.platform.chat.llm_gateway.ToolDefinition] | None=None, tool_choice: Union[uipath.platform.chat.llm_gateway.AutoToolChoice, uipath.platform.chat.llm_gateway.RequiredToolChoice, uipath.platform.chat.llm_gateway.SpecificToolChoice, Literal['auto', 'none'], NoneType]=None, response_format: dict[str, Any] | type[pydantic.main.BaseModel] | None=None, api_version: str="2024-08-01-preview") +sdk.llm.chat_completions(messages: list[dict[str, str]] | list[tuple[str, str]], model: str="gpt-4.1-mini-2025-04-14", max_tokens: int=4096, temperature: float=0, n: int=1, frequency_penalty: float=0, presence_penalty: float=0, top_p: float | None=1, top_k: int | None=None, tools: list[uipath.platform.chat.llm_gateway.ToolDefinition | dict[str, Any]] | None=None, tool_choice: Union[uipath.platform.chat.llm_gateway.AutoToolChoice, uipath.platform.chat.llm_gateway.RequiredToolChoice, uipath.platform.chat.llm_gateway.SpecificToolChoice, Literal['auto', 'none'], NoneType]=None, response_format: dict[str, Any] | type[pydantic.main.BaseModel] | None=None, api_version: str="2024-08-01-preview") ``` @@ -682,6 +793,43 @@ sdk.mcp.retrieve_async(slug: str, folder_path: str | None=None) -> uipath.platfo ``` +### Memory + +Memory service + +```python +# Create a new memory space. +sdk.memory.create(name: str, description: Optional[str]=None, is_encrypted: Optional[bool]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.memory.memory.MemorySpace + +# Asynchronously create a new memory space. +sdk.memory.create_async(name: str, description: Optional[str]=None, is_encrypted: Optional[bool]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.memory.memory.MemorySpace + +# Ingest a resolved escalation outcome into memory. +sdk.memory.escalation_ingest(memory_space_id: str, request: uipath.platform.memory.memory.EscalationMemoryIngestRequest, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> None + +# Asynchronously ingest a resolved escalation outcome into memory. +sdk.memory.escalation_ingest_async(memory_space_id: str, request: uipath.platform.memory.memory.EscalationMemoryIngestRequest, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> None + +# Search escalation memory for previously resolved outcomes. +sdk.memory.escalation_search(memory_space_id: str, request: uipath.platform.memory.memory.MemorySearchRequest, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.memory.memory.EscalationMemorySearchResponse + +# Asynchronously search escalation memory for previously resolved outcomes. +sdk.memory.escalation_search_async(memory_space_id: str, request: uipath.platform.memory.memory.MemorySearchRequest, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.memory.memory.EscalationMemorySearchResponse + +# List memory spaces with optional OData query parameters. +sdk.memory.list(filter: Optional[str]=None, orderby: Optional[str]=None, top: Optional[int]=None, skip: Optional[int]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.memory.memory.MemorySpaceListResponse + +# Asynchronously list memory spaces. +sdk.memory.list_async(filter: Optional[str]=None, orderby: Optional[str]=None, top: Optional[int]=None, skip: Optional[int]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.memory.memory.MemorySpaceListResponse + +# Search a memory space via LLMOps. +sdk.memory.search(memory_space_id: str, request: uipath.platform.memory.memory.MemorySearchRequest, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.memory.memory.MemorySearchResponse + +# Asynchronously search a memory space via LLMOps. +sdk.memory.search_async(memory_space_id: str, request: uipath.platform.memory.memory.MemorySearchRequest, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.memory.memory.MemorySearchResponse + +``` + ### Orchestrator Setup Orchestrator Setup service @@ -695,16 +843,29 @@ sdk.orchestrator_setup.enable_first_run_async() -> None ``` +### Pii Detection + +Pii Detection service + +```python +# Detect PII in the provided documents and/or files. +sdk.pii_detection.detect_pii(request: uipath.platform.pii_detection.pii_detection.PiiDetectionRequest) -> uipath.platform.pii_detection.pii_detection.PiiDetectionResponse + +# Detect PII in the provided documents and/or files (async). +sdk.pii_detection.detect_pii_async(request: uipath.platform.pii_detection.pii_detection.PiiDetectionRequest) -> uipath.platform.pii_detection.pii_detection.PiiDetectionResponse + +``` + ### Processes Processes service ```python # Start execution of a process by its name. -sdk.processes.invoke(name: str, input_arguments: Optional[Dict[str, Any]]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None, attachments: Optional[list[uipath.platform.attachments.attachments.Attachment]]=None, parent_operation_id: Optional[str]=None, **kwargs) -> uipath.platform.orchestrator.job.Job +sdk.processes.invoke(name: str, input_arguments: Optional[Dict[str, Any]]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None, attachments: Optional[list[uipath.platform.attachments.attachments.Attachment]]=None, parent_operation_id: Optional[str]=None, run_as_me: Optional[bool]=None, **kwargs) -> uipath.platform.orchestrator.job.Job # Asynchronously start execution of a process by its name. -sdk.processes.invoke_async(name: str, input_arguments: Optional[Dict[str, Any]]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None, attachments: Optional[list[uipath.platform.attachments.attachments.Attachment]]=None, parent_operation_id: Optional[str]=None, **kwargs) -> uipath.platform.orchestrator.job.Job +sdk.processes.invoke_async(name: str, input_arguments: Optional[Dict[str, Any]]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None, attachments: Optional[list[uipath.platform.attachments.attachments.Attachment]]=None, parent_operation_id: Optional[str]=None, run_as_me: Optional[bool]=None, **kwargs) -> uipath.platform.orchestrator.job.Job ``` @@ -779,19 +940,32 @@ Resource Catalog service sdk.resource_catalog.list(resource_types: Optional[List[uipath.platform.resource_catalog.resource_catalog.ResourceType]]=None, resource_sub_types: Optional[List[str]]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None, page_size: int=20) -> typing.Iterator[uipath.platform.resource_catalog.resource_catalog.Resource] # Asynchronously get tenant scoped resources and folder scoped resources (accessible to the user). -sdk.resource_catalog.list_async(resource_types: Optional[List[uipath.platform.resource_catalog.resource_catalog.ResourceType]]=None, resource_sub_types: Optional[List[str]]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None, page_size: int=20) -> typing.AsyncIterator[uipath.platform.resource_catalog.resource_catalog.Resource] +sdk.resource_catalog.list_async(resource_types: Optional[List[uipath.platform.resource_catalog.resource_catalog.ResourceType]]=None, resource_sub_types: Optional[List[str]]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None, page_size: int=20) -> typing.AsyncGenerator[uipath.platform.resource_catalog.resource_catalog.Resource, NoneType] # Get resources of a specific type (tenant scoped or folder scoped). sdk.resource_catalog.list_by_type(resource_type: typing.Iterator[uipath.platform.resource_catalog.resource_catalog.Resource] # Asynchronously get resources of a specific type (tenant scoped or folder scoped). -sdk.resource_catalog.list_by_type_async(resource_type: typing.AsyncIterator[uipath.platform.resource_catalog.resource_catalog.Resource] +sdk.resource_catalog.list_by_type_async(resource_type: typing.AsyncGenerator[uipath.platform.resource_catalog.resource_catalog.Resource, NoneType] # Search for tenant scoped resources and folder scoped resources (accessible to the user). sdk.resource_catalog.search(name: Optional[str]=None, resource_types: Optional[List[uipath.platform.resource_catalog.resource_catalog.ResourceType]]=None, resource_sub_types: Optional[List[str]]=None, page_size: int=20) -> typing.Iterator[uipath.platform.resource_catalog.resource_catalog.Resource] # Asynchronously search for tenant scoped resources and folder scoped resources (accessible to the user). -sdk.resource_catalog.search_async(name: Optional[str]=None, resource_types: Optional[List[uipath.platform.resource_catalog.resource_catalog.ResourceType]]=None, resource_sub_types: Optional[List[str]]=None, page_size: int=20) -> typing.AsyncIterator[uipath.platform.resource_catalog.resource_catalog.Resource] +sdk.resource_catalog.search_async(name: Optional[str]=None, resource_types: Optional[List[uipath.platform.resource_catalog.resource_catalog.ResourceType]]=None, resource_sub_types: Optional[List[str]]=None, page_size: int=20) -> typing.AsyncGenerator[uipath.platform.resource_catalog.resource_catalog.Resource, NoneType] + +``` + +### Semantic Proxy + +Semantic Proxy service + +```python +# Detect PII in the provided documents and/or files. +sdk.semantic_proxy.detect_pii(request: uipath.platform.semantic_proxy.semantic_proxy.PiiDetectionRequest) -> uipath.platform.semantic_proxy.semantic_proxy.PiiDetectionResponse + +# Detect PII in the provided documents and/or files (async). +sdk.semantic_proxy.detect_pii_async(request: uipath.platform.semantic_proxy.semantic_proxy.PiiDetectionRequest) -> uipath.platform.semantic_proxy.semantic_proxy.PiiDetectionResponse ``` @@ -806,6 +980,12 @@ sdk.tasks.create(title: str, data: Optional[Dict[str, Any]]=None, app_name: Opti # Creates a new action asynchronously. sdk.tasks.create_async(title: str, data: Optional[Dict[str, Any]]=None, app_name: Optional[str]=None, app_key: Optional[str]=None, app_folder_path: Optional[str]=None, app_folder_key: Optional[str]=None, assignee: Optional[str]=None, recipient: Optional[uipath.platform.action_center.tasks.TaskRecipient]=None, priority: Optional[str]=None, labels: Optional[List[str]]=None, is_actionable_message_enabled: Optional[bool]=None, actionable_message_metadata: Optional[Dict[str, Any]]=None, source_name: str="Agent") -> uipath.platform.action_center.tasks.Task +# Create a new QuickForm task synchronously. +sdk.tasks.create_quickform(title: str, task_schema_key: str, schema: Dict[str, Any], data: Optional[Dict[str, Any]]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None, assignee: Optional[str]=None, recipient: Optional[uipath.platform.action_center.tasks.TaskRecipient]=None, priority: Optional[str]=None, labels: Optional[List[str]]=None, is_actionable_message_enabled: Optional[bool]=None, actionable_message_metadata: Optional[Dict[str, Any]]=None, creator_job_key: Optional[str]=None, source_name: str="Agent") -> uipath.platform.action_center.tasks.Task + +# Creates a new QuickForm task asynchronously. +sdk.tasks.create_quickform_async(title: str, task_schema_key: str, schema: Dict[str, Any], data: Optional[Dict[str, Any]]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None, assignee: Optional[str]=None, recipient: Optional[uipath.platform.action_center.tasks.TaskRecipient]=None, priority: Optional[str]=None, labels: Optional[List[str]]=None, is_actionable_message_enabled: Optional[bool]=None, actionable_message_metadata: Optional[Dict[str, Any]]=None, creator_job_key: Optional[str]=None, source_name: str="Agent") -> uipath.platform.action_center.tasks.Task + # Retrieves a task by its key synchronously. sdk.tasks.retrieve(action_key: str, app_folder_path: Optional[str]=None, app_folder_key: Optional[str]=None, app_name: str | None=None) -> uipath.platform.action_center.tasks.Task diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 773d25d8a..1c1556c06 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.82" +version = "2.10.83" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.64" +version = "0.1.65" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 97ad2b6577195e6ee0cc117ffaf37c091388d76e Mon Sep 17 00:00:00 2001 From: GabrielVasilescu04 Date: Tue, 16 Jun 2026 18:15:12 +0300 Subject: [PATCH 100/191] feat(assets): add support for AllowDirectApiAccess (#1722) --- packages/uipath-platform/pyproject.toml | 2 +- .../platform/orchestrator/_assets_service.py | 146 ++++++++++++-- .../uipath/platform/orchestrator/assets.py | 6 + .../tests/services/test_assets_service.py | 180 +++++++++++++++++- packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 4 +- packages/uipath/uv.lock | 4 +- 7 files changed, 313 insertions(+), 31 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index a0d332607..6a50d6e4b 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.65" +version = "0.1.66" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_assets_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_assets_service.py index c95ad1b49..2e673fb7c 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_assets_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_assets_service.py @@ -283,13 +283,55 @@ async def retrieve_async( else: return Asset.model_validate(response.json()["value"][0]) - def _ensure_robot_context(self) -> None: + def _resolve_robot_key( + self, + name: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Optional[str]: + """Return the robot key, or ``None`` if the asset opts into direct API access. + + Raises ``ValueError`` when no robot key is available and ``AllowDirectApiAccess`` + is not enabled on the asset. + """ try: - is_user = self._execution_context.robot_key is not None + robot_key = self._execution_context.robot_key except ValueError: - is_user = False - if not is_user: - raise ValueError("This method can only be used for robot assets.") + robot_key = None + + if robot_key is None: + asset = self.retrieve( + name=name, folder_key=folder_key, folder_path=folder_path + ) + if not asset.allow_direct_api_access: + raise ValueError( + f"No robot key available and 'AllowDirectApiAccess' is disabled for asset '{name}'." + ) + return robot_key + + async def _resolve_robot_key_async( + self, + name: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Optional[str]: + """Async variant of :meth:`_resolve_robot_key`.""" + try: + robot_key = self._execution_context.robot_key + except ValueError: + robot_key = None + + if robot_key is None: + asset = await self.retrieve_async( + name=name, folder_key=folder_key, folder_path=folder_path + ) + if not asset.allow_direct_api_access: + raise ValueError( + f"No robot key available and 'AllowDirectApiAccess' is disabled for asset '{name}'." + ) + return robot_key @resource_override(resource_type="asset") @traced( @@ -305,6 +347,10 @@ def retrieve_credential( """Get the decrypted password of a Credential asset. The robot id is retrieved from the execution context (`UIPATH_ROBOT_KEY` environment variable). + If no robot key is available, the asset's `AllowDirectApiAccess` flag is checked: when + enabled, the credential is fetched without a robot key; otherwise a `ValueError` is raised. + + Related Activity: [Get Credential](https://docs.uipath.com/activities/other/latest/workflow/get-robot-credential) Args: name (str): The name of the credential asset. @@ -315,10 +361,18 @@ def retrieve_credential( Optional[str]: The decrypted credential password. Raises: - ValueError: If called outside a robot context (no `UIPATH_ROBOT_KEY`). + ValueError: If no robot key is available and the asset does not have `AllowDirectApiAccess` enabled. """ - self._ensure_robot_context() - spec = self._retrieve_spec(name, folder_key=folder_key, folder_path=folder_path) + robot_key = self._resolve_robot_key( + name, folder_key=folder_key, folder_path=folder_path + ) + + spec = self._retrieve_credential_spec( + name, + robot_key=robot_key, + folder_key=folder_key, + folder_path=folder_path, + ) response = self.request( spec.method, url=spec.endpoint, @@ -343,6 +397,10 @@ async def retrieve_credential_async( """Asynchronously get the decrypted password of a Credential asset. The robot id is retrieved from the execution context (`UIPATH_ROBOT_KEY` environment variable). + If no robot key is available, the asset's `AllowDirectApiAccess` flag is checked: when + enabled, the credential is fetched without a robot key; otherwise a `ValueError` is raised. + + Related Activity: [Get Credential](https://docs.uipath.com/activities/other/latest/workflow/get-robot-credential) Args: name (str): The name of the credential asset. @@ -353,10 +411,18 @@ async def retrieve_credential_async( Optional[str]: The decrypted credential password. Raises: - ValueError: If called outside a robot context (no `UIPATH_ROBOT_KEY`). + ValueError: If no robot key is available and the asset does not have `AllowDirectApiAccess` enabled. """ - self._ensure_robot_context() - spec = self._retrieve_spec(name, folder_key=folder_key, folder_path=folder_path) + robot_key = await self._resolve_robot_key_async( + name, folder_key=folder_key, folder_path=folder_path + ) + + spec = self._retrieve_credential_spec( + name, + robot_key=robot_key, + folder_key=folder_key, + folder_path=folder_path, + ) response = await self.request_async( spec.method, url=spec.endpoint, @@ -379,6 +445,8 @@ def retrieve_secret( """Get the decrypted value of a Secret asset. The robot id is retrieved from the execution context (`UIPATH_ROBOT_KEY` environment variable). + If no robot key is available, the asset's `AllowDirectApiAccess` flag is checked: when + enabled, the secret is fetched without a robot key; otherwise a `ValueError` is raised. Args: name (str): The name of the secret asset. @@ -389,10 +457,18 @@ def retrieve_secret( Optional[str]: The decrypted secret value. Raises: - ValueError: If called outside a robot context (no `UIPATH_ROBOT_KEY`). + ValueError: If no robot key is available and the asset does not have `AllowDirectApiAccess` enabled. """ - self._ensure_robot_context() - spec = self._retrieve_spec(name, folder_key=folder_key, folder_path=folder_path) + robot_key = self._resolve_robot_key( + name, folder_key=folder_key, folder_path=folder_path + ) + + spec = self._retrieve_credential_spec( + name, + robot_key=robot_key, + folder_key=folder_key, + folder_path=folder_path, + ) response = self.request( spec.method, url=spec.endpoint, @@ -415,6 +491,8 @@ async def retrieve_secret_async( """Asynchronously get the decrypted value of a Secret asset. The robot id is retrieved from the execution context (`UIPATH_ROBOT_KEY` environment variable). + If no robot key is available, the asset's `AllowDirectApiAccess` flag is checked: when + enabled, the secret is fetched without a robot key; otherwise a `ValueError` is raised. Args: name (str): The name of the secret asset. @@ -425,10 +503,18 @@ async def retrieve_secret_async( Optional[str]: The decrypted secret value. Raises: - ValueError: If called outside a robot context (no `UIPATH_ROBOT_KEY`). + ValueError: If no robot key is available and the asset does not have `AllowDirectApiAccess` enabled. """ - self._ensure_robot_context() - spec = self._retrieve_spec(name, folder_key=folder_key, folder_path=folder_path) + robot_key = await self._resolve_robot_key_async( + name, folder_key=folder_key, folder_path=folder_path + ) + + spec = self._retrieve_credential_spec( + name, + robot_key=robot_key, + folder_key=folder_key, + folder_path=folder_path, + ) response = await self.request_async( spec.method, url=spec.endpoint, @@ -559,6 +645,32 @@ def _retrieve_spec( }, ) + def _retrieve_credential_spec( + self, + name: str, + *, + robot_key: Optional[str], + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + body: Dict[str, Any] = { + "assetName": name, + "supportsCredentialsProxyDisconnected": True, + } + if robot_key is not None: + body["robotKey"] = robot_key + + return RequestSpec( + method="POST", + endpoint=Endpoint( + "/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey" + ), + json=body, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + def _update_spec( self, robot_asset: UserAsset, diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/assets.py b/packages/uipath-platform/src/uipath/platform/orchestrator/assets.py index 056420225..122821029 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/assets.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/assets.py @@ -47,6 +47,9 @@ class UserAsset(BaseModel): connection_data: Optional[CredentialsConnectionData] = Field( default=None, alias="ConnectionData" ) + allow_direct_api_access: Optional[bool] = Field( + default=None, alias="AllowDirectApiAccess" + ) id: Optional[int] = Field(default=None, alias="Id") @@ -73,3 +76,6 @@ class Asset(BaseModel): secret_value: Optional[str] = Field(default=None, alias="SecretValue") external_name: Optional[str] = Field(default=None, alias="ExternalName") credential_store_id: Optional[int] = Field(default=None, alias="CredentialStoreId") + allow_direct_api_access: Optional[bool] = Field( + default=None, alias="AllowDirectApiAccess" + ) diff --git a/packages/uipath-platform/tests/services/test_assets_service.py b/packages/uipath-platform/tests/services/test_assets_service.py index bf28a6afc..4b01ed210 100644 --- a/packages/uipath-platform/tests/services/test_assets_service.py +++ b/packages/uipath-platform/tests/services/test_assets_service.py @@ -362,20 +362,94 @@ def test_retrieve_credential( == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AssetsService.retrieve_credential/{version}" ) - def test_retrieve_credential_user_asset( + def test_retrieve_credential_no_robot_key_direct_access_disabled( self, - service: AssetsService, - monkeypatch: pytest.MonkeyPatch, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, config: UiPathApiConfig, + monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) + service = AssetsService( + config=config, + execution_context=UiPathExecutionContext(), + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetFiltered?$filter=Name eq 'Test Credential'&$top=1", + status_code=200, + json={ + "value": [ + { + "Key": "asset-key", + "Name": "Test Credential", + "ValueType": "Credential", + "AllowDirectApiAccess": False, + } + ] + }, + ) + with pytest.raises(ValueError): - monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) - service = AssetsService( - config=config, - execution_context=UiPathExecutionContext(), - ) service.retrieve_credential(name="Test Credential") + def test_retrieve_credential_no_robot_key_direct_access_enabled( + self, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + config: UiPathApiConfig, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + import json + + monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) + service = AssetsService( + config=config, + execution_context=UiPathExecutionContext(), + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetFiltered?$filter=Name eq 'Test Credential'&$top=1", + status_code=200, + json={ + "value": [ + { + "Key": "asset-key", + "Name": "Test Credential", + "ValueType": "Credential", + "AllowDirectApiAccess": True, + } + ] + }, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey", + status_code=200, + json={ + "id": 1, + "name": "Test Credential", + "credential_username": "test-user", + "credential_password": "test-password", + }, + ) + + credential = service.retrieve_credential(name="Test Credential") + + assert credential == "test-password" + + sent_requests = httpx_mock.get_requests() + assert len(sent_requests) == 2 + credential_request = sent_requests[1] + assert credential_request.method == "POST" + request_body = json.loads(credential_request.content) + assert request_body["assetName"] == "Test Credential" + assert request_body["supportsCredentialsProxyDisconnected"] is True + assert "robotKey" not in request_body + async def test_retrieve_credential_async( self, httpx_mock: HTTPXMock, @@ -417,6 +491,96 @@ async def test_retrieve_credential_async( == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AssetsService.retrieve_credential_async/{version}" ) + @pytest.mark.anyio + async def test_retrieve_credential_async_no_robot_key_direct_access_disabled( + self, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + config: UiPathApiConfig, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) + service = AssetsService( + config=config, + execution_context=UiPathExecutionContext(), + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetFiltered?$filter=Name eq 'Test Credential'&$top=1", + status_code=200, + json={ + "value": [ + { + "Key": "asset-key", + "Name": "Test Credential", + "ValueType": "Credential", + "AllowDirectApiAccess": False, + } + ] + }, + ) + + with pytest.raises(ValueError): + await service.retrieve_credential_async(name="Test Credential") + + @pytest.mark.anyio + async def test_retrieve_credential_async_no_robot_key_direct_access_enabled( + self, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + config: UiPathApiConfig, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + import json + + monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) + service = AssetsService( + config=config, + execution_context=UiPathExecutionContext(), + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetFiltered?$filter=Name eq 'Test Credential'&$top=1", + status_code=200, + json={ + "value": [ + { + "Key": "asset-key", + "Name": "Test Credential", + "ValueType": "Credential", + "AllowDirectApiAccess": True, + } + ] + }, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey", + status_code=200, + json={ + "id": 1, + "name": "Test Credential", + "credential_username": "test-user", + "credential_password": "test-password", + }, + ) + + credential = await service.retrieve_credential_async(name="Test Credential") + + assert credential == "test-password" + + sent_requests = httpx_mock.get_requests() + assert len(sent_requests) == 2 + credential_request = sent_requests[1] + assert credential_request.method == "POST" + request_body = json.loads(credential_request.content) + assert request_body["assetName"] == "Test Credential" + assert request_body["supportsCredentialsProxyDisconnected"] is True + assert "robotKey" not in request_body + def test_retrieve_secret( self, httpx_mock: HTTPXMock, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 689662d4d..c38310808 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.65" +version = "0.1.66" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index c04ca9d6c..7b32baacf 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.10.83" +version = "2.10.84" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.17, <0.6.0", "uipath-runtime>=0.11.0, <0.12.0", - "uipath-platform>=0.1.65, <0.2.0", + "uipath-platform>=0.1.66, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 1c1556c06..6f903c231 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.83" +version = "2.10.84" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.65" +version = "0.1.66" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From b0d85dbb1d3f401354db3fa9bf6d7933e4cb86d2 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Tue, 16 Jun 2026 18:32:28 +0300 Subject: [PATCH 101/191] ci: force local wheels via uv override in cross-tests (#1723) --- .github/scripts/write_uv_overrides.py | 50 +++++++++++++++++++ .../workflows/test-uipath-integrations.yml | 28 +++++++---- .github/workflows/test-uipath-langchain.yml | 28 +++++++---- .github/workflows/test-uipath-runtime.yml | 13 +++-- 4 files changed, 96 insertions(+), 23 deletions(-) create mode 100644 .github/scripts/write_uv_overrides.py diff --git a/.github/scripts/write_uv_overrides.py b/.github/scripts/write_uv_overrides.py new file mode 100644 index 000000000..557f9d26e --- /dev/null +++ b/.github/scripts/write_uv_overrides.py @@ -0,0 +1,50 @@ +"""Write a uv override file forcing the locally built uipath wheels. + +Cross-test workflows build uipath wheels from the PR and run them against +downstream repos (uipath-langchain-python, uipath-integrations-python, +uipath-runtime-python). Those downstreams cap the uipath* version (e.g. +``uipath<2.11.0``), so a backward-compatible minor bump would fail resolution +purely on the cap. uv ``override-dependencies`` ignore the declared version +specifier, so pointing them at the local wheels lets the cross-test exercise the +real new code regardless of the cap. + +The script is layout-agnostic: it overrides whatever ``uipath*`` wheels exist +under ``$GITHUB_WORKSPACE/wheels`` (recursively), so it works for the +three-wheel layout (``wheels//dist/*.whl``) and the single-wheel runtime +layout (``wheels/*.whl``) alike. + +The resulting override file path is appended to ``GITHUB_ENV`` as ``UV_OVERRIDE`` +so every subsequent ``uv`` invocation in the job honors it. +""" + +import glob +import os +import pathlib + + +def main() -> None: + wheels = pathlib.Path(os.environ.get("GITHUB_WORKSPACE", ".")).resolve() / "wheels" + + lines = [] + for whl in sorted(glob.glob(str(wheels / "**" / "*.whl"), recursive=True)): + # Wheel filename is ``{distribution}-{version}-...whl`` where the + # distribution escapes hyphens to underscores (uipath_core -> uipath-core). + dist = pathlib.Path(whl).name.split("-", 1)[0].replace("_", "-") + if not dist.startswith("uipath"): + continue + lines.append(f"{dist} @ {pathlib.Path(whl).resolve().as_uri()}") + + if not lines: + raise SystemExit(f"no uipath wheels found under {wheels}") + + out = wheels / "overrides.txt" + out.write_text("\n".join(lines) + "\n") + + with open(os.environ["GITHUB_ENV"], "a") as fh: + fh.write(f"UV_OVERRIDE={out}\n") + + print("\n".join(lines)) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/test-uipath-integrations.yml b/.github/workflows/test-uipath-integrations.yml index ed50a4b3d..95c621941 100644 --- a/.github/workflows/test-uipath-integrations.yml +++ b/.github/workflows/test-uipath-integrations.yml @@ -102,13 +102,17 @@ jobs: repository: 'UiPath/uipath-integrations-python' path: 'uipath-integrations-python' - - name: Update uipath packages + - name: Checkout uipath-python scripts + uses: actions/checkout@v4 + with: + path: _scripts + sparse-checkout: .github/scripts + + - name: Override uipath packages with local wheels shell: bash - working-directory: uipath-integrations-python/packages/${{ matrix.package }} run: | - uv add ../../../wheels/uipath-core/dist/*.whl --dev - uv add ../../../wheels/uipath-platform/dist/*.whl --dev - uv add ../../../wheels/uipath/dist/*.whl --dev + PYBIN=$(command -v python || command -v python3) + "$PYBIN" "$GITHUB_WORKSPACE/_scripts/.github/scripts/write_uv_overrides.py" - name: Install dependencies and run tests shell: bash @@ -202,13 +206,17 @@ jobs: repository: 'UiPath/uipath-integrations-python' path: 'uipath-integrations-python' - - name: Update uipath packages + - name: Checkout uipath-python scripts + uses: actions/checkout@v4 + with: + path: _scripts + sparse-checkout: .github/scripts + + - name: Override uipath packages with local wheels shell: bash - working-directory: uipath-integrations-python/packages/${{ matrix.package }} run: | - uv add ../../../wheels/uipath-core/dist/*.whl - uv add ../../../wheels/uipath-platform/dist/*.whl - uv add ../../../wheels/uipath/dist/*.whl + PYBIN=$(command -v python || command -v python3) + "$PYBIN" "$GITHUB_WORKSPACE/_scripts/.github/scripts/write_uv_overrides.py" - name: Install dependencies working-directory: uipath-integrations-python/packages/${{ matrix.package }} diff --git a/.github/workflows/test-uipath-langchain.yml b/.github/workflows/test-uipath-langchain.yml index f7ccb2cf9..25b027633 100644 --- a/.github/workflows/test-uipath-langchain.yml +++ b/.github/workflows/test-uipath-langchain.yml @@ -72,13 +72,17 @@ jobs: repository: 'UiPath/uipath-langchain-python' path: 'uipath-langchain-python' - - name: Update uipath packages + - name: Checkout uipath-python scripts + uses: actions/checkout@v4 + with: + path: _scripts + sparse-checkout: .github/scripts + + - name: Override uipath packages with local wheels shell: bash - working-directory: uipath-langchain-python run: | - uv add ../wheels/uipath-core/dist/*.whl --dev - uv add ../wheels/uipath-platform/dist/*.whl --dev - uv add ../wheels/uipath/dist/*.whl --dev + PYBIN=$(command -v python || command -v python3) + "$PYBIN" "$GITHUB_WORKSPACE/_scripts/.github/scripts/write_uv_overrides.py" - name: Run uipath-langchain tests working-directory: uipath-langchain-python @@ -146,13 +150,17 @@ jobs: repository: 'UiPath/uipath-langchain-python' path: 'uipath-langchain-python' - - name: Update uipath packages + - name: Checkout uipath-python scripts + uses: actions/checkout@v4 + with: + path: _scripts + sparse-checkout: .github/scripts + + - name: Override uipath packages with local wheels shell: bash - working-directory: uipath-langchain-python run: | - uv add ../wheels/uipath-core/dist/*.whl - uv add ../wheels/uipath-platform/dist/*.whl - uv add ../wheels/uipath/dist/*.whl + PYBIN=$(command -v python || command -v python3) + "$PYBIN" "$GITHUB_WORKSPACE/_scripts/.github/scripts/write_uv_overrides.py" - name: Install dependencies working-directory: uipath-langchain-python diff --git a/.github/workflows/test-uipath-runtime.yml b/.github/workflows/test-uipath-runtime.yml index 13ad019ef..200a1b8ca 100644 --- a/.github/workflows/test-uipath-runtime.yml +++ b/.github/workflows/test-uipath-runtime.yml @@ -64,10 +64,17 @@ jobs: repository: 'UiPath/uipath-runtime-python' path: 'uipath-runtime-python' - - name: Update uipath-core version + - name: Checkout uipath-python scripts + uses: actions/checkout@v4 + with: + path: _scripts + sparse-checkout: .github/scripts + + - name: Override uipath packages with local wheels shell: bash - working-directory: uipath-runtime-python - run: uv add ../wheels/*.whl --dev + run: | + PYBIN=$(command -v python || command -v python3) + "$PYBIN" "$GITHUB_WORKSPACE/_scripts/.github/scripts/write_uv_overrides.py" - name: Run uipath-runtime tests working-directory: uipath-runtime-python From ed5e5094cded66985565e5a6754b3bee4a41c96b Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Tue, 16 Jun 2026 19:42:03 +0300 Subject: [PATCH 102/191] feat: add stable id to uipath.json for packaging and spans (#1695) --- packages/uipath-platform/pyproject.toml | 2 +- .../uipath/platform/chat/llm_trace_context.py | 4 +- .../src/uipath/platform/common/_span_utils.py | 46 +++++- .../tests/services/test_llm_trace_context.py | 20 +-- .../tests/services/test_span_utils.py | 144 +++++++++++++++++- packages/uipath-platform/uv.lock | 2 +- packages/uipath/docs/cli/index.md | 8 + packages/uipath/pyproject.toml | 4 +- packages/uipath/specs/uipath.schema.json | 5 + packages/uipath/specs/uipath.spec.md | 37 ++++- .../src/uipath/_cli/_evals/_telemetry.py | 6 +- .../src/uipath/_cli/_utils/_project_files.py | 48 ++++-- packages/uipath/src/uipath/_cli/cli_debug.py | 1 - packages/uipath/src/uipath/_cli/cli_init.py | 47 +++--- packages/uipath/src/uipath/_cli/cli_pack.py | 50 +++--- .../uipath/_cli/models/uipath_json_schema.py | 6 + .../uipath/src/uipath/telemetry/_track.py | 17 ++- .../testcases/langchain-cross/pyproject.toml | 9 +- .../tests/cli/eval/test_eval_telemetry.py | 13 +- packages/uipath/tests/cli/test_init.py | 96 ++++++++++++ packages/uipath/tests/cli/test_pack.py | 130 +++++++++++++++- packages/uipath/tests/telemetry/test_track.py | 46 ++++++ packages/uipath/uv.lock | 4 +- 23 files changed, 621 insertions(+), 124 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 6a50d6e4b..464549970 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.66" +version = "0.1.67" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py b/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py index fc97da511..b9c1f74a0 100644 --- a/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py +++ b/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py @@ -5,7 +5,7 @@ from uipath.core.tracing.span_utils import UiPathSpanUtils from ..common._config import UiPathConfig -from ..common._span_utils import _SpanUtils +from ..common._span_utils import _SpanUtils, resolve_project_id def build_trace_context_headers( @@ -40,7 +40,7 @@ def build_trace_context_headers( baggage_parts: list[str] = list(extra_baggage) if extra_baggage else [] if folder_key := UiPathConfig.folder_key: baggage_parts.append(f"folderKey={folder_key}") - if agent_id := UiPathConfig.agent_id: + if agent_id := resolve_project_id(): baggage_parts.append(f"agentId={agent_id}") if process_key := UiPathConfig.process_key: baggage_parts.append(f"processKey={process_key}") diff --git a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py index ab91b3623..954fbc038 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -2,9 +2,11 @@ import json import logging import os +import uuid from dataclasses import dataclass, field from datetime import datetime from enum import IntEnum +from functools import lru_cache from os import environ as env from typing import Any, Dict, List, Optional @@ -19,6 +21,40 @@ DEFAULT_SOURCE = 10 +@lru_cache(maxsize=1) +def _read_config_id() -> str | None: + """Return a valid GUID ``id`` from ``uipath.json``, cached for the process lifetime.""" + from uipath.platform.common._config import UiPathConfig + + try: + config_file = json.loads(UiPathConfig.config_file_path.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + return None + + project_id = config_file.get("id") + if not isinstance(project_id, str): + logger.warning("'id' field not present in uipath.json") + return None + + try: + uuid.UUID(project_id) + except ValueError: + logger.warning("Ignoring uipath.json 'id' %r: not a valid GUID.", project_id) + return None + + return project_id + + +def resolve_project_id() -> str | None: + """Resolve the project id. + + Prefers ``uipath.json#id``, falls back to env vars. + """ + from uipath.platform.common._config import UiPathConfig + + return _read_config_id() or UiPathConfig.agent_id or UiPathConfig.project_key + + class AttachmentProvider(IntEnum): ORCHESTRATOR = 0 @@ -281,9 +317,11 @@ def otel_span_to_uipath_span( ] attributes_dict["links"] = links_list + if agent_id := resolve_project_id(): + attributes_dict["agentId"] = agent_id + # Add process context attributes from environment variables for env_key, attr_key in ( - ("PROJECT_KEY", "agentId"), ("UIPATH_PROCESS_KEY", "agentName"), ("UIPATH_PROCESS_VERSION", "agentVersion"), ): @@ -297,10 +335,8 @@ def otel_span_to_uipath_span( # Top-level fields for internal tracing schema execution_type = attributes_dict.get("executionType") agent_version = attributes_dict.get("agentVersion") - reference_id = ( - env.get("UIPATH_AGENT_ID") - or attributes_dict.get("agentId") - or attributes_dict.get("referenceId") + reference_id = attributes_dict.get("agentId") or attributes_dict.get( + "referenceId" ) verbosity_level = attributes_dict.get("verbosityLevel") diff --git a/packages/uipath-platform/tests/services/test_llm_trace_context.py b/packages/uipath-platform/tests/services/test_llm_trace_context.py index 8db61200b..2b078cd62 100644 --- a/packages/uipath-platform/tests/services/test_llm_trace_context.py +++ b/packages/uipath-platform/tests/services/test_llm_trace_context.py @@ -8,6 +8,7 @@ from uipath.core.feature_flags import FeatureFlags from uipath.platform.chat.llm_trace_context import build_trace_context_headers +from uipath.platform.common.constants import ENV_PROJECT_KEY FEATURE_FLAG = "EnableTraceContextHeaders" @@ -110,13 +111,16 @@ class TestBaggageHeader: """When enabled, x-uipath-tracebaggage is populated from UiPathConfig.""" def setup_method(self) -> None: + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() FeatureFlags.reset_flags() FeatureFlags.configure_flags({FEATURE_FLAG: True}) def test_all_env_vars_present(self) -> None: env = { "UIPATH_FOLDER_KEY": "folder-abc", - "UIPATH_AGENT_ID": "agent-123", + ENV_PROJECT_KEY: "agent-123", "UIPATH_PROCESS_KEY": "process-789", } with patch.dict(os.environ, env, clear=True): @@ -135,22 +139,14 @@ def test_partial_env_vars(self) -> None: baggage = headers["x-uipath-tracebaggage"] assert "folderKey=folder-only" in baggage - def test_agent_id_from_agent_id_env(self) -> None: - env = {"UIPATH_AGENT_ID": "real-agent-id"} + def test_agent_id_from_project_key_env(self) -> None: + env = {ENV_PROJECT_KEY: "real-agent-id"} with patch.dict(os.environ, env, clear=True): headers = build_trace_context_headers() baggage = headers["x-uipath-tracebaggage"] assert "agentId=real-agent-id" in baggage - def test_agent_id_falls_back_to_project_id(self) -> None: - env = {"UIPATH_PROJECT_ID": "project-123"} - with patch.dict(os.environ, env, clear=True): - headers = build_trace_context_headers() - - baggage = headers["x-uipath-tracebaggage"] - assert "agentId=project-123" in baggage - def test_no_agent_id_without_env_vars(self) -> None: env = {"UIPATH_FOLDER_KEY": "f1"} with patch.dict(os.environ, env, clear=True): @@ -169,7 +165,7 @@ def test_no_baggage_without_env_vars(self) -> None: def test_baggage_comma_separated(self) -> None: env = { "UIPATH_FOLDER_KEY": "f1", - "UIPATH_AGENT_ID": "a1", + ENV_PROJECT_KEY: "a1", } with patch.dict(os.environ, env, clear=True): headers = build_trace_context_headers() diff --git a/packages/uipath-platform/tests/services/test_span_utils.py b/packages/uipath-platform/tests/services/test_span_utils.py index 03f728eb8..35c2b78e7 100644 --- a/packages/uipath-platform/tests/services/test_span_utils.py +++ b/packages/uipath-platform/tests/services/test_span_utils.py @@ -8,6 +8,21 @@ from opentelemetry.trace import SpanContext, StatusCode from uipath.platform.common import UiPathSpan, _SpanUtils +from uipath.platform.common.constants import ( + ENV_PROJECT_KEY, + ENV_UIPATH_AGENT_ID, + ENV_UIPATH_PROJECT_ID, +) + + +@pytest.fixture(autouse=True) +def _clear_id_cache(): + """Isolate the process-global id cache between tests.""" + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() + yield + _read_config_id.cache_clear() class TestOTelToUiPathSpan: @@ -92,10 +107,11 @@ def test_verbosity_level_omitted_when_unset(self) -> None: class TestReferenceIdResolution: """`reference_id` resolution chain. - Priority: `UIPATH_AGENT_ID` env var > `agentId` attribute > `referenceId` - attribute. Falsy values (missing / empty string) at each step fall through - to the next source. The `referenceId` fallback exists for backwards - compatibility with older producers that only emit that attribute. + `reference_id` is derived from the span's resolved `agentId` attribute + (which itself goes through `resolve_project_id()`), falling back to the + `referenceId` attribute. Falsy values (missing / empty string) at each step + fall through to the next source. The `referenceId` fallback exists for + backwards compatibility with older producers that only emit that attribute. """ @pytest.mark.parametrize( @@ -105,7 +121,7 @@ class TestReferenceIdResolution: "env-agent", {"agentId": "attr-agent", "referenceId": "attr-ref"}, "env-agent", - id="env-var-wins", + id="env-var-overrides-attr", ), pytest.param( None, @@ -140,10 +156,15 @@ def test_reference_id_chain( expected: str | None, monkeypatch: pytest.MonkeyPatch, ) -> None: + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() + monkeypatch.delenv(ENV_UIPATH_AGENT_ID, raising=False) + monkeypatch.delenv(ENV_UIPATH_PROJECT_ID, raising=False) if env_value is None: - monkeypatch.delenv("UIPATH_AGENT_ID", raising=False) + monkeypatch.delenv(ENV_PROJECT_KEY, raising=False) else: - monkeypatch.setenv("UIPATH_AGENT_ID", env_value) + monkeypatch.setenv(ENV_PROJECT_KEY, env_value) mock_span = Mock(spec=OTelSpan) mock_context = SpanContext( @@ -166,6 +187,115 @@ def test_reference_id_chain( assert uipath_span.reference_id == expected +class TestAgentIdResolution: + """`agentId` span attribute resolution via `resolve_project_id()`. + + Priority: `uipath.json#id` (cached, read once per process) > `UIPATH_AGENT_ID` + / `UIPATH_PROJECT_ID` > the legacy `PROJECT_KEY` env var injected by the + executor at runtime. When no source is present the `agentId` attribute is + omitted entirely. + """ + + @staticmethod + def _make_span() -> Mock: + mock_span = Mock(spec=OTelSpan) + mock_context = SpanContext( + trace_id=0x123456789ABCDEF0123456789ABCDEF0, + span_id=0x0123456789ABCDEF, + is_remote=False, + ) + mock_span.get_span_context.return_value = mock_context + mock_span.name = "test-span" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + mock_span.attributes = {} + mock_span.events = [] + mock_span.links = [] + now_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = now_ns + mock_span.end_time = now_ns + 1_000_000 + return mock_span + + @staticmethod + def _resolve(monkeypatch: pytest.MonkeyPatch, tmp_path) -> object: + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() + monkeypatch.delenv("UIPATH_CONFIG_PATH", raising=False) + monkeypatch.delenv(ENV_UIPATH_AGENT_ID, raising=False) + monkeypatch.delenv(ENV_UIPATH_PROJECT_ID, raising=False) + monkeypatch.chdir(tmp_path) + uipath_span = _SpanUtils.otel_span_to_uipath_span( + TestAgentIdResolution._make_span(), serialize_attributes=False + ) + attributes = uipath_span.attributes + assert isinstance(attributes, dict) + return attributes.get("agentId") + + def test_agent_id_from_uipath_json_wins_over_env( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + (tmp_path / "uipath.json").write_text( + json.dumps({"id": "00000000-0000-0000-0000-000000000001"}) + ) + monkeypatch.setenv(ENV_PROJECT_KEY, "from-env") + assert ( + self._resolve(monkeypatch, tmp_path) + == "00000000-0000-0000-0000-000000000001" + ) + + def test_agent_id_falls_back_to_project_key( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + # No uipath.json on disk. + monkeypatch.setenv(ENV_PROJECT_KEY, "from-env") + assert self._resolve(monkeypatch, tmp_path) == "from-env" + + def test_agent_id_falls_back_when_config_has_no_id( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + (tmp_path / "uipath.json").write_text(json.dumps({"functions": {}})) + monkeypatch.setenv(ENV_PROJECT_KEY, "from-env") + assert self._resolve(monkeypatch, tmp_path) == "from-env" + + def test_agent_id_absent_when_no_source( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + monkeypatch.delenv(ENV_PROJECT_KEY, raising=False) + assert self._resolve(monkeypatch, tmp_path) is None + + def test_non_guid_config_id_is_ignored_and_falls_back( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + # A malformed (non-GUID) id must not reach ReferenceId; fall back to env. + (tmp_path / "uipath.json").write_text(json.dumps({"id": "not-a-guid"})) + monkeypatch.setenv(ENV_PROJECT_KEY, "from-env") + assert self._resolve(monkeypatch, tmp_path) == "from-env" + + def test_config_id_is_cached( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + from uipath.platform.common._span_utils import _read_config_id + + first = "00000000-0000-0000-0000-000000000001" + second = "00000000-0000-0000-0000-000000000002" + + _read_config_id.cache_clear() + monkeypatch.delenv("UIPATH_CONFIG_PATH", raising=False) + monkeypatch.chdir(tmp_path) + config = tmp_path / "uipath.json" + + config.write_text(json.dumps({"id": first})) + assert _read_config_id() == first + + # A later edit is not observed: the value is read once and cached. + config.write_text(json.dumps({"id": second})) + assert _read_config_id() == first + + _read_config_id.cache_clear() + assert _read_config_id() == second + + class TestNormalizeIds: """Tests for OTEL ID normalization functions.""" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index c38310808..0e01f1f73 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.66" +version = "0.1.67" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/docs/cli/index.md b/packages/uipath/docs/cli/index.md index f73afa0d1..e74a4d83e 100644 --- a/packages/uipath/docs/cli/index.md +++ b/packages/uipath/docs/cli/index.md @@ -121,6 +121,14 @@ Running `uipath init` will process these function definitions and create the cor `uipath init` generates one `.mermaid` file per function/agent containing a static call graph, rendered in the UiPath Orchestrator UI. These files are regenerated on every `uipath init`. /// + +/// warning +### About the `id` field + +The first `uipath init` mints a stable `id` (GUID) into `uipath.json` and preserves it across subsequent runs. It is what identifies your project consistently wherever it is deployed and run. + +Do not change or remove it. Changing it makes the project look like a brand-new, unrelated one, so you lose the link to everything previously published and tracked under the old id. `uipath pack` rejects an `id` that is not a valid GUID. +/// --- ::: mkdocs-click diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 7b32baacf..be0c4c22d 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.10.84" +version = "2.11.0" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.17, <0.6.0", "uipath-runtime>=0.11.0, <0.12.0", - "uipath-platform>=0.1.66, <0.2.0", + "uipath-platform>=0.1.67, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/specs/uipath.schema.json b/packages/uipath/specs/uipath.schema.json index 8f2a550f8..2ee966e82 100644 --- a/packages/uipath/specs/uipath.schema.json +++ b/packages/uipath/specs/uipath.schema.json @@ -9,6 +9,11 @@ "type": "string", "description": "Reference to this JSON schema for editor support" }, + "id": { + "type": "string", + "format": "uuid", + "description": "Stable unique identifier for the project, minted once on the first 'uipath init' and preserved for its lifetime. Used as the package 'projectId' at pack time. Do not change it." + }, "runtimeOptions": { "type": "object", "description": "Runtime behavior configuration", diff --git a/packages/uipath/specs/uipath.spec.md b/packages/uipath/specs/uipath.spec.md index 5c7599faa..9679bf223 100644 --- a/packages/uipath/specs/uipath.spec.md +++ b/packages/uipath/specs/uipath.spec.md @@ -9,6 +9,7 @@ The `uipath.json` file is a configuration file for UiPath projects that defines ```json { "$schema": "https://cloud.uipath.com/draft/2024-12/uipath", + "id": "00000000-0000-0000-0000-000000000000", "runtimeOptions": { ... }, "designOptions": { ... }, "packOptions": { ... }, @@ -20,7 +21,29 @@ The `uipath.json` file is a configuration file for UiPath projects that defines ## Configuration Sections -### 1. `runtimeOptions` +### 1. `id` + +Stable unique identifier (GUID) for the project, minted once on the first `uipath init` and preserved for its lifetime. Used as the package `projectId` at pack time. + +**Properties:** + +| Property | Type | Required | Default | Description | +|----------|------|----------|---------|-------------| +| `id` | `string` (uuid) | No | minted on first `uipath init` | Stable identifier for the project. Do not change it. | + +> Do not change or remove `id`. It identifies your project consistently wherever it is deployed and run. Changing it makes the project look like a brand-new, unrelated one, so you lose the link to everything previously published and tracked under the old id. `uipath pack` rejects an `id` that is not a valid GUID. + +**Example:** + +```json +{ + "id": "00000000-0000-0000-0000-000000000001" +} +``` + +--- + +### 2. `runtimeOptions` Controls runtime behavior of your UiPath project. @@ -42,7 +65,7 @@ Controls runtime behavior of your UiPath project. --- -### 2. `designOptions` +### 3. `designOptions` Design-time configuration and preferences. @@ -57,7 +80,7 @@ Design-time configuration and preferences. --- -### 3. `packOptions` +### 4. `packOptions` Controls which files and directories are included or excluded when packaging your project. @@ -87,7 +110,7 @@ Controls which files and directories are included or excluded when packaging you --- -### 4. `functions` +### 5. `functions` Defines entrypoints for pure Python scripts. Each key is a friendly name for the entrypoint, and each value specifies the file path and function name. @@ -128,6 +151,7 @@ Defines entrypoints for pure Python scripts. Each key is a friendly name for the ```json { "$schema": "https://cloud.uipath.com/draft/2024-12/uipath", + "id": "00000000-0000-0000-0000-000000000001", "runtimeOptions": { "isConversational": false }, @@ -218,6 +242,11 @@ The complete JSON Schema is available in `uipath.schema.json`: "type": "string", "description": "Reference to this JSON schema for editor support" }, + "id": { + "type": "string", + "format": "uuid", + "description": "Stable unique identifier for the project, minted once on the first 'uipath init' and preserved for its lifetime. Used as the package 'projectId' at pack time. Do not change it." + }, "runtimeOptions": { "type": "object", "description": "Runtime behavior configuration", diff --git a/packages/uipath/src/uipath/_cli/_evals/_telemetry.py b/packages/uipath/src/uipath/_cli/_evals/_telemetry.py index 04cb7e2c4..bdbdc67f7 100644 --- a/packages/uipath/src/uipath/_cli/_evals/_telemetry.py +++ b/packages/uipath/src/uipath/_cli/_evals/_telemetry.py @@ -308,10 +308,12 @@ def _enrich_properties(self, properties: dict[str, Any]) -> None: Args: properties: The properties dictionary to enrich. """ + from uipath.platform.common._span_utils import resolve_project_id + if UiPathConfig.project_id: properties["ProjectId"] = UiPathConfig.project_id - if UiPathConfig.agent_id: - properties["AgentId"] = UiPathConfig.agent_id + if agent_id := resolve_project_id(): + properties["AgentId"] = agent_id if UiPathConfig.organization_id: properties["CloudOrganizationId"] = UiPathConfig.organization_id diff --git a/packages/uipath/src/uipath/_cli/_utils/_project_files.py b/packages/uipath/src/uipath/_cli/_utils/_project_files.py index c7c025197..15f5e53c5 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_project_files.py +++ b/packages/uipath/src/uipath/_cli/_utils/_project_files.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any, AsyncIterator, Dict, Literal, Optional, Tuple +import anyio from pydantic import BaseModel, Field, TypeAdapter from uipath._cli.models.uipath_json_schema import PackOptions, UiPathJsonConfig @@ -25,6 +26,34 @@ logger = logging.getLogger(__name__) +def resolve_existing_project_id(directory: str = ".") -> Optional[str]: + """Return an already-established project id for this project, if any. + + Checks the Studio Web project env var first, then falls back to the legacy + ``ProjectKey`` stored in ``.uipath/.telemetry.json``. Returns ``None`` when + neither is present. + + Args: + directory: The project root directory to look for the telemetry file in. + """ + from ...telemetry._constants import _PROJECT_KEY, _TELEMETRY_CONFIG_FILE + + if project_id := UiPathConfig.project_id: + return project_id + + telemetry_file = os.path.join(directory, ".uipath", _TELEMETRY_CONFIG_FILE) + if os.path.exists(telemetry_file): + try: + with open(telemetry_file, "r") as f: + telemetry_data = json.load(f) + if project_id := telemetry_data.get(_PROJECT_KEY): + return project_id + except (json.JSONDecodeError, IOError): + pass + + return None + + class Severity(IntEnum): LOG = 0 WARNING = 1 @@ -592,21 +621,21 @@ async def download_folder_files( collect_files_from_folder(folder, "", files_dict) for file_path, remote_file in files_dict.items(): - local_path = base_path / file_path - local_path.parent.mkdir(parents=True, exist_ok=True) + local_path = anyio.Path(base_path / file_path) + await local_path.parent.mkdir(parents=True, exist_ok=True) response = await studio_client.download_project_file_async(remote_file) remote_content = response.read().decode("utf-8") remote_hash = compute_normalized_hash(remote_content) - if os.path.exists(local_path): - with open(local_path, "r", encoding="utf-8") as f: - local_content = f.read() - local_hash = compute_normalized_hash(local_content) + if await local_path.exists(): + local_content = await local_path.read_text(encoding="utf-8") + local_hash = compute_normalized_hash(local_content) if local_hash != remote_hash: - with open(local_path, "w", encoding="utf-8", newline="\n") as f: - f.write(remote_content) + await local_path.write_text( + remote_content, encoding="utf-8", newline="\n" + ) yield UpdateEvent( file_path=file_path, @@ -620,8 +649,7 @@ async def download_folder_files( message=f"File '{file_path}' is up to date", ) else: - with open(local_path, "w", encoding="utf-8", newline="\n") as f: - f.write(remote_content) + await local_path.write_text(remote_content, encoding="utf-8", newline="\n") yield UpdateEvent( file_path=file_path, diff --git a/packages/uipath/src/uipath/_cli/cli_debug.py b/packages/uipath/src/uipath/_cli/cli_debug.py index cd9042b78..92b8ea454 100644 --- a/packages/uipath/src/uipath/_cli/cli_debug.py +++ b/packages/uipath/src/uipath/_cli/cli_debug.py @@ -160,7 +160,6 @@ async def execute_debug_runtime(): debug_bridge: UiPathDebugProtocol = get_debug_bridge( ctx, attach=attach_mode ) - runtime = await factory.new_runtime( entrypoint, ctx.conversation_id or ctx.job_id or "default", diff --git a/packages/uipath/src/uipath/_cli/cli_init.py b/packages/uipath/src/uipath/_cli/cli_init.py index 80396d8ff..90a4ca117 100644 --- a/packages/uipath/src/uipath/_cli/cli_init.py +++ b/packages/uipath/src/uipath/_cli/cli_init.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Any +import anyio import click from graphtty import RenderOptions, render from graphtty.themes import TOKYO_NIGHT @@ -30,13 +31,11 @@ ) from uipath.runtime.schema import UiPathRuntimeGraph, UiPathRuntimeSchema -from .._utils.constants import ENV_TELEMETRY_ENABLED -from ..telemetry._constants import _PROJECT_KEY, _TELEMETRY_CONFIG_FILE from ._telemetry import track_command from ._utils._common import determine_project_type from ._utils._console import ConsoleLogger from ._utils._constants import AGENT_INITIAL_CODE_VERSION, SCHEMA_VERSION -from ._utils._project_files import read_toml_project +from ._utils._project_files import read_toml_project, resolve_existing_project_id from .middlewares import Middlewares from .models.runtime_schema import Bindings, EntryPoint from .models.uipath_json_schema import UiPathJsonConfig @@ -54,30 +53,6 @@ class Action(str, enum.Enum): UPDATED = "Updated" -def create_telemetry_config_file(target_directory: str) -> None: - """Create telemetry file if telemetry is enabled. - - Args: - target_directory: The directory where the .uipath folder should be created. - """ - telemetry_enabled = os.getenv(ENV_TELEMETRY_ENABLED, "true").lower() == "true" - - if not telemetry_enabled: - return - - uipath_dir = os.path.join(target_directory, ".uipath") - telemetry_file = os.path.join(uipath_dir, _TELEMETRY_CONFIG_FILE) - - if os.path.exists(telemetry_file): - return - - os.makedirs(uipath_dir, exist_ok=True) - telemetry_data = {_PROJECT_KEY: UiPathConfig.project_id or str(uuid.uuid4())} - - with open(telemetry_file, "w") as f: - json.dump(telemetry_data, f, indent=4) - - def generate_env_file(target_directory): env_path = os.path.join(target_directory, ".env") @@ -421,7 +396,6 @@ def init(no_agents_md_override: bool) -> None: with console.spinner("Initializing UiPath project ..."): current_directory = os.getcwd() generate_env_file(current_directory) - create_telemetry_config_file(current_directory) async def initialize() -> list[UiPathRuntimeSchema]: try: @@ -429,10 +403,25 @@ async def initialize() -> list[UiPathRuntimeSchema]: config_path = UiPathConfig.config_file_path if not config_path.exists(): config = UiPathJsonConfig.create_default() + config.id = resolve_existing_project_id(current_directory) or str( + uuid.uuid4() + ) config.save_to_file(config_path) console.success(f"{Action.CREATED.value} '{config_path}' file.") else: - console.info(f"'{config_path}' already exists, skipping.") + # backfill id if not present + async_config_path = anyio.Path(config_path) + raw_config = json.loads(await async_config_path.read_text()) + if not raw_config.get("id"): + raw_config["id"] = resolve_existing_project_id( + current_directory + ) or str(uuid.uuid4()) + await async_config_path.write_text( + json.dumps(raw_config, indent=2) + ) + console.success( + f"{Action.UPDATED.value} '{config_path}' file with 'id'." + ) # Create bindings.json if it doesn't exist bindings_path = UiPathConfig.bindings_file_path diff --git a/packages/uipath/src/uipath/_cli/cli_pack.py b/packages/uipath/src/uipath/_cli/cli_pack.py index 83cedf870..d51eec53c 100644 --- a/packages/uipath/src/uipath/_cli/cli_pack.py +++ b/packages/uipath/src/uipath/_cli/cli_pack.py @@ -8,11 +8,10 @@ from pydantic import TypeAdapter from uipath._cli.models.runtime_schema import Bindings, EntryPoint, EntryPoints -from uipath._cli.models.uipath_json_schema import RuntimeOptions, UiPathJsonConfig +from uipath._cli.models.uipath_json_schema import UiPathJsonConfig from uipath.eval.constants import EVALS_FOLDER, LEGACY_EVAL_FOLDER from uipath.platform.common import UiPathConfig -from ..telemetry._constants import _PROJECT_KEY, _TELEMETRY_CONFIG_FILE from ._telemetry import track_command from ._utils._common import determine_project_type from ._utils._console import ConsoleLogger @@ -21,6 +20,7 @@ files_to_include, get_project_config, read_toml_project, + resolve_existing_project_id, validate_config, ) from ._utils._uv_helpers import handle_uv_operations @@ -30,31 +30,6 @@ schema = "https://cloud.uipath.com/draft/2024-12/entry-point" -def get_project_id() -> str: - """Get project ID from telemetry file if it exists, otherwise generate a new one. - - Returns: - Project ID string (either from telemetry file or newly generated). - """ - # first check if this is a studio project - if project_id := UiPathConfig.project_id: - return project_id - - telemetry_file = os.path.join(".uipath", _TELEMETRY_CONFIG_FILE) - - if os.path.exists(telemetry_file): - try: - with open(telemetry_file, "r") as f: - telemetry_data = json.load(f) - project_id = telemetry_data.get(_PROJECT_KEY) - if project_id: - return project_id - except (json.JSONDecodeError, IOError): - pass - - return str(uuid.uuid4()) - - def get_project_version(directory): toml_path = os.path.join(directory, "pyproject.toml") if not os.path.exists(toml_path): @@ -72,14 +47,27 @@ def validate_config_structure(config_data): def generate_operate_file( - entrypoints: list[EntryPoint], runtimeOptions: RuntimeOptions, dependencies=None + entrypoints: list[EntryPoint], + config: UiPathJsonConfig, + dependencies=None, + directory: str = ".", ): if not entrypoints: raise ValueError( "No entry points found in entry-points.json. Please run 'uipath init' to generate valid entry points." ) - project_id = get_project_id() + # prefer id from uipath.json; fall back to the legacy + # .telemetry.json or SW project id. + if config.id: + try: + uuid.UUID(config.id) + except ValueError: + console.error(f"uipath.json 'id' must be a valid GUID, got '{config.id}'.") + + project_id = ( + config.id or resolve_existing_project_id(directory) or str(uuid.uuid4()) + ) project_type = determine_project_type(entrypoints) first_entry = entrypoints[0] @@ -94,7 +82,7 @@ def generate_operate_file( "runtimeOptions": { "requiresUserInteraction": False, "isAttended": False, - "isConversational": runtimeOptions.is_conversational, + "isConversational": config.runtime_options.is_conversational, }, } @@ -239,7 +227,7 @@ def pack_fn( config_data = TypeAdapter(UiPathJsonConfig).validate_python(json.load(f)) operate_file = generate_operate_file( - entrypoints, config_data.runtime_options, dependencies + entrypoints, config_data, dependencies, directory ) # try to read bindings from bindings.json diff --git a/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py b/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py index f1cd30202..4dd2f6700 100644 --- a/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py +++ b/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py @@ -68,6 +68,12 @@ class UiPathJsonConfig(BaseModelWithDefaultConfig): alias="$schema", description="Reference to the JSON schema for editor support", ) + id: str | None = Field( + default=None, + description="Stable unique identifier for the agent. Minted once at " + "project creation (by 'uipath init' or Studio Web) and preserved for the " + "lifetime of the project. Used as the package 'projectId' at pack time.", + ) runtime_options: RuntimeOptions = Field( default_factory=RuntimeOptions, alias="runtimeOptions", diff --git a/packages/uipath/src/uipath/telemetry/_track.py b/packages/uipath/src/uipath/telemetry/_track.py index 2d3f11ebf..3e585f8e5 100644 --- a/packages/uipath/src/uipath/telemetry/_track.py +++ b/packages/uipath/src/uipath/telemetry/_track.py @@ -105,18 +105,23 @@ def _get_connection_string() -> str | None: def _get_project_key() -> str: - """Get project key from telemetry file if present. + """Get the id used to attribute telemetry. - Returns: - Project key string if available, otherwise empty string. + Resolves ``uipath.json#id`` (then the runtime env var) via the shared + ``resolve_project_id`` helper, falling back to a legacy ``.uipath/.telemetry.json`` + ``ProjectKey`` if present. + Returns ``_UNKNOWN`` when no id is available. """ + from uipath.platform.common._span_utils import resolve_project_id + + if project_id := resolve_project_id(): + return project_id + try: telemetry_file = os.path.join(".uipath", _TELEMETRY_CONFIG_FILE) if os.path.exists(telemetry_file): with open(telemetry_file, "r") as f: - telemetry_data = json.load(f) - project_id = telemetry_data.get(_PROJECT_KEY) - if project_id: + if project_id := json.load(f).get(_PROJECT_KEY): return project_id except (json.JSONDecodeError, IOError, KeyError): pass diff --git a/packages/uipath/testcases/langchain-cross/pyproject.toml b/packages/uipath/testcases/langchain-cross/pyproject.toml index ae2717777..0420b6c48 100644 --- a/packages/uipath/testcases/langchain-cross/pyproject.toml +++ b/packages/uipath/testcases/langchain-cross/pyproject.toml @@ -10,4 +10,11 @@ dependencies = [ requires-python = ">=3.11" [tool.uv.sources] -uipath = { path = "../../", editable = true } \ No newline at end of file +uipath = { path = "../../", editable = true } + +# Force the local uipath (the version under test) regardless of the upper bound +# the published uipath-langchain declares, so a backward-compatible minor bump +# does not break resolution purely on that cap. Mirrors the uv override used in +# the cross-repo test workflows. +[tool.uv] +override-dependencies = ["uipath"] \ No newline at end of file diff --git a/packages/uipath/tests/cli/eval/test_eval_telemetry.py b/packages/uipath/tests/cli/eval/test_eval_telemetry.py index 48911638a..49a71bc2f 100644 --- a/packages/uipath/tests/cli/eval/test_eval_telemetry.py +++ b/packages/uipath/tests/cli/eval/test_eval_telemetry.py @@ -25,6 +25,7 @@ EvalSetRunCreatedEvent, EvalSetRunUpdatedEvent, ) +from uipath.platform.common.constants import ENV_UIPATH_AGENT_ID class TestEventNameConstants: @@ -422,6 +423,10 @@ def test_enrich_properties_adds_env_vars(self, mock_get_claim): """Test that environment variables are added when present.""" mock_get_claim.return_value = "user-789" + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() + subscriber = EvalTelemetrySubscriber() properties: dict[str, Any] = {} @@ -429,6 +434,7 @@ def test_enrich_properties_adds_env_vars(self, mock_get_claim): os.environ, { "UIPATH_PROJECT_ID": "project-123", + ENV_UIPATH_AGENT_ID: "agent-123", "UIPATH_ORGANIZATION_ID": "org-456", "UIPATH_TENANT_ID": "tenant-abc", "UIPATH_EVAL_RUN_SOURCE": "FirstSuccessfulRun", @@ -437,7 +443,7 @@ def test_enrich_properties_adds_env_vars(self, mock_get_claim): subscriber._enrich_properties(properties) assert properties["ProjectId"] == "project-123" - assert properties["AgentId"] == "project-123" + assert properties["AgentId"] == "agent-123" assert properties["CloudOrganizationId"] == "org-456" assert properties["CloudUserId"] == "user-789" assert properties["TenantId"] == "tenant-abc" @@ -448,6 +454,10 @@ def test_enrich_properties_skips_missing_env_vars(self, mock_get_claim): """Test that missing environment variables are not added.""" mock_get_claim.side_effect = Exception("No token") + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() + subscriber = EvalTelemetrySubscriber() properties: dict[str, Any] = {} @@ -455,6 +465,7 @@ def test_enrich_properties_skips_missing_env_vars(self, mock_get_claim): # Remove env vars if they exist for key in [ "UIPATH_PROJECT_ID", + ENV_UIPATH_AGENT_ID, "UIPATH_ORGANIZATION_ID", "UIPATH_TENANT_ID", "UIPATH_EVAL_RUN_SOURCE", diff --git a/packages/uipath/tests/cli/test_init.py b/packages/uipath/tests/cli/test_init.py index 59d4eaaa8..afa5d15fa 100644 --- a/packages/uipath/tests/cli/test_init.py +++ b/packages/uipath/tests/cli/test_init.py @@ -1,5 +1,6 @@ import json import os +import uuid from unittest.mock import patch import pytest @@ -57,6 +58,101 @@ def test_init_creates_empty_uipath_json( assert isinstance(config["functions"], dict) assert len(config["functions"]) == 0 + def test_init_mints_agent_id_in_uipath_json( + self, runner: CliRunner, temp_dir: str + ) -> None: + """init writes a valid id into a newly created uipath.json.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + self._generate_pyproject() + result = runner.invoke(cli, ["init"], env={}) + assert result.exit_code == 0 + + with open("uipath.json", "r") as f: + config = json.load(f) + assert "id" in config + # Must be a valid UUID-shaped identifier. + uuid.UUID(config["id"]) + + def test_init_does_not_create_telemetry_file( + self, runner: CliRunner, temp_dir: str + ) -> None: + """init no longer writes .uipath/.telemetry.json; the id lives in uipath.json.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + self._generate_pyproject() + result = runner.invoke(cli, ["init"], env={}) + assert result.exit_code == 0 + + assert not os.path.exists(os.path.join(".uipath", ".telemetry.json")) + with open("uipath.json", "r") as f: + uuid.UUID(json.load(f)["id"]) + + def test_init_mints_agent_id_with_telemetry_disabled( + self, runner: CliRunner, temp_dir: str + ) -> None: + """id is still written when telemetry is opted out.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + self._generate_pyproject() + result = runner.invoke( + cli, ["init"], env={"UIPATH_TELEMETRY_ENABLED": "false"} + ) + assert result.exit_code == 0 + + assert not os.path.exists(os.path.join(".uipath", ".telemetry.json")) + with open("uipath.json", "r") as f: + config = json.load(f) + uuid.UUID(config["id"]) + + def test_init_preserves_existing_agent_id( + self, runner: CliRunner, temp_dir: str + ) -> None: + """init keeps an id already present in uipath.json (first writer wins).""" + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("main.py", "w") as f: + f.write("def main(input: str) -> str: return input") + with open("uipath.json", "w") as f: + json.dump( + { + "id": "existing-agent-id", + "functions": {"main": "main.py:main"}, + }, + f, + ) + self._generate_pyproject() + + result = runner.invoke(cli, ["init"], env={}) + assert result.exit_code == 0 + # Existing id must not be backfilled/overwritten. + assert "with 'id'" not in result.output + + with open("uipath.json", "r") as f: + assert json.load(f)["id"] == "existing-agent-id" + + def test_init_backfills_agent_id_from_telemetry( + self, runner: CliRunner, temp_dir: str + ) -> None: + """init backfills id on an existing uipath.json, reusing the telemetry key.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("main.py", "w") as f: + f.write("def main(input: str) -> str: return input") + with open("uipath.json", "w") as f: + json.dump({"functions": {"main": "main.py:main"}}, f) + os.makedirs(".uipath", exist_ok=True) + with open(os.path.join(".uipath", ".telemetry.json"), "w") as f: + json.dump({"ProjectKey": "legacy-project-key"}, f) + self._generate_pyproject() + + result = runner.invoke(cli, ["init"], env={}) + assert result.exit_code == 0 + assert "Updated 'uipath.json' file with 'id'" in result.output + + with open("uipath.json", "r") as f: + config = json.load(f) + assert config["id"] == "legacy-project-key" + # Existing fields are preserved. + assert config["functions"]["main"] == "main.py:main" + # The backfill is targeted: no defaulted fields are materialized. + assert set(config.keys()) == {"functions", "id"} + def test_init_with_existing_uipath_json( self, runner: CliRunner, temp_dir: str ) -> None: diff --git a/packages/uipath/tests/cli/test_pack.py b/packages/uipath/tests/cli/test_pack.py index cf6bd5cc1..c644b3c7d 100644 --- a/packages/uipath/tests/cli/test_pack.py +++ b/packages/uipath/tests/cli/test_pack.py @@ -10,17 +10,17 @@ import uipath._cli.cli_pack as cli_pack from uipath._cli import cli from uipath._cli.middlewares import MiddlewareResult -from uipath._cli.models.uipath_json_schema import RuntimeOptions +from uipath._cli.models.uipath_json_schema import RuntimeOptions, UiPathJsonConfig -def create_bindings_file(): +def create_bindings_file(directory: str = "."): """Helper to create a default bindings.json file for tests.""" bindings_content = {"version": "2.0", "resources": []} - with open("bindings.json", "w") as f: + with open(os.path.join(directory, "bindings.json"), "w") as f: json.dump(bindings_content, f, indent=4) -def create_entry_points_file(entrypoint_type: str = "function"): +def create_entry_points_file(entrypoint_type: str = "function", directory: str = "."): """Helper to create a default entry-points.json file for tests.""" entry_points_content = { "$schema": "https://cloud.uipath.com/draft/2024-12/entry-point", @@ -38,7 +38,7 @@ def create_entry_points_file(entrypoint_type: str = "function"): } ], } - with open("entry-points.json", "w") as f: + with open(os.path.join(directory, "entry-points.json"), "w") as f: json.dump(entry_points_content, f, indent=4) @@ -1096,14 +1096,17 @@ def test_generate_operate_file(self, runner: CliRunner, temp_dir: str) -> None: ) ] - operate_data = cli_pack.generate_operate_file( - entrypoints, RuntimeOptions(is_conversational=False) + config = UiPathJsonConfig( + runtimeOptions=RuntimeOptions(is_conversational=False), + id="00000000-0000-0000-0000-000000000001", ) + operate_data = cli_pack.generate_operate_file(entrypoints, config) assert ( operate_data["$schema"] == "https://cloud.uipath.com/draft/2024-12/entry-point" ) + assert operate_data["projectId"] == "00000000-0000-0000-0000-000000000001" assert operate_data["main"] == "agent1.py" assert operate_data["contentType"] == "agent" assert operate_data["targetFramework"] == "Portable" @@ -1114,6 +1117,119 @@ def test_generate_operate_file(self, runner: CliRunner, temp_dir: str) -> None: "isConversational": False, } + def test_pack_uses_agent_id_as_project_id( + self, + runner: CliRunner, + temp_dir: str, + project_details: ProjectDetails, + ) -> None: + """operate.json projectId is sourced from uipath.json#id.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + config = create_uipath_json() + config["id"] = "00000000-0000-0000-0000-000000000001" + with open("uipath.json", "w") as f: + json.dump(config, f) + with open("pyproject.toml", "w") as f: + f.write(project_details.to_toml()) + with open("main.py", "w") as f: + f.write("def main(input): return input") + create_bindings_file() + create_entry_points_file() + + result = runner.invoke(cli, ["pack", "./"], env={}) + assert result.exit_code == 0 + + with zipfile.ZipFile( + f".uipath/{project_details.name}.{project_details.version}.nupkg", "r" + ) as z: + operate_data = json.loads(z.read("content/operate.json")) + assert operate_data["projectId"] == "00000000-0000-0000-0000-000000000001" + + def test_pack_fails_when_id_is_not_a_guid( + self, + runner: CliRunner, + temp_dir: str, + project_details: ProjectDetails, + ) -> None: + """pack fails when uipath.json#id is set but is not a valid GUID.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + config = create_uipath_json() + config["id"] = "not-a-guid" + with open("uipath.json", "w") as f: + json.dump(config, f) + with open("pyproject.toml", "w") as f: + f.write(project_details.to_toml()) + with open("main.py", "w") as f: + f.write("def main(input): return input") + create_bindings_file() + create_entry_points_file() + + result = runner.invoke(cli, ["pack", "./"], env={}) + assert result.exit_code != 0 + assert "must be a valid GUID" in result.output + + def test_pack_falls_back_to_telemetry_project_key( + self, + runner: CliRunner, + temp_dir: str, + project_details: ProjectDetails, + ) -> None: + """Without id, operate.json projectId falls back to the telemetry key.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + # uipath.json deliberately has no id (legacy project). + with open("uipath.json", "w") as f: + json.dump(create_uipath_json(), f) + with open("pyproject.toml", "w") as f: + f.write(project_details.to_toml()) + with open("main.py", "w") as f: + f.write("def main(input): return input") + create_bindings_file() + create_entry_points_file() + os.makedirs(".uipath", exist_ok=True) + with open(os.path.join(".uipath", ".telemetry.json"), "w") as f: + json.dump({"ProjectKey": "telemetry-fallback-key"}, f) + + result = runner.invoke(cli, ["pack", "./"], env={}) + assert result.exit_code == 0 + + with zipfile.ZipFile( + f".uipath/{project_details.name}.{project_details.version}.nupkg", "r" + ) as z: + operate_data = json.loads(z.read("content/operate.json")) + assert operate_data["projectId"] == "telemetry-fallback-key" + + def test_pack_telemetry_fallback_from_outside_project_dir( + self, + runner: CliRunner, + temp_dir: str, + project_details: ProjectDetails, + ) -> None: + """The legacy telemetry fallback resolves against the packed directory, not CWD.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + os.makedirs("project") + with open(os.path.join("project", "uipath.json"), "w") as f: + json.dump(create_uipath_json(), f) + with open(os.path.join("project", "pyproject.toml"), "w") as f: + f.write(project_details.to_toml()) + with open(os.path.join("project", "main.py"), "w") as f: + f.write("def main(input): return input") + create_bindings_file(directory="project") + create_entry_points_file(directory="project") + os.makedirs(os.path.join("project", ".uipath"), exist_ok=True) + with open(os.path.join("project", ".uipath", ".telemetry.json"), "w") as f: + json.dump({"ProjectKey": "telemetry-fallback-key"}, f) + + result = runner.invoke(cli, ["pack", "./project"], env={}) + assert result.exit_code == 0 + + # the package itself is written under the caller's CWD + with zipfile.ZipFile( + f".uipath/{project_details.name}.{project_details.version}.nupkg", + "r", + ) as z: + operate_data = json.loads(z.read("content/operate.json")) + assert operate_data["projectId"] == "telemetry-fallback-key" + def test_generate_bindings_content(self, runner: CliRunner, temp_dir: str) -> None: """Test generating bindings content.""" bindings_data = cli_pack.generate_bindings_content() diff --git a/packages/uipath/tests/telemetry/test_track.py b/packages/uipath/tests/telemetry/test_track.py index fe72130d5..827b3878d 100644 --- a/packages/uipath/tests/telemetry/test_track.py +++ b/packages/uipath/tests/telemetry/test_track.py @@ -1,11 +1,20 @@ """Tests for telemetry tracking functionality.""" +import json import os from unittest.mock import MagicMock, patch +import pytest + +from uipath.platform.common.constants import ( + ENV_PROJECT_KEY, + ENV_UIPATH_AGENT_ID, + ENV_UIPATH_PROJECT_ID, +) from uipath.telemetry._track import ( _AppInsightsEventClient, _DiagnosticSender, + _get_project_key, _parse_connection_string, _TelemetryClient, flush_events, @@ -17,6 +26,43 @@ ) +class TestGetProjectKey: + """`_get_project_key` resolution: uipath.json#id, then legacy telemetry file.""" + + @pytest.fixture(autouse=True) + def _clear_cache(self, monkeypatch): + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() + for var in (ENV_UIPATH_AGENT_ID, ENV_UIPATH_PROJECT_ID, ENV_PROJECT_KEY): + monkeypatch.delenv(var, raising=False) + yield + _read_config_id.cache_clear() + + def test_prefers_uipath_json_id(self, monkeypatch, tmp_path): + config_id = "00000000-0000-0000-0000-000000000001" + (tmp_path / "uipath.json").write_text(json.dumps({"id": config_id})) + os.makedirs(tmp_path / ".uipath", exist_ok=True) + (tmp_path / ".uipath" / ".telemetry.json").write_text( + json.dumps({"ProjectKey": "from-telemetry"}) + ) + monkeypatch.chdir(tmp_path) + assert _get_project_key() == config_id + + def test_falls_back_to_legacy_telemetry_file(self, monkeypatch, tmp_path): + # No uipath.json#id and no env var; honor an existing .telemetry.json. + os.makedirs(tmp_path / ".uipath", exist_ok=True) + (tmp_path / ".uipath" / ".telemetry.json").write_text( + json.dumps({"ProjectKey": "from-telemetry"}) + ) + monkeypatch.chdir(tmp_path) + assert _get_project_key() == "from-telemetry" + + def test_unknown_when_no_source(self, monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + assert _get_project_key() == "" + + class TestParseConnectionString: """Test connection string parsing functionality.""" diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 6f903c231..a0c0cd96d 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.84" +version = "2.11.0" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.66" +version = "0.1.67" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From b2b36089b36b96dffc99ad361b67a628366d29f4 Mon Sep 17 00:00:00 2001 From: Giulia Imbrea Date: Tue, 16 Jun 2026 20:36:39 +0300 Subject: [PATCH 103/191] docs: add SETUP.MD (#1682) --- SETUP.MD | 133 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 SETUP.MD diff --git a/SETUP.MD b/SETUP.MD new file mode 100644 index 000000000..4e728471b --- /dev/null +++ b/SETUP.MD @@ -0,0 +1,133 @@ +# SETUP.MD + +This file documents how to provision a clean development environment for the three packages in this repo (`uipath-core`, `uipath-platform`, `uipath`), run the build, execute the tests, and validate a sample code change end-to-end. It is intended both as a quick reference for human contributors and as a structured guide for automated environment-setup tooling. + +## Prerequisites + +- Python 3.11+ +- [uv](https://docs.astral.sh/uv/) 0.5+ + +### Supported platforms + +`uv` is shell- and OS-agnostic, so the commands below run unchanged on every supported platform: + +- [x] Linux +- [x] Windows +- [x] macOS + +## Environment Variables + +None required for environment setup, build, or unit tests. The suites under the `Test` section run fully offline and require no external authentication. + +> **All commands below must be run from the repository root.** The `uv --directory packages/` invocations resolve each subpackage relative to the current working directory. The first line of `## Setup` enforces this by `cd`-ing to the git root. + +## Setup + +```bash +cd "$(git rev-parse --show-toplevel)" +python3 -m pip install --upgrade uv + +# Sync all three packages (dependency order: core → platform → main) +uv --directory packages/uipath-core sync --all-extras +uv --directory packages/uipath-platform sync --all-extras +uv --directory packages/uipath sync --all-extras +``` + +## Verify Setup + +```bash +uv --version +uv --directory packages/uipath-core run python --version +uv --directory packages/uipath-core run python -c "import uipath.core; print('uipath-core ok')" +uv --directory packages/uipath-platform run python -c "import uipath.platform; print('uipath-platform ok')" +uv --directory packages/uipath run python -c "import uipath; print('uipath ok')" +``` + +## Build + +N/A + +## Test + +```bash +uv --directory packages/uipath-core run pytest +uv --directory packages/uipath-platform run pytest +uv --directory packages/uipath run pytest +``` + +> Note: `uipath-platform`'s `pyproject.toml` already excludes its E2E tests via `addopts = "... -m 'not e2e'"`. `uipath-core` and `uipath` do not register an `e2e` marker. + +## Sample Code Change + +### The change + +Add a new `size` property to `SpanRegistry` in `packages/uipath-core/src/uipath/core/tracing/span_utils.py`, immediately after the `clear` method and before the `# Global span registry instance` comment: + +```python +@property +def size(self) -> int: + """Return the number of currently registered spans.""" + return len(self._spans) +``` + +Then create `packages/uipath-core/tests/tracing/test_span_registry_size.py` with two pytest tests: + +```python +from unittest.mock import MagicMock + +from uipath.core.tracing.span_utils import SpanRegistry + + +def _make_span(span_id: int) -> MagicMock: + span = MagicMock() + span.get_span_context.return_value.span_id = span_id + span.parent = None # registered as a root span (no parent) + return span + + +def test_size_empty_registry() -> None: + registry = SpanRegistry() + assert registry.size == 0 + + +def test_size_after_registrations() -> None: + registry = SpanRegistry() + registry.register_span(_make_span(1)) + registry.register_span(_make_span(2)) + assert registry.size == 2 +``` + +### Verification + +```bash +uv --directory packages/uipath-core run pytest tests/tracing/test_span_registry_size.py -v +``` + +## Test with a real UiPath Coded Agent + +> This section is for human contributors who want to validate changes end-to-end against the real cloud platform. It is **not executed by the Agentic Inner Loop validation pipeline** — that pipeline only runs the sections above (Setup → Verify → Build → Test → Sample Code Change). + +The unit tests above are necessary but not sufficient — they don't exercise the package end-to-end through a real agent. The flow below validates changes against a live runtime: + +1. Apply the code changes locally. +2. Run the unit tests (see the `Sample Code Change` section above). +3. Scaffold a coded UiPath agent that exercises the changed code path. +4. In the downstream project's `pyproject.toml`, add this local library as an editable dependency (substitute `uipath`, `uipath-platform`, or `uipath-core` depending on which package you changed): + + ```toml + [tool.uv.sources] + uipath = { path = "../path/to/uipath-python/packages/uipath", editable = true } + ``` + +5. Exercise the new behavior end-to-end: + + ```bash + uv run uipath run --input '{...}' + ``` + +6. (Optional) Open a PR and apply the `build:dev` label — this publishes the development version to Test PyPI. +7. The PR description is updated automatically with instructions for pointing the downstream agent at the Test PyPI dev version. +8. Validate the new behavior against the real platform — use either or both of the deploy targets below (Studio Web and Orchestrator are not mutually exclusive): + - **Studio Web**: export the `UIPATH_PROJECT_ID` environment variable pointing to an existing Coded Agent project in your solution, then run [`uipath push`](https://uipath.github.io/uipath-python/cli/#push) to push the dev version to that project. Open it in Studio Web and exercise the changed code path. + - **Orchestrator**: run [`uipath deploy`](https://uipath.github.io/uipath-python/cli/#deploy) to deploy the dev version as a package, then start a job in Orchestrator and exercise the changed code path. +9. Once validation is done, close the dev PR — these PRs are not meant to be merged; their only purpose was to publish a Test PyPI build for end-to-end validation. From cc8284c23d691a096761bd4b0ed61c03ac3b3dd0 Mon Sep 17 00:00:00 2001 From: kunaluipath <138852527+kunaluipath@users.noreply.github.com> Date: Wed, 17 Jun 2026 02:32:04 -0400 Subject: [PATCH 104/191] feat(agent): add tool-output and V2 escalation recipient types [ACTN-10480] (#1667) Co-authored-by: Claude Opus 4.7 --- packages/uipath-platform/pyproject.toml | 2 +- .../platform/action_center/_tasks_service.py | 28 +++ .../uipath/platform/action_center/tasks.py | 19 +- .../tests/services/test_actions_service.py | 162 +++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 4 +- .../uipath/src/uipath/agent/models/agent.py | 99 +++++++++- .../uipath/tests/agent/models/test_agent.py | 185 ++++++++++++++++++ packages/uipath/uv.lock | 4 +- 9 files changed, 497 insertions(+), 8 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 464549970..698270df8 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.67" +version = "0.1.68" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py b/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py index 491b1bd73..2c4a4bde7 100644 --- a/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py +++ b/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py @@ -305,6 +305,34 @@ async def _assign_task_spec( } ] } + elif task_recipient.type == TaskRecipientType.WORKLOAD: + # This branch covers BOTH agent-side Workload criteria (single + # group, distributed by workload) AND agent-side CustomAssignees + # criteria (explicit email list — already resolved into + # `task_recipient.values` upstream). Both submit to the Action + # Center API as a "Workload" assignment; the difference is whether + # `values` carries one group or N emails. + request_spec.json = { + "taskAssignments": [ + { + "taskId": task_key, + "assignmentCriteria": "Workload", + "assigneeNamesOrEmails": task_recipient.values + or [recipient_value], + } + ] + } + elif task_recipient.type == TaskRecipientType.ROUND_ROBIN: + request_spec.json = { + "taskAssignments": [ + { + "taskId": task_key, + "assignmentCriteria": "RoundRobin", + "assigneeNamesOrEmails": task_recipient.values + or [recipient_value], + } + ] + } else: request_spec.json = { "taskAssignments": [ diff --git a/packages/uipath-platform/src/uipath/platform/action_center/tasks.py b/packages/uipath-platform/src/uipath/platform/action_center/tasks.py index f882cf40f..f1a932cb8 100644 --- a/packages/uipath-platform/src/uipath/platform/action_center/tasks.py +++ b/packages/uipath-platform/src/uipath/platform/action_center/tasks.py @@ -22,18 +22,35 @@ class TaskRecipientType(str, enum.Enum): GROUP_ID = "GroupId" EMAIL = "UserEmail" GROUP_NAME = "GroupName" + WORKLOAD = "Workload" + ROUND_ROBIN = "RoundRobin" class TaskRecipient(BaseModel): - """Model representing a task recipient.""" + """Model representing a task recipient. + + `value` is the single identifier (group name, group id, user id, email, …). + `values` is the multi-assignee form used by Workload-with-custom-emails + assignments; when set it takes precedence over `value` for the + `assigneeNamesOrEmails` payload. + + Note: there is no CustomAssignees member here on purpose. The agent-side + CustomAssignees criteria (AgentEscalationRecipientType.CUSTOM_ASSIGNEES, + type 11) is resolved to a Workload assignment with the explicit email list + in `values` before reaching this layer, so the Action Center + AssignTasks API only ever sees the existing literal types. + """ type: Literal[ TaskRecipientType.USER_ID, TaskRecipientType.GROUP_ID, TaskRecipientType.EMAIL, TaskRecipientType.GROUP_NAME, + TaskRecipientType.WORKLOAD, + TaskRecipientType.ROUND_ROBIN, ] = Field(..., alias="type") value: str = Field(..., alias="value") + values: Optional[List[str]] = Field(default=None, alias="values") display_name: Optional[str] = Field(default=None, alias="displayName") diff --git a/packages/uipath-platform/tests/services/test_actions_service.py b/packages/uipath-platform/tests/services/test_actions_service.py index 6758c452a..ea5fe2c84 100644 --- a/packages/uipath-platform/tests/services/test_actions_service.py +++ b/packages/uipath-platform/tests/services/test_actions_service.py @@ -7,6 +7,7 @@ from uipath.platform import UiPathApiConfig, UiPathExecutionContext from uipath.platform.action_center import Task from uipath.platform.action_center._tasks_service import TasksService +from uipath.platform.action_center.tasks import TaskRecipient, TaskRecipientType from uipath.platform.common.constants import HEADER_USER_AGENT @@ -186,6 +187,167 @@ def test_create_with_assignee( assert action.title == "Test Action" +def _mock_app_lookup_and_create( + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Common httpx mock setup for app lookup + task creation + assign.""" + monkeypatch.setenv("UIPATH_TENANT_ID", "test-tenant-id") + httpx_mock.add_response( + url=f"{base_url}{org}/apps_/default/api/v1/default/deployed-action-apps-schemas?search=test-app&filterByDeploymentTitle=true", + status_code=200, + json={ + "deployed": [ + { + "systemName": "test-app", + "deploymentTitle": "test-app", + "actionSchema": { + "key": "test-key", + "inputs": [], + "outputs": [], + "inOuts": [], + "outcomes": [], + }, + "deploymentFolder": { + "fullyQualifiedName": "test-folder-path", + "key": "test-folder-key", + }, + } + ] + }, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/tasks/AppTasks/CreateAppTask", + status_code=200, + json={"id": 1, "title": "Test Action"}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Tasks/UiPath.Server.Configuration.OData.AssignTasks", + status_code=200, + json={}, + ) + + +def _assign_request_payload(httpx_mock: HTTPXMock) -> dict[str, Any]: + """Return the parsed JSON body of the last AssignTasks request captured by the mock.""" + assign_request = next( + req + for req in reversed(httpx_mock.get_requests()) + if "AssignTasks" in str(req.url) + ) + return json.loads(assign_request.content) + + +class TestAssignTaskSpec: + """Tests for the task-assignment payload built by `_assign_task_spec`.""" + + def test_assign_workload_recipient_uses_workload_criteria_with_group( + self, + httpx_mock: HTTPXMock, + service: TasksService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + _mock_app_lookup_and_create(httpx_mock, base_url, org, tenant, monkeypatch) + + service.create( + title="Test Action", + app_name="test-app", + data={"x": 1}, + recipient=TaskRecipient( + type=TaskRecipientType.WORKLOAD, + value="Support Team", + displayName="Support Team", + ), + ) + + payload = _assign_request_payload(httpx_mock) + assert payload == { + "taskAssignments": [ + { + "taskId": 1, + "assignmentCriteria": "Workload", + "assigneeNamesOrEmails": ["Support Team"], + } + ] + } + + def test_assign_round_robin_recipient_uses_round_robin_criteria( + self, + httpx_mock: HTTPXMock, + service: TasksService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + _mock_app_lookup_and_create(httpx_mock, base_url, org, tenant, monkeypatch) + + service.create( + title="Test Action", + app_name="test-app", + data={"x": 1}, + recipient=TaskRecipient( + type=TaskRecipientType.ROUND_ROBIN, + value="Support Team", + displayName="Support Team", + ), + ) + + payload = _assign_request_payload(httpx_mock) + assert payload == { + "taskAssignments": [ + { + "taskId": 1, + "assignmentCriteria": "RoundRobin", + "assigneeNamesOrEmails": ["Support Team"], + } + ] + } + + def test_assign_workload_with_multiple_emails_uses_values_list( + self, + httpx_mock: HTTPXMock, + service: TasksService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Custom-assignees path: Workload criteria with a list of emails.""" + _mock_app_lookup_and_create(httpx_mock, base_url, org, tenant, monkeypatch) + + service.create( + title="Test Action", + app_name="test-app", + data={"x": 1}, + recipient=TaskRecipient( + type=TaskRecipientType.WORKLOAD, + value="alice@example.com", + values=["alice@example.com", "bob@example.com"], + ), + ) + + payload = _assign_request_payload(httpx_mock) + assert payload == { + "taskAssignments": [ + { + "taskId": 1, + "assignmentCriteria": "Workload", + "assigneeNamesOrEmails": [ + "alice@example.com", + "bob@example.com", + ], + } + ] + } + + def _make_deployed_app( name: str, folder_path: str, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 0e01f1f73..5d4c41d63 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.67" +version = "0.1.68" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index be0c4c22d..0471ece40 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.11.0" +version = "2.11.1" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.17, <0.6.0", "uipath-runtime>=0.11.0, <0.12.0", - "uipath-platform>=0.1.67, <0.2.0", + "uipath-platform>=0.1.68, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 385a37e7c..a14066969 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -140,6 +140,9 @@ class AgentEscalationRecipientType(str, CaseInsensitiveEnum): ASSET_GROUP_NAME = "AssetGroupName" ARGUMENT_EMAIL = "ArgumentEmail" ARGUMENT_GROUP_NAME = "ArgumentGroupName" + WORKLOAD = "Workload" + ROUND_ROBIN = "RoundRobin" + CUSTOM_ASSIGNEES = "CustomAssignees" class AgentEscalationChannelType(str, CaseInsensitiveEnum): @@ -547,6 +550,9 @@ class AgentA2aResourceConfig(BaseAgentResourceConfig): 6: AgentEscalationRecipientType.ASSET_GROUP_NAME, 7: AgentEscalationRecipientType.ARGUMENT_EMAIL, 8: AgentEscalationRecipientType.ARGUMENT_GROUP_NAME, + 9: AgentEscalationRecipientType.WORKLOAD, + 10: AgentEscalationRecipientType.ROUND_ROBIN, + 11: AgentEscalationRecipientType.CUSTOM_ASSIGNEES, } @@ -626,14 +632,105 @@ class ArgumentGroupNameRecipient(BaseEscalationRecipient): argument_path: str = Field(..., alias="argumentName") +class WorkloadRecipient(BaseEscalationRecipient): + """Workload-based group assignment. + + The Action Center distributes tasks to the group member with the lightest workload. + """ + + type: Literal[AgentEscalationRecipientType.WORKLOAD,] = Field(..., alias="type") + value: str = Field(..., alias="value") + display_name: str = Field(..., alias="displayName") + + +class RoundRobinRecipient(BaseEscalationRecipient): + """Round-robin group assignment. + + The Action Center cycles through group members in order on each new task. + """ + + type: Literal[AgentEscalationRecipientType.ROUND_ROBIN,] = Field(..., alias="type") + value: str = Field(..., alias="value") + display_name: str = Field(..., alias="displayName") + + +class CustomAssigneesRecipient(BaseEscalationRecipient): + """Custom multi-user assignment. + + A channel can carry multiple instances, one per assignee email. All are passed + to Action Center together using a Workload assignment criteria. + """ + + type: Literal[AgentEscalationRecipientType.CUSTOM_ASSIGNEES,] = Field( + ..., alias="type" + ) + value: str = Field(..., alias="value") + display_name: Optional[str] = Field(default=None, alias="displayName") + + +class ToolOutputRecipient(BaseEscalationRecipient): + """Recipient whose value is resolved at runtime from a named tool's output. + + Instead of a literal value entered at design time, this binding points at a + field within a named tool's output. The runtime walks the agent's message + history, finds the most recent ToolMessage matching `tool_name`, parses its + content as JSON, and extracts `output_path` (a top-level field for v1). + + Only the assignment-criteria recipient types that accept a runtime-computed + value are supported: USER_ID, GROUP_ID, WORKLOAD, ROUND_ROBIN, + CUSTOM_ASSIGNEES. The asset/static/argument types do not participate in + tool-output binding (they have their own design-time resolution rules). + """ + + type: Literal[ + AgentEscalationRecipientType.USER_ID, + AgentEscalationRecipientType.GROUP_ID, + AgentEscalationRecipientType.WORKLOAD, + AgentEscalationRecipientType.ROUND_ROBIN, + AgentEscalationRecipientType.CUSTOM_ASSIGNEES, + ] = Field(..., alias="type") + source: Literal["toolOutput"] = Field(..., alias="source") + tool_name: str = Field(..., alias="toolName") + output_path: str = Field(..., alias="outputPath") + + +# ────────────────────────────────────────────────────────────────────────────── +# AgentEscalationRecipient — Union ordering & invariants +# ────────────────────────────────────────────────────────────────────────────── +# Pydantic evaluates Union members left-to-right and stops at the first +# successful match, so member order determines which class a payload resolves +# to when multiple members share the same `type` value (e.g. WORKLOAD is valid +# on both WorkloadRecipient and ToolOutputRecipient). +# +# How dispatching works: +# - Payload with `source: "toolOutput"` → matches ToolOutputRecipient +# (it is the only class declaring `source` as a required Literal field). +# - Payload without `source` → ToolOutputRecipient validation fails +# (`source` missing), so it falls through to the literal class below +# that owns its `type`. +# +# Why we don't use `Field(discriminator="type")`: +# The `type` values are NOT unique across the Union — both WorkloadRecipient +# and ToolOutputRecipient declare `type=WORKLOAD`, same for the other +# tool-output-capable criteria. A typed discriminator requires unique +# discriminator values across members, which this union violates by design. +# +# Critical invariants (any of these breaking causes silent mis-typing): +# 1. ToolOutputRecipient remains the FIRST member of the Union. +# 2. ToolOutputRecipient.source remains a required `Literal["toolOutput"]` +# (NOT `Optional`, NOT a default value). +# 3. No literal class below it gains an optional `source` field. AgentEscalationRecipient = Annotated[ Union[ + ToolOutputRecipient, StandardRecipient, AssetRecipient, ArgumentEmailRecipient, ArgumentGroupNameRecipient, + WorkloadRecipient, + RoundRobinRecipient, + CustomAssigneesRecipient, ], - Field(discriminator="type"), BeforeValidator(_normalize_recipient_type), ] diff --git a/packages/uipath/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py index c324d4e7e..ab0e6a557 100644 --- a/packages/uipath/tests/agent/models/test_agent.py +++ b/packages/uipath/tests/agent/models/test_agent.py @@ -51,12 +51,16 @@ BatchTransformFileExtension, BatchTransformWebSearchGrounding, CitationMode, + CustomAssigneesRecipient, DeepRagFileExtension, + RoundRobinRecipient, StandardRecipient, TaskTitleType, TextBuilderTaskTitle, TextToken, TextTokenType, + ToolOutputRecipient, + WorkloadRecipient, ) from uipath.platform.guardrails import ( EnumListParameterValue, @@ -4368,3 +4372,184 @@ def test_agent_with_client_side_tool_output_schema_alias(self): assert tool.output_schema["type"] == "object" assert "imageBase64" in tool.output_schema["properties"] assert tool.output_schema["required"] == ["imageBase64"] + + +class TestCustomAssignmentRecipientDeserialization: + def test_workload_recipient_by_type_int(self): + payload = {"type": 9, "value": "group-1", "displayName": "Support Team"} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, WorkloadRecipient) + assert recipient.value == "group-1" + assert recipient.display_name == "Support Team" + assert recipient.type == AgentEscalationRecipientType.WORKLOAD + + def test_workload_recipient_by_type_string(self): + payload = { + "type": "Workload", + "value": "group-1", + "displayName": "Support Team", + } + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, WorkloadRecipient) + assert recipient.value == "group-1" + assert recipient.display_name == "Support Team" + + def test_round_robin_recipient_by_type_int(self): + payload = {"type": 10, "value": "group-1", "displayName": "Support Team"} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, RoundRobinRecipient) + assert recipient.value == "group-1" + assert recipient.display_name == "Support Team" + assert recipient.type == AgentEscalationRecipientType.ROUND_ROBIN + + def test_round_robin_recipient_by_type_string(self): + payload = { + "type": "RoundRobin", + "value": "group-1", + "displayName": "Support Team", + } + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, RoundRobinRecipient) + + def test_custom_assignees_recipient_by_type_int(self): + payload = { + "type": 11, + "value": "alice@example.com", + "displayName": "Alice", + } + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, CustomAssigneesRecipient) + assert recipient.value == "alice@example.com" + assert recipient.display_name == "Alice" + assert recipient.type == AgentEscalationRecipientType.CUSTOM_ASSIGNEES + + def test_custom_assignees_recipient_by_type_string(self): + payload = {"type": "CustomAssignees", "value": "alice@example.com"} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, CustomAssigneesRecipient) + assert recipient.value == "alice@example.com" + assert recipient.display_name is None + + def test_custom_assignees_recipient_accepts_empty_value_sentinel(self): + payload = {"type": 11, "value": ""} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, CustomAssigneesRecipient) + assert recipient.value == "" + + def test_workload_recipient_missing_value_raises(self): + payload = {"type": 9, "displayName": "Support Team"} + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_workload_recipient_missing_display_name_raises(self): + payload = {"type": 9, "value": "group-1"} + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_round_robin_recipient_missing_value_raises(self): + payload = {"type": 10, "displayName": "Support Team"} + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_custom_assignees_recipient_missing_value_raises(self): + payload = {"type": 11} + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + +class TestToolOutputRecipientDeserialization: + @pytest.mark.parametrize( + "recipient_type", + [1, 2, 9, 10, 11], + ) + def test_tool_output_recipient_by_type_int_for_supported_types( + self, recipient_type + ): + payload = { + "type": recipient_type, + "source": "toolOutput", + "toolName": "API workflow A", + "outputPath": "includeEmails", + } + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, ToolOutputRecipient) + assert recipient.tool_name == "API workflow A" + assert recipient.output_path == "includeEmails" + assert recipient.source == "toolOutput" + + def test_tool_output_recipient_for_custom_assignees_by_type_string(self): + payload = { + "type": "CustomAssignees", + "source": "toolOutput", + "toolName": "API workflow A", + "outputPath": "includeEmails", + } + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, ToolOutputRecipient) + assert recipient.type == AgentEscalationRecipientType.CUSTOM_ASSIGNEES + + def test_tool_output_recipient_missing_tool_name_raises(self): + payload = {"type": 11, "source": "toolOutput", "outputPath": "emails"} + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_tool_output_recipient_missing_output_path_raises(self): + payload = {"type": 11, "source": "toolOutput", "toolName": "A"} + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_tool_output_recipient_unknown_source_raises(self): + payload = { + "type": 11, + "source": "magicBox", + "toolName": "A", + "outputPath": "emails", + } + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + @pytest.mark.parametrize( + "recipient_type", + [3, 4, 5, 6, 7, 8], + ) + def test_tool_output_recipient_not_allowed_for_static_asset_argument_types( + self, recipient_type + ): + # Static/asset/argument types (3, 4, 5, 6, 7, 8) are not supported + # for tool-output binding because they have their own design-time + # resolution rules. + payload = { + "type": recipient_type, + "source": "toolOutput", + "toolName": "A", + "outputPath": "emails", + } + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_literal_recipient_without_source_still_parses_to_literal_class(self): + # Backward compat: a payload without `source` still matches the literal class. + payload = {"type": 11, "value": "alice@example.com", "displayName": "Alice"} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, CustomAssigneesRecipient) + assert not isinstance(recipient, ToolOutputRecipient) diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index a0c0cd96d..216cbeb0c 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.0" +version = "2.11.1" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.67" +version = "0.1.68" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 76040269f07d64bf72931a55f31c1f8d13820adf Mon Sep 17 00:00:00 2001 From: ajay-kesavan Date: Wed, 17 Jun 2026 00:00:29 -0700 Subject: [PATCH 105/191] fix(eval): skip synthesized tool spans in per-call extractors (#1724) Co-authored-by: Claude Opus 4.7 --- packages/uipath/pyproject.toml | 2 +- .../eval/_helpers/evaluators_helpers.py | 37 +++++++++----- .../evaluators/test_evaluator_helpers.py | 48 +++++++++++++++++++ packages/uipath/uv.lock | 2 +- 4 files changed, 75 insertions(+), 14 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 0471ece40..c887ffda1 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.11.1" +version = "2.11.3" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py b/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py index d64954b99..bc2168648 100644 --- a/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py +++ b/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py @@ -11,6 +11,8 @@ ToolOutput, ) +TOOL_NAME_ATTR = "tool.name" + COMPARATOR_MAPPINGS = { ">": "gt", "<": "lt", @@ -24,6 +26,18 @@ COMMUNITY_agents_SUFFIX = "-community-agents" +def _unsynthesized_tool_attrs(span: ReadableSpan) -> Mapping[str, Any] | None: + """Return span.attributes if this is a real tool invocation, else None.""" + attrs = span.attributes + if ( + not attrs + or attrs.get("tool.synthesized", False) + or not attrs.get(TOOL_NAME_ATTR) + ): + return None + return attrs + + def extract_tool_calls_names(spans: Sequence[ReadableSpan]) -> list[str]: """Extract the tool call names from execution spans IN ORDER. @@ -36,9 +50,8 @@ def extract_tool_calls_names(spans: Sequence[ReadableSpan]) -> list[str]: tool_calls_names = [] for span in spans: - # Check for tool.name attribute first - if span.attributes and (tool_name := span.attributes.get("tool.name")): - tool_calls_names.append(str(tool_name)) + if (attrs := _unsynthesized_tool_attrs(span)) is not None: + tool_calls_names.append(str(attrs[TOOL_NAME_ATTR])) return tool_calls_names @@ -55,20 +68,19 @@ def extract_tool_calls(spans: Sequence[ReadableSpan]) -> list[ToolCall]: tool_calls = [] for span in spans: - if span.attributes and (tool_name := span.attributes.get("tool.name")): + if (attrs := _unsynthesized_tool_attrs(span)) is not None: + tool_name = str(attrs[TOOL_NAME_ATTR]) try: - input_value: Any = span.attributes.get("input.value", {}) - # Ensure input_value is a string before parsing + input_value: Any = attrs.get("input.value", {}) if isinstance(input_value, str): arguments = ast.literal_eval(input_value) elif isinstance(input_value, dict): arguments = input_value else: arguments = {} - tool_calls.append(ToolCall(name=str(tool_name), args=arguments)) + tool_calls.append(ToolCall(name=tool_name, args=arguments)) except (json.JSONDecodeError, SyntaxError, ValueError): - # Handle case where input.value is not valid JSON/Python syntax - tool_calls.append(ToolCall(name=str(tool_name), args={})) + tool_calls.append(ToolCall(name=tool_name, args={})) return tool_calls @@ -87,8 +99,9 @@ def extract_tool_calls_outputs(spans: Sequence[ReadableSpan]) -> list[ToolOutput potential_output_keys = ["content"] tool_calls_outputs = [] for span in spans: - if span.attributes and (tool_name := span.attributes.get("tool.name")): - output = span.attributes.get("output.value", "") + if (attrs := _unsynthesized_tool_attrs(span)) is not None: + tool_name = str(attrs[TOOL_NAME_ATTR]) + output = attrs.get("output.value", "") final_output = "" # Handle different output formats @@ -465,7 +478,7 @@ def trace_to_str(agent_trace: Sequence[ReadableSpan]) -> str: seen_tool_calls = set() for span in agent_trace: - if span.attributes and (tool_name := span.attributes.get("tool.name")): + if span.attributes and (tool_name := span.attributes.get(TOOL_NAME_ATTR)): # Get span timing information start_time = span.start_time end_time = span.end_time diff --git a/packages/uipath/tests/evaluators/test_evaluator_helpers.py b/packages/uipath/tests/evaluators/test_evaluator_helpers.py index 84eb1159f..23b6d71e3 100644 --- a/packages/uipath/tests/evaluators/test_evaluator_helpers.py +++ b/packages/uipath/tests/evaluators/test_evaluator_helpers.py @@ -681,6 +681,54 @@ def test_extract_tool_calls_outputs_filters_non_tool_spans( assert "non_tool_span" not in output_names assert len(result) == 3 + def test_extractors_skip_synthesized_tool_spans(self) -> None: + """Spans tagged with tool.synthesized=True (BPMN container spans + synthesized for trajectory rendering) must be filtered from all three + per-call extractors so they don't pollute tool-call evaluator actuals. + """ + from opentelemetry.sdk.trace import ReadableSpan + + synth_process = ReadableSpan( + name="Instance: abc", + start_time=0, + end_time=1, + attributes={ + "tool.name": "FlowExecution", + "tool.synthesized": True, + "input.value": '{"instanceId": "abc"}', + "output.value": "Status: Completed", + }, + ) + synth_element = ReadableSpan( + name="Autonomous Agent", + start_time=2, + end_time=3, + attributes={ + "tool.name": "ServiceTask: Autonomous Agent", + "tool.synthesized": True, + "input.value": "{}", + "output.value": "Status: Completed", + }, + ) + real_tool = ReadableSpan( + name="Tool call - web_search", + start_time=4, + end_time=5, + attributes={ + "tool.name": "web_search", + "input.value": '{"query": "x"}', + "output.value": '{"content": "ok"}', + }, + ) + + spans = [synth_process, synth_element, real_tool] + + assert extract_tool_calls_names(spans) == ["web_search"] + calls = extract_tool_calls(spans) + assert [c.name for c in calls] == ["web_search"] + outputs = extract_tool_calls_outputs(spans) + assert [o.name for o in outputs] == ["web_search"] + def test_all_extraction_functions_consistent(self, sample_spans: list[Any]) -> None: """Test that all extraction functions return consistent results.""" names = extract_tool_calls_names(sample_spans) diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 216cbeb0c..0fb2c92cd 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.1" +version = "2.11.3" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From b7220919625d59f23c8dc9476ba24b6a02faee64 Mon Sep 17 00:00:00 2001 From: ajay-kesavan Date: Wed, 17 Jun 2026 00:20:40 -0700 Subject: [PATCH 106/191] feat(eval): match tool calls by id with name fallback (#1725) Co-authored-by: Claude Opus 4.7 --- packages/uipath/pyproject.toml | 2 +- .../eval/_helpers/evaluators_helpers.py | 219 ++++++++++++++-- .../evaluators/tool_call_count_evaluator.py | 9 +- .../evaluators/tool_call_order_evaluator.py | 12 +- .../uipath/src/uipath/eval/models/models.py | 16 +- .../evaluators/test_evaluator_helpers.py | 246 ++++++++++++++++++ packages/uipath/uv.lock | 2 +- 7 files changed, 468 insertions(+), 38 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index c887ffda1..98ecaa277 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.11.3" +version = "2.11.4" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py b/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py index bc2168648..912dcd41d 100644 --- a/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py +++ b/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py @@ -38,6 +38,86 @@ def _unsynthesized_tool_attrs(span: ReadableSpan) -> Mapping[str, Any] | None: return attrs +def _match_key(actual_name: str, actual_id: str | None, expected_key: str) -> bool: + """True when `expected_key` matches either the actual call's `id` or `name`. + + Eval-set criteria can be authored against either the tool's stable id or + its display name; the scorers accept whichever the author used. + """ + if actual_id is not None and expected_key == actual_id: + return True + return expected_key == actual_name + + +def _calls_match(actual, expected) -> bool: + """True when an actual ToolCall/ToolOutput matches an expected one. + + Prefers id-equality when both sides carry an id; otherwise falls back to + name-equality. This keeps the legacy name-keyed behavior intact while + making id-keyed eval-sets rename-safe. + """ + if actual.id is not None and expected.id is not None: + return actual.id == expected.id + return actual.name == expected.name + + +def _parse_tool_args(input_value: Any) -> dict[str, Any]: + """Coerce a span's `input.value` into a dict of tool args. + + Tries JSON first (handles `true`/`false`/`null` and double-quoted keys), + falls back to `ast.literal_eval` for Python literal syntax (single-quoted + dict repr). Returns `{}` for non-dict parsed values or any parse failure. + """ + if isinstance(input_value, dict): + return input_value + if not isinstance(input_value, str): + return {} + try: + try: + parsed = json.loads(input_value) + except ValueError: # JSONDecodeError is a ValueError + parsed = ast.literal_eval(input_value) + except (SyntaxError, ValueError): + return {} + return parsed if isinstance(parsed, dict) else {} + + +def _tool_id_from(attrs: Mapping[str, Any]) -> str | None: + """Return the span's `tool.id` as a string when present, else None. + + Uses `is not None` (not truthiness) so an id of 0 or '' isn't dropped. + """ + tool_id = attrs.get("tool.id") + return str(tool_id) if tool_id is not None else None + + +def _build_tool_call(span: ReadableSpan, include_args: bool) -> ToolCall | None: + """Build a ToolCall from a span, or None for synthesized / non-tool spans.""" + attrs = _unsynthesized_tool_attrs(span) + if attrs is None: + return None + tool_name = str(attrs[TOOL_NAME_ATTR]) + tool_id = _tool_id_from(attrs) + args = _parse_tool_args(attrs.get("input.value", {})) if include_args else {} + return ToolCall(name=tool_name, args=args, id=tool_id) + + +def count_tool_calls_by_name_and_id(tool_calls: Sequence[ToolCall]) -> dict[str, int]: + """Count tool calls under BOTH their name and id keys. + + Each call contributes one unit to its name bucket and (when present and + different) one unit to its id bucket. Lookups by either return the same + count for that tool. This lets `tool_calls_count_score` honour eval-set + criteria keyed by id with no signature change to the score function. + """ + counts: dict[str, int] = {} + for c in tool_calls: + counts[c.name] = counts.get(c.name, 0) + 1 + if c.id is not None and c.id != c.name: + counts[c.id] = counts.get(c.id, 0) + 1 + return counts + + def extract_tool_calls_names(spans: Sequence[ReadableSpan]) -> list[str]: """Extract the tool call names from execution spans IN ORDER. @@ -56,33 +136,23 @@ def extract_tool_calls_names(spans: Sequence[ReadableSpan]) -> list[str]: return tool_calls_names -def extract_tool_calls(spans: Sequence[ReadableSpan]) -> list[ToolCall]: - """Extract the tool calls from execution spans with their arguments. +def extract_tool_calls( + spans: Sequence[ReadableSpan], + include_args: bool = True, +) -> list[ToolCall]: + """Extract the tool calls from execution spans. Args: spans: List of ReadableSpan objects from agent execution. + include_args: When False, skip parsing `input.value` and return + ToolCall objects with `args={}`. Use for evaluators that only + need name/id (count, order) — avoids a parse per span on large + traces. Returns: - Dict of tool calls with their arguments. + List of tool calls with their arguments. """ - tool_calls = [] - - for span in spans: - if (attrs := _unsynthesized_tool_attrs(span)) is not None: - tool_name = str(attrs[TOOL_NAME_ATTR]) - try: - input_value: Any = attrs.get("input.value", {}) - if isinstance(input_value, str): - arguments = ast.literal_eval(input_value) - elif isinstance(input_value, dict): - arguments = input_value - else: - arguments = {} - tool_calls.append(ToolCall(name=tool_name, args=arguments)) - except (json.JSONDecodeError, SyntaxError, ValueError): - tool_calls.append(ToolCall(name=tool_name, args={})) - - return tool_calls + return [c for s in spans if (c := _build_tool_call(s, include_args)) is not None] def extract_tool_calls_outputs(spans: Sequence[ReadableSpan]) -> list[ToolOutput]: @@ -101,6 +171,7 @@ def extract_tool_calls_outputs(spans: Sequence[ReadableSpan]) -> list[ToolOutput for span in spans: if (attrs := _unsynthesized_tool_attrs(span)) is not None: tool_name = str(attrs[TOOL_NAME_ATTR]) + tool_id = _tool_id_from(attrs) output = attrs.get("output.value", "") final_output = "" @@ -131,8 +202,9 @@ def extract_tool_calls_outputs(spans: Sequence[ReadableSpan]) -> list[ToolOutput tool_calls_outputs.append( ToolOutput( - name=str(tool_name), + name=tool_name, output=str(final_output) if final_output else "", + id=tool_id, ) ) return tool_calls_outputs @@ -209,6 +281,105 @@ def tool_calls_order_score( return lcs_length / n, justification +def _strict_order_score( + actual: Sequence[ToolCall], + expected: Sequence[str], + justification: dict[str, Any], +) -> tuple[float, dict[str, Any]]: + """Strict-mode evaluation — only an exact positional match scores 1.0.""" + if len(actual) != len(expected): + return 0.0, justification + for i, key in enumerate(expected): + if not _match_key(actual[i].name, actual[i].id, key): + return 0.0, justification + justification["lcs"] = list(expected) + return 1.0, justification + + +def _build_lcs_dp( + actual: Sequence[ToolCall], expected: Sequence[str] +) -> list[list[int]]: + """Fill the LCS dynamic-programming table for id-aware matching.""" + m, n = len(actual), len(expected) + dp = [[0] * (n + 1) for _ in range(m + 1)] + for i in range(1, m + 1): + for j in range(1, n + 1): + if _match_key(actual[i - 1].name, actual[i - 1].id, expected[j - 1]): + dp[i][j] = dp[i - 1][j - 1] + 1 + else: + dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) + return dp + + +def _reconstruct_lcs( + actual: Sequence[ToolCall], + expected: Sequence[str], + dp: list[list[int]], +) -> list[str]: + """Walk the DP table backwards to recover the LCS as a list of expected keys.""" + lcs: list[str] = [] + i, j = len(actual), len(expected) + while i > 0 and j > 0: + if _match_key(actual[i - 1].name, actual[i - 1].id, expected[j - 1]): + lcs.append(expected[j - 1]) + i -= 1 + j -= 1 + elif dp[i - 1][j] > dp[i][j - 1]: + i -= 1 + else: + j -= 1 + lcs.reverse() + return lcs + + +def tool_calls_order_score_with_ids( + actual_tool_calls: Sequence[ToolCall], + expected_tool_calls_keys: Sequence[str], + strict: bool = False, +) -> tuple[float, dict[str, Any]]: + """LCS-based ordering score with id-aware matching. + + Identical scoring algorithm to `tool_calls_order_score`, but each expected + key string is allowed to match either the actual call's `id` or its + `name`. Use this when eval-set criteria may be authored against the + stable tool id so renames of `name` don't silently break ordering checks. + + Args: + actual_tool_calls: ToolCall objects in the actual order. Each may carry + an `id` from the runtime's `tool.id` span attribute. + expected_tool_calls_keys: List of names OR ids in the expected order. + strict: When True, only perfect matches score above 0. + + Returns: + Same shape as `tool_calls_order_score`. The "actual" justification + renders the resolved match-key sequence (id when available, else name) + so the LCS reconstruction reads clearly. + """ + actual_keys: list[str] = [ + (c.id if c.id is not None else c.name) for c in actual_tool_calls + ] + justification: dict[str, Any] = { + "actual": str(list(actual_keys)), + "expected": str(list(expected_tool_calls_keys)), + "lcs": [], + } + + if not expected_tool_calls_keys and not actual_tool_calls: + return 1.0, justification + if not expected_tool_calls_keys or not actual_tool_calls: + return 0.0, justification + + if strict: + return _strict_order_score( + actual_tool_calls, expected_tool_calls_keys, justification + ) + + dp = _build_lcs_dp(actual_tool_calls, expected_tool_calls_keys) + lcs = _reconstruct_lcs(actual_tool_calls, expected_tool_calls_keys, dp) + justification["lcs"] = lcs + return len(lcs) / len(expected_tool_calls_keys), justification + + def tool_calls_count_score( actual_tool_calls_count: Mapping[str, int], expected_tool_calls_count: Mapping[str, tuple[str, int]], @@ -323,7 +494,7 @@ def tool_calls_args_score( for expected_tool_call in expected_tool_calls: for idx, call in enumerate(actual_tool_calls): - if call.name == expected_tool_call.name and idx not in visited: + if _calls_match(call, expected_tool_call) and idx not in visited: # Get or initialize counter for this tool name tool_counters[call.name] = tool_counters.get(call.name, 0) tool_key = f"{call.name}_{tool_counters[call.name]}" @@ -415,7 +586,7 @@ def tool_calls_output_score( for idx, actual_tool_call_output in enumerate(actual_tool_calls_outputs): if idx in visited: continue - if actual_tool_call_output.name == expected_tool_call_output.name: + if _calls_match(actual_tool_call_output, expected_tool_call_output): # Get or initialize counter for this tool name tool_counters[actual_tool_call_output.name] = tool_counters.get( actual_tool_call_output.name, 0 diff --git a/packages/uipath/src/uipath/eval/evaluators/tool_call_count_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/tool_call_count_evaluator.py index 11d684ae1..4df237e7e 100644 --- a/packages/uipath/src/uipath/eval/evaluators/tool_call_count_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/tool_call_count_evaluator.py @@ -1,9 +1,8 @@ """Tool call count evaluator for validating expected tool usage patterns.""" -from collections import Counter - from .._helpers.evaluators_helpers import ( - extract_tool_calls_names, + count_tool_calls_by_name_and_id, + extract_tool_calls, tool_calls_count_score, ) from ..models import AgentExecution, EvaluationResult, NumericEvaluationResult @@ -72,8 +71,8 @@ async def evaluate( Returns: EvaluationResult: Boolean result indicating correct tool call order (True/False) """ - tool_calls_count = Counter( - extract_tool_calls_names(agent_execution.agent_trace) + tool_calls_count = count_tool_calls_by_name_and_id( + extract_tool_calls(agent_execution.agent_trace, include_args=False) ) score, justification = tool_calls_count_score( tool_calls_count, diff --git a/packages/uipath/src/uipath/eval/evaluators/tool_call_order_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/tool_call_order_evaluator.py index 1050ddc76..4676750d3 100644 --- a/packages/uipath/src/uipath/eval/evaluators/tool_call_order_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/tool_call_order_evaluator.py @@ -1,8 +1,8 @@ """Tool call order evaluator for validating correct sequence of tool calls.""" from .._helpers.evaluators_helpers import ( - extract_tool_calls_names, - tool_calls_order_score, + extract_tool_calls, + tool_calls_order_score_with_ids, ) from ..models import AgentExecution, EvaluationResult, NumericEvaluationResult from ..models.models import EvaluatorType @@ -69,9 +69,11 @@ async def evaluate( Returns: EvaluationResult: Boolean result indicating correct tool call order (True/False) """ - tool_calls_order = extract_tool_calls_names(agent_execution.agent_trace) - score, justification = tool_calls_order_score( - tool_calls_order, + actual_calls = extract_tool_calls( + agent_execution.agent_trace, include_args=False + ) + score, justification = tool_calls_order_score_with_ids( + actual_calls, evaluation_criteria.tool_calls_order, self.evaluator_config.strict, ) diff --git a/packages/uipath/src/uipath/eval/models/models.py b/packages/uipath/src/uipath/eval/models/models.py index d2dc26df9..14c130c92 100644 --- a/packages/uipath/src/uipath/eval/models/models.py +++ b/packages/uipath/src/uipath/eval/models/models.py @@ -303,17 +303,29 @@ class EvaluatorType(str, Enum): class ToolCall(BaseModel): - """Represents a tool call with its arguments.""" + """Represents a tool call with its arguments. + + `id` is the stable identifier from the tool's resource definition (e.g. a + UUID from `bindings.json`). When present on both the actual call and the + expected criterion, scorers match by `id` so a rename of `name` does not + break eval sets. When `id` is absent on either side, scorers fall back to + matching by `name` (the legacy behavior). + """ name: str args: dict[str, Any] + id: str | None = None class ToolOutput(BaseModel): - """Represents a tool output with its output.""" + """Represents a tool output with its output. + + See `ToolCall.id` for the id semantics. + """ name: str output: str + id: str | None = None class UiPathEvaluationErrorCategory(str, Enum): diff --git a/packages/uipath/tests/evaluators/test_evaluator_helpers.py b/packages/uipath/tests/evaluators/test_evaluator_helpers.py index 23b6d71e3..061b57aab 100644 --- a/packages/uipath/tests/evaluators/test_evaluator_helpers.py +++ b/packages/uipath/tests/evaluators/test_evaluator_helpers.py @@ -868,3 +868,249 @@ def test_extract_tool_calls_outputs_with_json_non_dict_value(self) -> None: assert result[0].name == "json_array_tool" # Should use the original string when parsed JSON is not a dict assert result[0].output == '["item1", "item2", "item3"]' + + +class TestIdAwareExtraction: + """Verify tool.id propagation through the three extractors, plus the + include_args=False optimization and the JSON-first parse fallback. + """ + + def test_extractors_read_tool_id_when_present(self) -> None: + """When a span carries `tool.id`, it must surface on ToolCall/ToolOutput.id.""" + from opentelemetry.sdk.trace import ReadableSpan + + span = ReadableSpan( + name="Tool call - Web_Search", + start_time=0, + end_time=1, + attributes={ + "tool.name": "Web_Search", + "tool.id": "7abae702-f898-4cc9-95f1-c365b9a857f9", + "input.value": "{}", + "output.value": '{"content": "ok"}', + }, + ) + calls = extract_tool_calls([span]) + outputs = extract_tool_calls_outputs([span]) + assert calls[0].id == "7abae702-f898-4cc9-95f1-c365b9a857f9" + assert calls[0].name == "Web_Search" + assert outputs[0].id == "7abae702-f898-4cc9-95f1-c365b9a857f9" + assert outputs[0].name == "Web_Search" + + def test_extractors_preserve_falsy_but_present_tool_id(self) -> None: + """tool.id of 0 or empty string is unusual but legal — must not be silently dropped. + + Original code used `if tool_id` which would treat 0 / '' as missing. + Fix uses `is not None`. + """ + from opentelemetry.sdk.trace import ReadableSpan + + for falsy_id in (0, "", False): + span = ReadableSpan( + name="t", + start_time=0, + end_time=1, + attributes={ + "tool.name": "f", + "tool.id": falsy_id, + "input.value": "{}", + "output.value": '{"content": "ok"}', + }, + ) + calls = extract_tool_calls([span]) + outputs = extract_tool_calls_outputs([span]) + assert calls[0].id == str(falsy_id), f"falsy id {falsy_id!r} was dropped" + assert outputs[0].id == str(falsy_id), f"falsy id {falsy_id!r} was dropped" + + def test_extract_tool_calls_parses_json_literals(self) -> None: + """input.value with JSON `true`/`false`/`null` should parse cleanly. + + `ast.literal_eval` doesn't recognise those tokens (Python uses + True/False/None); the extractor now tries `json.loads` first and only + falls back to `ast.literal_eval` on JSON parse failure. + """ + from opentelemetry.sdk.trace import ReadableSpan + + span = ReadableSpan( + name="t", + start_time=0, + end_time=1, + attributes={ + "tool.name": "t", + "input.value": '{"a": true, "b": false, "c": null}', + }, + ) + calls = extract_tool_calls([span]) + assert calls[0].args == {"a": True, "b": False, "c": None} + + def test_extract_tool_calls_falls_back_to_python_literal(self) -> None: + """Single-quoted Python dict repr (the historical input shape) still parses.""" + from opentelemetry.sdk.trace import ReadableSpan + + span = ReadableSpan( + name="t", + start_time=0, + end_time=1, + attributes={ + "tool.name": "t", + "input.value": "{'a': 1, 'b': 'two'}", # JSON-invalid, Python-valid + }, + ) + calls = extract_tool_calls([span]) + assert calls[0].args == {"a": 1, "b": "two"} + + def test_extract_tool_calls_non_dict_parsed_result_yields_empty_args(self) -> None: + """If input.value parses to a non-dict (e.g. a bare string), args→{}. + + Avoids pydantic validation failures from feeding a non-dict into + ToolCall(args=...). + """ + from opentelemetry.sdk.trace import ReadableSpan + + span = ReadableSpan( + name="t", + start_time=0, + end_time=1, + attributes={ + "tool.name": "t", + "input.value": '"hello"', # JSON-valid string, not a dict + }, + ) + calls = extract_tool_calls([span]) + assert calls[0].args == {} + + def test_extract_tool_calls_include_args_false_skips_parse(self) -> None: + """With include_args=False, broken input.value is not parsed and doesn't raise. + + Used by count / order evaluators that don't need args. + """ + from opentelemetry.sdk.trace import ReadableSpan + + span = ReadableSpan( + name="t", + start_time=0, + end_time=1, + attributes={ + "tool.name": "t", + "tool.id": "abc", + "input.value": "this is not valid python or json{{{", + }, + ) + calls = extract_tool_calls([span], include_args=False) + assert len(calls) == 1 + assert calls[0].name == "t" + assert calls[0].id == "abc" + assert calls[0].args == {} # short-circuited, not parsed + + def test_extractors_default_id_to_none_when_absent(self) -> None: + """Spans without `tool.id` produce ToolCall/ToolOutput with id=None (back-compat).""" + from opentelemetry.sdk.trace import ReadableSpan + + span = ReadableSpan( + name="Tool call - legacy", + start_time=0, + end_time=1, + attributes={ + "tool.name": "legacy_tool", + "input.value": "{}", + "output.value": '{"content": "ok"}', + }, + ) + calls = extract_tool_calls([span]) + outputs = extract_tool_calls_outputs([span]) + assert calls[0].id is None + assert outputs[0].id is None + + +class TestIdAwareMatching: + """Verify id-aware matching across all four tool-call scoring functions. + + For each function: an Expected criterion authored against the tool's id + matches the actual call when the actual carries the same id, even if the + `name` differs (the common case after a tool rename or the + 'Web Search' → 'Web_Search' display-vs-runtime divergence). + """ + + def test_args_score_matches_by_id_when_names_differ(self) -> None: + """Expected keyed by id matches actual with same id but different name.""" + from uipath.eval._helpers.evaluators_helpers import tool_calls_args_score + + actual = [ToolCall(name="Web_Search", id="uuid-1", args={"q": "x"})] + expected = [ToolCall(name="Web Search", id="uuid-1", args={"q": "x"})] + score, _ = tool_calls_args_score(actual, expected) + assert score == 1.0 + + def test_args_score_falls_back_to_name_when_id_missing(self) -> None: + """Legacy eval-set without id still matches by name (back-compat).""" + from uipath.eval._helpers.evaluators_helpers import tool_calls_args_score + + actual = [ToolCall(name="Web_Search", id="uuid-1", args={"q": "x"})] + expected = [ToolCall(name="Web_Search", args={"q": "x"})] + score, _ = tool_calls_args_score(actual, expected) + assert score == 1.0 + + def test_args_score_no_match_when_ids_differ(self) -> None: + """Different ids → no match even with same name.""" + from uipath.eval._helpers.evaluators_helpers import tool_calls_args_score + + actual = [ToolCall(name="Web_Search", id="uuid-A", args={"q": "x"})] + expected = [ToolCall(name="Web_Search", id="uuid-B", args={"q": "x"})] + score, _ = tool_calls_args_score(actual, expected) + assert score == 0.0 + + def test_output_score_matches_by_id(self) -> None: + from uipath.eval._helpers.evaluators_helpers import tool_calls_output_score + + actual = [ToolOutput(name="Web_Search", id="uuid-1", output="ok")] + expected = [ToolOutput(name="Web Search", id="uuid-1", output="ok")] + score, _ = tool_calls_output_score(actual, expected) + assert score == 1.0 + + def test_count_by_name_and_id_helper(self) -> None: + """Each call contributes one unit to its name AND to its id key.""" + from uipath.eval._helpers.evaluators_helpers import ( + count_tool_calls_by_name_and_id, + ) + + calls = [ + ToolCall(name="Web_Search", id="uuid-1", args={}), + ToolCall(name="Web_Search", id="uuid-1", args={}), + ToolCall(name="get_temp", args={}), # no id + ] + counts = count_tool_calls_by_name_and_id(calls) + assert counts["Web_Search"] == 2 + assert counts["uuid-1"] == 2 # same calls retrievable by id + assert counts["get_temp"] == 1 + + def test_order_score_with_ids_matches_id_keyed_expected(self) -> None: + """LCS treats expected key as match if it equals actual.id OR actual.name.""" + from uipath.eval._helpers.evaluators_helpers import ( + tool_calls_order_score_with_ids, + ) + + actual = [ + ToolCall(name="Web_Search", id="uuid-1", args={}), + ToolCall(name="Web_Search", id="uuid-1", args={}), + ] + # Expected authored by id + score, _ = tool_calls_order_score_with_ids(actual, ["uuid-1", "uuid-1"]) + assert score == 1.0 + # Expected authored by name (legacy) + score, _ = tool_calls_order_score_with_ids(actual, ["Web_Search", "Web_Search"]) + assert score == 1.0 + # Mixed: works either way + score, _ = tool_calls_order_score_with_ids(actual, ["uuid-1", "Web_Search"]) + assert score == 1.0 + + def test_order_score_with_ids_back_compat_when_id_absent(self) -> None: + """When actual has no ids (legacy traces), comparison is name-only.""" + from uipath.eval._helpers.evaluators_helpers import ( + tool_calls_order_score_with_ids, + ) + + actual = [ + ToolCall(name="get_temp", args={}), + ToolCall(name="get_humidity", args={}), + ] + score, _ = tool_calls_order_score_with_ids(actual, ["get_temp", "get_humidity"]) + assert score == 1.0 diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 0fb2c92cd..96ddf7869 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.3" +version = "2.11.4" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 1e4089117ac402ab0d7603edbd414a42329fd0ca Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan <84002867+viswa-uipath@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:44:49 +0530 Subject: [PATCH 107/191] feat(core): add EnforcementMode enum to governance contracts (#1727) Co-authored-by: Claude Opus 4.7 (1M context) --- packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/governance/__init__.py | 3 +- .../src/uipath/core/governance/config.py | 7 +++-- .../src/uipath/core/governance/models.py | 29 ++++++++++++++----- packages/uipath-core/uv.lock | 4 +-- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 7 files changed, 34 insertions(+), 15 deletions(-) diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index eef3ab27e..df8c0bb71 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.18" +version = "0.5.19" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/governance/__init__.py b/packages/uipath-core/src/uipath/core/governance/__init__.py index dd32228ed..3a06b82df 100644 --- a/packages/uipath-core/src/uipath/core/governance/__init__.py +++ b/packages/uipath-core/src/uipath/core/governance/__init__.py @@ -18,12 +18,13 @@ GovernanceViolation, Severity, ) -from .models import Action, AuditRecord, LifecycleHook, RuleEvaluation +from .models import Action, AuditRecord, EnforcementMode, LifecycleHook, RuleEvaluation __all__ = [ # Output models (cross adapter boundary) "Action", "AuditRecord", + "EnforcementMode", "LifecycleHook", "RuleEvaluation", # Config diff --git a/packages/uipath-core/src/uipath/core/governance/config.py b/packages/uipath-core/src/uipath/core/governance/config.py index 0bb0a6042..7fec33848 100644 --- a/packages/uipath-core/src/uipath/core/governance/config.py +++ b/packages/uipath-core/src/uipath/core/governance/config.py @@ -1,8 +1,11 @@ """Governance configuration. Process-level feature-flag gate that decides whether the Python -governance checker runs at all. Enforcement mode is per-policy and -lives in the runtime package alongside the ``/runtime/policy`` client. +governance checker runs at all. The +:class:`uipath.core.governance.EnforcementMode` value type is defined +in :mod:`uipath.core.governance.models`; the per-policy runtime state +that selects a mode (backend-supplied via the ``/runtime/policy`` +client) lives in the ``uipath-runtime`` package. """ from __future__ import annotations diff --git a/packages/uipath-core/src/uipath/core/governance/models.py b/packages/uipath-core/src/uipath/core/governance/models.py index 6b3993639..9fc5e2084 100644 --- a/packages/uipath-core/src/uipath/core/governance/models.py +++ b/packages/uipath-core/src/uipath/core/governance/models.py @@ -1,10 +1,17 @@ -"""Shared governance output types. - -These dataclasses cross the adapter boundary — every evaluator -implementation (native, AGT, composite, …) produces them, and every -adapter consumes them. They are kept free of policy-input concepts -(``Rule``/``Check``/``Condition``) so the adapter packages don't -inherit the native policy model. +"""Shared governance contracts. + +Two groups of types live here, both kept free of policy-input concepts +(``Rule``/``Check``/``Condition``) so adapter packages don't inherit +the native policy model: + +- **Output types** (:class:`Action`, :class:`LifecycleHook`, + :class:`RuleEvaluation`, :class:`AuditRecord`) — cross the adapter + boundary at evaluation time: every evaluator implementation (native, + AGT, composite, …) produces them, and every adapter consumes them. +- **Configuration value types** (:class:`EnforcementMode`) — describe + governance configuration shared by core, runtime, and consumers. The + runtime state that selects an enforcement mode lives in + ``uipath-runtime``; only the value type lives here. """ from __future__ import annotations @@ -35,6 +42,14 @@ class LifecycleHook(str, Enum): AFTER_TOOL = "after_tool" +class EnforcementMode(str, Enum): + """Governance enforcement modes.""" + + AUDIT = "audit" # Evaluate and log; never block. + ENFORCE = "enforce" # Block on DENY rules. + DISABLED = "disabled" # Skip evaluation entirely. + + @dataclass class RuleEvaluation: """Result of evaluating a single rule.""" diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index 0ef8204e9..2aec09333 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-06-06T13:38:31.678016Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [[package]] @@ -1011,7 +1011,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.18" +version = "0.5.19" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 5d4c41d63..2ce9597a8 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1063,7 +1063,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.18" +version = "0.5.19" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 96ddf7869..59a6df63d 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2659,7 +2659,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.18" +version = "0.5.19" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, From 0ac907089067100e549b37ae7bd76db114436407 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Wed, 17 Jun 2026 12:37:19 +0300 Subject: [PATCH 108/191] fix(platform): buckets.delete honors folder_path/folder_key args (#1729) --- packages/uipath-platform/pyproject.toml | 2 +- .../platform/orchestrator/_buckets_service.py | 4 +- .../tests/services/test_buckets_service.py | 84 +++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 5 files changed, 89 insertions(+), 5 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 698270df8..75e84a6f8 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.68" +version = "0.1.69" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_buckets_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_buckets_service.py index 0d536cd46..fc1d84db3 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_buckets_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_buckets_service.py @@ -365,7 +365,7 @@ def delete( self.request( "DELETE", url=f"/orchestrator_/odata/Buckets({bucket.id})", - headers={**self.folder_headers}, + headers={**header_folder(folder_key, folder_path)}, ) @resource_override(resource_type="bucket") @@ -386,7 +386,7 @@ async def delete_async( await self.request_async( "DELETE", url=f"/orchestrator_/odata/Buckets({bucket.id})", - headers={**self.folder_headers}, + headers={**header_folder(folder_key, folder_path)}, ) @resource_override(resource_type="bucket") diff --git a/packages/uipath-platform/tests/services/test_buckets_service.py b/packages/uipath-platform/tests/services/test_buckets_service.py index 0fbb5f974..8cbb9f50c 100644 --- a/packages/uipath-platform/tests/services/test_buckets_service.py +++ b/packages/uipath-platform/tests/services/test_buckets_service.py @@ -646,6 +646,90 @@ async def test_create_async( assert bucket.id == 1 +class TestDelete: + """Tests for delete() / delete_async(). + + Regression coverage for UV-14977: delete() must build the folder header + from the folder_path/folder_key arguments (via header_folder), not solely + from the UIPATH_FOLDER_PATH / UIPATH_FOLDER_KEY env vars. + """ + + def test_delete_by_name_uses_folder_path_arg( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """delete(name=..., folder_path=...) sends the arg folder header on DELETE.""" + # retrieve() locates the bucket in the target folder + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'old-storage'&$top=1", + status_code=200, + json={"value": [{"Id": 203380, "Name": "old-storage", "Identifier": "id"}]}, + match_headers={"x-uipath-folderpath": "Playground"}, + ) + # the DELETE must carry the arg folder header (not the env-var fallback) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(203380)", + method="DELETE", + status_code=204, + match_headers={"x-uipath-folderpath": "Playground"}, + ) + + service.delete(name="old-storage", folder_path="Playground") + + def test_delete_by_key_uses_folder_key_arg( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """delete(key=..., folder_key=...) sends the arg folder-key header on DELETE.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets/UiPath.Server.Configuration.OData.GetByKey(identifier='bucket-key')", + status_code=200, + json={"value": [{"Id": 55, "Name": "kbucket", "Identifier": "bucket-key"}]}, + match_headers={"x-uipath-folderkey": "folder-123"}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(55)", + method="DELETE", + status_code=204, + match_headers={"x-uipath-folderkey": "folder-123"}, + ) + + service.delete(key="bucket-key", folder_key="folder-123") + + @pytest.mark.asyncio + async def test_delete_async_uses_folder_path_arg( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Async version honors the folder_path argument on DELETE.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'old-storage'&$top=1", + status_code=200, + json={"value": [{"Id": 99, "Name": "old-storage", "Identifier": "id"}]}, + match_headers={"x-uipath-folderpath": "Playground"}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(99)", + method="DELETE", + status_code=204, + match_headers={"x-uipath-folderpath": "Playground"}, + ) + + await service.delete_async(name="old-storage", folder_path="Playground") + + class TestEdgeCases: """Tests for edge cases and error handling.""" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 2ce9597a8..a6cd279f6 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.68" +version = "0.1.69" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 59a6df63d..fd631cd69 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.68" +version = "0.1.69" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From aa5bb7e2bafe7490ce8df0803dbb2db207342437 Mon Sep 17 00:00:00 2001 From: Valentina Bojan Date: Wed, 17 Jun 2026 13:08:08 +0300 Subject: [PATCH 109/191] feat(guardrails): add enum/text/text-list validator parameter types (#1726) Co-authored-by: Valentina Bojan Co-authored-by: Claude --- packages/uipath-platform/pyproject.toml | 2 +- .../uipath/platform/guardrails/guardrails.py | 37 ++++++++++++++++++- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 75e84a6f8..adae84ca6 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.69" +version = "0.1.70" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py b/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py index cfc1e295f..16262ca5e 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py @@ -37,8 +37,43 @@ class NumberParameterValue(BaseModel): model_config = ConfigDict(populate_by_name=True, extra="allow") +class EnumParameterValue(BaseModel): + """Single-select enum parameter value.""" + + parameter_type: Literal["enum"] = Field(alias="$parameterType") + id: str + value: str + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + +class TextParameterValue(BaseModel): + """Free-text parameter value.""" + + parameter_type: Literal["text"] = Field(alias="$parameterType") + id: str + value: str + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + +class TextListParameterValue(BaseModel): + """List-of-text parameter value.""" + + parameter_type: Literal["text-list"] = Field(alias="$parameterType") + id: str + value: list[str] + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + ValidatorParameter = Annotated[ - EnumListParameterValue | MapEnumParameterValue | NumberParameterValue, + EnumListParameterValue + | MapEnumParameterValue + | NumberParameterValue + | EnumParameterValue + | TextParameterValue + | TextListParameterValue, Field(discriminator="parameter_type"), ] diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index a6cd279f6..6b5a34c52 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.69" +version = "0.1.70" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index fd631cd69..783b409a5 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.69" +version = "0.1.70" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From c9593922ae12696524ebbb3d12e1bd8171aed5a7 Mon Sep 17 00:00:00 2001 From: Christin <164907691+scottcmg@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:28:50 -0700 Subject: [PATCH 110/191] feat: send conversation owner id header on CAS websocket handshake [JAR-9965] (#1712) Co-authored-by: Claude Opus 4.8 (1M context) --- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/_cli/_chat/_bridge.py | 13 ++++- .../src/uipath/_cli/_chat/_voice_bridge.py | 9 ++++ packages/uipath/tests/cli/chat/test_bridge.py | 47 +++++++++++++++++++ .../tests/cli/chat/test_voice_bridge.py | 32 +++++++++++++ packages/uipath/uv.lock | 2 +- 6 files changed, 101 insertions(+), 4 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 98ecaa277..0add2e09e 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.11.4" +version = "2.11.5" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/_chat/_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_bridge.py index 96566e898..b4cddf3b8 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_bridge.py @@ -518,13 +518,22 @@ def get_chat_bridge( # Build headers from context headers = { "Authorization": f"Bearer {os.environ.get('UIPATH_ACCESS_TOKEN', '')}", - "X-UiPath-Internal-TenantId": f"{context.tenant_id}" + "X-UiPath-Internal-TenantId": context.tenant_id or os.environ.get("UIPATH_TENANT_ID", ""), - "X-UiPath-Internal-AccountId": f"{context.org_id}" + "X-UiPath-Internal-AccountId": context.org_id or os.environ.get("UIPATH_ORGANIZATION_ID", ""), "X-UiPath-ConversationId": context.conversation_id, } + # Conversation owner id (conversationalService.conversationalUserId) that CAS forwards via + # FpsProperties; always sent when present. It's there for RunAsMe=false, where the unattended + # robot's token subject is the robot account rather than the conversation owner, so CAS validates + # this presented id against conversation.user_id on the handshake instead of the token subject. + # Sent as a header (not a query param) to keep it out of access / load-balancer logs. + conversational_user_id = getattr(context, "conversational_user_id", None) + if conversational_user_id: + headers["X-UiPath-Internal-ConversationalUserId"] = conversational_user_id + return SocketIOChatBridge( websocket_url=websocket_url, websocket_path=websocket_path, diff --git a/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py index 6164b9f3d..8473c1574 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py @@ -249,6 +249,15 @@ def get_voice_bridge( "X-UiPath-ConversationId": context.conversation_id, } + # Conversation owner id (conversationalService.conversationalUserId) that CAS forwards via + # FpsProperties; always sent when present. It's there for RunAsMe=false, where the unattended + # robot's token subject is the robot account rather than the conversation owner, so CAS validates + # this presented id against conversation.user_id on the handshake instead of the token subject. + # Sent as a header (not a query param) to keep it out of access / load-balancer logs. + conversational_user_id = getattr(context, "conversational_user_id", None) + if conversational_user_id: + headers["X-UiPath-Internal-ConversationalUserId"] = conversational_user_id + return VoiceToolCallSession( url=url, socketio_path=socketio_path, diff --git a/packages/uipath/tests/cli/chat/test_bridge.py b/packages/uipath/tests/cli/chat/test_bridge.py index bbd385def..41659ffe2 100644 --- a/packages/uipath/tests/cli/chat/test_bridge.py +++ b/packages/uipath/tests/cli/chat/test_bridge.py @@ -206,6 +206,53 @@ def test_get_chat_bridge_constructs_correct_headers( assert "X-UiPath-ConversationId" in bridge.headers assert bridge.headers["X-UiPath-ConversationId"] == "conv-789" + def test_get_chat_bridge_falls_back_to_env_when_tenant_and_org_absent( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Tenant/account headers fall back to env vars when context values are None.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "my-access-token") + monkeypatch.setenv("UIPATH_TENANT_ID", "env-tenant") + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "env-org") + + context = MockRuntimeContext( + tenant_id=None, # type: ignore[arg-type] + org_id=None, # type: ignore[arg-type] + conversation_id="conv-789", + ) + + bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) + + assert bridge.headers["X-UiPath-Internal-TenantId"] == "env-tenant" + assert bridge.headers["X-UiPath-Internal-AccountId"] == "env-org" + + def test_get_chat_bridge_includes_conversational_user_id_header_when_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Conversation owner id (from FpsProperties) is sent on the handshake for CAS to validate.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "my-access-token") + + context = MockRuntimeContext(conversation_id="conv-789") + context.conversational_user_id = "owner-guid" # type: ignore[attr-defined] + + bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) + + assert bridge.headers["X-UiPath-Internal-ConversationalUserId"] == "owner-guid" + + def test_get_chat_bridge_omits_conversational_user_id_header_when_absent( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """No header is sent when the runtime has no owner id (backward compatible).""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "my-access-token") + + context = MockRuntimeContext(conversation_id="conv-789") + + bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) + + assert "X-UiPath-Internal-ConversationalUserId" not in bridge.headers + def test_get_chat_bridge_raises_without_uipath_url( self, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/packages/uipath/tests/cli/chat/test_voice_bridge.py b/packages/uipath/tests/cli/chat/test_voice_bridge.py index b945fb6e3..39e094bfa 100644 --- a/packages/uipath/tests/cli/chat/test_voice_bridge.py +++ b/packages/uipath/tests/cli/chat/test_voice_bridge.py @@ -135,3 +135,35 @@ def test_headers_fall_back_to_env_when_context_ids_are_none( assert bridge._headers["X-UiPath-Internal-TenantId"] == "env-tenant" assert bridge._headers["X-UiPath-Internal-AccountId"] == "env-org" + + def test_includes_conversational_user_id_header_when_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Conversation owner id (from FpsProperties) is sent on the handshake for CAS to validate.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + ctx = MagicMock( + conversation_id="conv-1", + tenant_id="t", + org_id="o", + conversational_user_id="owner-guid", + ) + + bridge = get_voice_bridge(ctx, AsyncMock()) + + assert bridge._headers["X-UiPath-Internal-ConversationalUserId"] == "owner-guid" + + def test_omits_conversational_user_id_header_when_none( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """No header is sent when the runtime has no owner id (backward compatible).""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + ctx = MagicMock( + conversation_id="conv-1", + tenant_id="t", + org_id="o", + conversational_user_id=None, + ) + + bridge = get_voice_bridge(ctx, AsyncMock()) + + assert "X-UiPath-Internal-ConversationalUserId" not in bridge._headers diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 783b409a5..86f8936e1 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.4" +version = "2.11.5" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 4e32d0447e748f2aebb24e74453e2d46512a2e38 Mon Sep 17 00:00:00 2001 From: ctiliescuuipath Date: Fri, 19 Jun 2026 15:00:56 +0300 Subject: [PATCH 111/191] feat(guardrails): extract span ID from traceparent header for trace correlation (#1734) Co-authored-by: Claude Opus 4.6 (1M context) --- packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/guardrails/guardrails.py | 9 +- packages/uipath-core/uv.lock | 2 +- packages/uipath-platform/pyproject.toml | 4 +- .../guardrails/_guardrails_service.py | 54 +++++- .../tests/services/test_guardrails_service.py | 176 ++++++++++++++++++ packages/uipath-platform/uv.lock | 4 +- packages/uipath/uv.lock | 4 +- 8 files changed, 243 insertions(+), 12 deletions(-) diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index df8c0bb71..ef97c0d24 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.19" +version = "0.5.20" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/guardrails/guardrails.py b/packages/uipath-core/src/uipath/core/guardrails/guardrails.py index fc7102904..576cc437a 100644 --- a/packages/uipath-core/src/uipath/core/guardrails/guardrails.py +++ b/packages/uipath-core/src/uipath/core/guardrails/guardrails.py @@ -1,7 +1,7 @@ """Guardrails models for UiPath Platform.""" from enum import Enum -from typing import Annotated, Any, Callable, Literal +from typing import Annotated, Any, Callable, Literal, Optional from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -30,6 +30,8 @@ class GuardrailValidationResult(BaseModel): Attributes: result: The validation result type. reason: Textual explanation describing why the validation passed or failed. + span_id: Span ID from the guardrail service response, formatted as a GUID + for trace correlation. None when the response omits the header. """ model_config = ConfigDict(populate_by_name=True) @@ -40,6 +42,11 @@ class GuardrailValidationResult(BaseModel): reason: str = Field( alias="reason", description="Explanation for the validation result." ) + span_id: Optional[str] = Field( + default=None, + alias="spanId", + description="Span ID returned by the guardrail service for trace correlation.", + ) class FieldSource(str, Enum): diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index 2aec09333..ee08896ea 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -1011,7 +1011,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.19" +version = "0.5.20" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index adae84ca6..37d6fcdaa 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.70" +version = "0.1.71" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" @@ -8,7 +8,7 @@ dependencies = [ "httpx>=0.28.1", "tenacity>=9.0.0", "truststore>=0.10.1", - "uipath-core>=0.5.8, <0.6.0", + "uipath-core>=0.5.20, <0.6.0", "pydantic-function-models>=0.1.11", "sqlparse>=0.5.5", ] diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py index ebfbaf33d..424b35dad 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py @@ -1,4 +1,5 @@ -from typing import Any +import re +from typing import Any, Optional from httpx import HTTPStatusError from uipath.core.guardrails import ( @@ -7,6 +8,7 @@ ) from uipath.core.tracing import traced +from ..chat.llm_trace_context import build_trace_context_headers from ..common._base_service import BaseService from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext @@ -14,6 +16,13 @@ from ..errors import EnrichedException from .guardrails import BuiltInValidatorGuardrail +# x-uipath-traceparent-id header format: {version}-{trace_id}-{span_id}[-{trace_flags}] +# Based on W3C traceparent but allows 16- or 32-hex span IDs. +_TRACEPARENT_PATTERN = re.compile( + r"^[0-9a-f]{2}-[0-9a-f]{32}-(?P[0-9a-f]{16}|[0-9a-f]{32})(?:-[0-9a-f]{2})?$", + re.IGNORECASE, +) + class GuardrailsService(BaseService): """Service for validating text against UiPath Guardrails. @@ -34,6 +43,31 @@ def __init__( ) -> None: super().__init__(config=config, execution_context=execution_context) + @staticmethod + def _extract_span_id_from_traceparent( + traceparent: Optional[str], + ) -> Optional[str]: + """Extract span ID from x-uipath-traceparent-id header and format as GUID. + + Args: + traceparent: Value from the ``x-uipath-traceparent-id`` response header. + Accepts 3-part ``"00-{trace_id}-{span_id}"`` or 4-part + ``"00-{trace_id}-{span_id}-{trace_flags}"``. Span ID may be + 16 or 32 hex chars. + + Returns: + Span ID formatted as lowercase GUID (8-4-4-4-12), or None if not parseable. + """ + if not traceparent: + return None + match = _TRACEPARENT_PATTERN.match(traceparent) + if not match: + return None + span_id_hex = match.group("span_id").lower() + # Pad to 32 chars for GUID conversion (span IDs may be 16 hex chars) + padded = span_id_hex.zfill(32) + return f"{padded[:8]}-{padded[8:12]}-{padded[12:16]}-{padded[16:20]}-{padded[20:32]}" + @staticmethod def _parse_result(result_str: str) -> GuardrailValidationResultType: """Parse result string from API response to GuardrailValidationResultType. @@ -88,12 +122,19 @@ def evaluate_guardrail( endpoint=Endpoint("/agentsruntime_/api/execution/guardrails/validate"), json=payload, ) + # Include trace context headers for server-side span correlation + trace_headers = build_trace_context_headers() + request_headers = {**(spec.headers or {}), **trace_headers} + span_id = None try: response = self.request( spec.method, url=spec.endpoint, json=spec.json, - headers=spec.headers, + headers=request_headers, + ) + span_id = self._extract_span_id_from_traceparent( + response.headers.get("x-uipath-traceparent-id") ) response_data = response.json() except EnrichedException as e: @@ -107,6 +148,11 @@ def evaluate_guardrail( and original_error.response ): try: + span_id = self._extract_span_id_from_traceparent( + original_error.response.headers.get( + "x-uipath-traceparent-id" + ) + ) response_data = original_error.response.json() except Exception: # If JSON parsing fails, re-raise the original exception @@ -127,9 +173,11 @@ def evaluate_guardrail( reason = response_data.get("details", "") # Prepare model data - model_data = { + model_data: dict[str, Any] = { "result": result.value, "reason": reason, } + if span_id: + model_data["spanId"] = span_id return GuardrailValidationResult.model_validate(model_data) diff --git a/packages/uipath-platform/tests/services/test_guardrails_service.py b/packages/uipath-platform/tests/services/test_guardrails_service.py index 9d8f5a900..dd20d5646 100644 --- a/packages/uipath-platform/tests/services/test_guardrails_service.py +++ b/packages/uipath-platform/tests/services/test_guardrails_service.py @@ -298,3 +298,179 @@ def capture_request(request): # Verify result fields assert result.result == GuardrailValidationResultType.PASSED assert result.reason == "Validation passed" + + def test_evaluate_guardrail_sends_trace_context_headers( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Outgoing request includes trace context headers.""" + captured_request = None + + def capture_request(request): + nonlocal captured_request + captured_request = request + return httpx.Response( + status_code=200, + json={ + "result": "PASSED", + "details": "OK", + }, + ) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", + callback=capture_request, + ) + + pii_guardrail = BuiltInValidatorGuardrail( + id="test-id", + name="PII guardrail", + description="Test", + enabled_for_evals=True, + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["tool1"] + ), + guardrail_type="builtInValidator", + validator_type="pii_detection", + validator_parameters=[], + ) + + service.evaluate_guardrail("test input", pii_guardrail) + + assert captured_request is not None + # build_trace_context_headers() injects traceparent/tracestate when + # an active span exists; at minimum, the merge with spec.headers + # should not fail and the request should go through successfully. + # When there IS an active trace context, headers are present: + headers = dict(captured_request.headers) + # The request should have been sent (basic smoke check that + # header merging works even when no active span exists) + assert "content-type" in headers + + def test_evaluate_guardrail_extracts_span_id_from_traceparent( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Response with x-uipath-traceparent-id header populates span_id.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", + status_code=200, + json={ + "result": "VALIDATION_FAILED", + "details": "PII detected", + }, + headers={ + "x-uipath-traceparent-id": "00-abcdef1234567890abcdef1234567890-1234567890abcdef" + }, + ) + + pii_guardrail = BuiltInValidatorGuardrail( + id="test-id", + name="PII guardrail", + description="Test", + enabled_for_evals=True, + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["tool1"] + ), + guardrail_type="builtInValidator", + validator_type="pii_detection", + validator_parameters=[], + ) + + result = service.evaluate_guardrail("test input", pii_guardrail) + + assert result.result == GuardrailValidationResultType.VALIDATION_FAILED + assert result.span_id == "00000000-0000-0000-1234-567890abcdef" + + def test_evaluate_guardrail_no_traceparent_header_no_span_id( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Response without x-uipath-traceparent-id header leaves span_id as None.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", + status_code=200, + json={ + "result": "PASSED", + "details": "OK", + }, + ) + + pii_guardrail = BuiltInValidatorGuardrail( + id="test-id", + name="PII guardrail", + description="Test", + enabled_for_evals=True, + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["tool1"] + ), + guardrail_type="builtInValidator", + validator_type="pii_detection", + validator_parameters=[], + ) + + result = service.evaluate_guardrail("test input", pii_guardrail) + + assert result.result == GuardrailValidationResultType.PASSED + assert result.span_id is None + + class TestExtractSpanIdFromTraceparent: + """Tests for _extract_span_id_from_traceparent.""" + + def test_valid_traceparent_16_char_span_id(self) -> None: + result = GuardrailsService._extract_span_id_from_traceparent( + "00-abcdef1234567890abcdef1234567890-1234567890abcdef" + ) + assert result == "00000000-0000-0000-1234-567890abcdef" + + def test_valid_traceparent_32_char_span_id(self) -> None: + result = GuardrailsService._extract_span_id_from_traceparent( + "00-abcdef1234567890abcdef1234567890-0a1b2c3d4e5f67890a1b2c3d4e5f6789" + ) + assert result == "0a1b2c3d-4e5f-6789-0a1b-2c3d4e5f6789" + + def test_none_input(self) -> None: + assert GuardrailsService._extract_span_id_from_traceparent(None) is None + + def test_empty_string(self) -> None: + assert GuardrailsService._extract_span_id_from_traceparent("") is None + + def test_valid_traceparent_4_part_with_trace_flags(self) -> None: + result = GuardrailsService._extract_span_id_from_traceparent( + "00-abcdef1234567890abcdef1234567890-1234567890abcdef-01" + ) + assert result == "00000000-0000-0000-1234-567890abcdef" + + def test_uppercase_hex_normalized_to_lowercase(self) -> None: + result = GuardrailsService._extract_span_id_from_traceparent( + "00-ABCDEF1234567890ABCDEF1234567890-1234567890ABCDEF" + ) + assert result == "00000000-0000-0000-1234-567890abcdef" + + def test_invalid_span_id_length_rejected(self) -> None: + """Span IDs that are neither 16 nor 32 hex chars are rejected.""" + assert ( + GuardrailsService._extract_span_id_from_traceparent( + "00-abcdef1234567890abcdef1234567890-1234abcd" + ) + is None + ) + + def test_invalid_format(self) -> None: + assert ( + GuardrailsService._extract_span_id_from_traceparent("not-valid") is None + ) diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 6b5a34c52..df0dc786a 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1063,7 +1063,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.19" +version = "0.5.20" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.70" +version = "0.1.71" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 86f8936e1..ff53d1215 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2659,7 +2659,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.19" +version = "0.5.20" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.70" +version = "0.1.71" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 6d9468532ead2b812fc31955a64cddc495296ec6 Mon Sep 17 00:00:00 2001 From: ajay-kesavan Date: Fri, 19 Jun 2026 19:21:35 -0700 Subject: [PATCH 112/191] feat(eval): id-aware match with sanitised-name fallback (Option C, layer 1/3) (#1736) Co-authored-by: Claude Opus 4.7 --- packages/uipath/pyproject.toml | 2 +- .../eval/_helpers/evaluators_helpers.py | 59 +++++----- .../evaluators/test_evaluator_helpers.py | 108 ++++++++++++++++-- packages/uipath/uv.lock | 2 +- 4 files changed, 129 insertions(+), 42 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 0add2e09e..fd088202e 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.11.5" +version = "2.11.6" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py b/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py index 912dcd41d..b639e631d 100644 --- a/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py +++ b/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py @@ -1,5 +1,6 @@ import ast import json +import re from collections.abc import Mapping, Sequence from datetime import datetime from typing import Any @@ -13,6 +14,17 @@ TOOL_NAME_ATTR = "tool.name" +# Mirrors uipath_langchain.agent.tools.utils.sanitize_tool_name; pinned by TestSanitizedNameMatch. +_TOOL_NAME_DISALLOWED = re.compile(r"[^a-zA-Z0-9_-]") + + +def _sanitize_tool_name(name: str | None) -> str: + """Sanitise a tool name the same way the LangChain runtime does.""" + if not name: + return "" + return _TOOL_NAME_DISALLOWED.sub("", "_".join(name.split()))[:64] + + COMPARATOR_MAPPINGS = { ">": "gt", "<": "lt", @@ -39,26 +51,19 @@ def _unsynthesized_tool_attrs(span: ReadableSpan) -> Mapping[str, Any] | None: def _match_key(actual_name: str, actual_id: str | None, expected_key: str) -> bool: - """True when `expected_key` matches either the actual call's `id` or `name`. - - Eval-set criteria can be authored against either the tool's stable id or - its display name; the scorers accept whichever the author used. - """ - if actual_id is not None and expected_key == actual_id: - return True - return expected_key == actual_name + """Strict per-call kind: id-only when actual has one, sanitised-name otherwise — never cross-kind.""" + if actual_id is not None: + return expected_key == actual_id + return _sanitize_tool_name(expected_key) == _sanitize_tool_name(actual_name) def _calls_match(actual, expected) -> bool: - """True when an actual ToolCall/ToolOutput matches an expected one. - - Prefers id-equality when both sides carry an id; otherwise falls back to - name-equality. This keeps the legacy name-keyed behavior intact while - making id-keyed eval-sets rename-safe. - """ - if actual.id is not None and expected.id is not None: - return actual.id == expected.id - return actual.name == expected.name + """Strict per-call kind: id-only when actual has one, sanitised-name otherwise — never cross-kind.""" + if actual.id is not None: + # Picker stores the id under `expected.name` when an id was chosen — honour either field. + expected_key = expected.id if expected.id is not None else expected.name + return actual.id == expected_key + return _sanitize_tool_name(actual.name) == _sanitize_tool_name(expected.name) def _parse_tool_args(input_value: Any) -> dict[str, Any]: @@ -103,18 +108,11 @@ def _build_tool_call(span: ReadableSpan, include_args: bool) -> ToolCall | None: def count_tool_calls_by_name_and_id(tool_calls: Sequence[ToolCall]) -> dict[str, int]: - """Count tool calls under BOTH their name and id keys. - - Each call contributes one unit to its name bucket and (when present and - different) one unit to its id bucket. Lookups by either return the same - count for that tool. This lets `tool_calls_count_score` honour eval-set - criteria keyed by id with no signature change to the score function. - """ + """Bucket each call under its id when present, else its name — strict per-call kind, no cross-kind matching.""" counts: dict[str, int] = {} for c in tool_calls: - counts[c.name] = counts.get(c.name, 0) + 1 - if c.id is not None and c.id != c.name: - counts[c.id] = counts.get(c.id, 0) + 1 + key = c.id if c.id is not None else c.name + counts[key] = counts.get(key, 0) + 1 return counts @@ -424,7 +422,12 @@ def tool_calls_count_score( expected_comparator, expected_count, ) in expected_tool_calls_count.items(): - actual_count = actual_tool_calls_count.get(tool_name, 0.0) + # Raw key first (id-keyed / exact-match), then sanitised (legacy display-name). `is None` not `or`: count of 0 is a hit. + actual_count = actual_tool_calls_count.get(tool_name) + if actual_count is None: + actual_count = actual_tool_calls_count.get( + _sanitize_tool_name(tool_name), 0 + ) comparator = f"__{COMPARATOR_MAPPINGS[expected_comparator]}__" to_add = float(getattr(actual_count, comparator)(expected_count)) diff --git a/packages/uipath/tests/evaluators/test_evaluator_helpers.py b/packages/uipath/tests/evaluators/test_evaluator_helpers.py index 061b57aab..6064381cb 100644 --- a/packages/uipath/tests/evaluators/test_evaluator_helpers.py +++ b/packages/uipath/tests/evaluators/test_evaluator_helpers.py @@ -12,6 +12,9 @@ import pytest from uipath.eval._helpers.evaluators_helpers import ( + _calls_match, + _match_key, + _sanitize_tool_name, extract_tool_calls, extract_tool_calls_names, extract_tool_calls_outputs, @@ -1040,13 +1043,22 @@ def test_args_score_matches_by_id_when_names_differ(self) -> None: score, _ = tool_calls_args_score(actual, expected) assert score == 1.0 - def test_args_score_falls_back_to_name_when_id_missing(self) -> None: - """Legacy eval-set without id still matches by name (back-compat).""" + def test_args_score_strict_kind_no_id_fallback(self) -> None: + """Strict kind: actual has id → id-only mode, no name fallback even when actual.name matches expected.name.""" from uipath.eval._helpers.evaluators_helpers import tool_calls_args_score actual = [ToolCall(name="Web_Search", id="uuid-1", args={"q": "x"})] expected = [ToolCall(name="Web_Search", args={"q": "x"})] score, _ = tool_calls_args_score(actual, expected) + assert score == 0.0 # actual.id="uuid-1" != expected.name="Web_Search" + + def test_args_score_name_only_when_actual_has_no_id(self) -> None: + """When actual has no id, sanitised-name comparison is the only path.""" + from uipath.eval._helpers.evaluators_helpers import tool_calls_args_score + + actual = [ToolCall(name="Web_Search", args={"q": "x"})] + expected = [ToolCall(name="Web Search", args={"q": "x"})] + score, _ = tool_calls_args_score(actual, expected) assert score == 1.0 def test_args_score_no_match_when_ids_differ(self) -> None: @@ -1067,7 +1079,7 @@ def test_output_score_matches_by_id(self) -> None: assert score == 1.0 def test_count_by_name_and_id_helper(self) -> None: - """Each call contributes one unit to its name AND to its id key.""" + """Strict per-call kind: id-keyed when call has id, name-keyed otherwise — never both.""" from uipath.eval._helpers.evaluators_helpers import ( count_tool_calls_by_name_and_id, ) @@ -1078,12 +1090,12 @@ def test_count_by_name_and_id_helper(self) -> None: ToolCall(name="get_temp", args={}), # no id ] counts = count_tool_calls_by_name_and_id(calls) - assert counts["Web_Search"] == 2 - assert counts["uuid-1"] == 2 # same calls retrievable by id - assert counts["get_temp"] == 1 + assert counts == {"uuid-1": 2, "get_temp": 1} + # Name key is NOT populated when id is present — kind separation. + assert "Web_Search" not in counts def test_order_score_with_ids_matches_id_keyed_expected(self) -> None: - """LCS treats expected key as match if it equals actual.id OR actual.name.""" + """Strict kind: actual has id → only id-keyed expected matches; legacy name-keyed against id-bearing actual is a miss.""" from uipath.eval._helpers.evaluators_helpers import ( tool_calls_order_score_with_ids, ) @@ -1092,15 +1104,15 @@ def test_order_score_with_ids_matches_id_keyed_expected(self) -> None: ToolCall(name="Web_Search", id="uuid-1", args={}), ToolCall(name="Web_Search", id="uuid-1", args={}), ] - # Expected authored by id + # Expected authored by id matches. score, _ = tool_calls_order_score_with_ids(actual, ["uuid-1", "uuid-1"]) assert score == 1.0 - # Expected authored by name (legacy) + # Expected authored by name against id-bearing actual is a miss (no cross-kind). score, _ = tool_calls_order_score_with_ids(actual, ["Web_Search", "Web_Search"]) - assert score == 1.0 - # Mixed: works either way + assert score == 0.0 + # Mixed expected: only the id-keyed element matches. score, _ = tool_calls_order_score_with_ids(actual, ["uuid-1", "Web_Search"]) - assert score == 1.0 + assert 0.0 < score < 1.0 def test_order_score_with_ids_back_compat_when_id_absent(self) -> None: """When actual has no ids (legacy traces), comparison is name-only.""" @@ -1114,3 +1126,75 @@ def test_order_score_with_ids_back_compat_when_id_absent(self) -> None: ] score, _ = tool_calls_order_score_with_ids(actual, ["get_temp", "get_humidity"]) assert score == 1.0 + + +class TestSanitizedNameMatch: + """Sanitised-name fallback in ``_match_key`` / ``_calls_match`` — id-equality wins first.""" + + @staticmethod + def _reference_sanitize(name: str) -> str: + """Pinned copy of ``uipath_langchain.agent.tools.utils.sanitize_tool_name``.""" + import re + + trim_whitespaces = "_".join(name.split()) + sanitized = re.sub(r"[^a-zA-Z0-9_-]", "", trim_whitespaces) + return sanitized[:64] + + @pytest.mark.parametrize( + "raw", + [ + "Web Search", + "Google Sheets / Read", + "Add Numbers", + "tool with spaces and (parens)", + "snake_case_tool", + "kebab-case-tool", + "alreadySanitised", + " multiple whitespace ", + "very-long-name-" + "x" * 100, + "", + ], + ) + def test_normalize_matches_langchain_reference(self, raw: str) -> None: + assert _sanitize_tool_name(raw) == self._reference_sanitize(raw) + + def test_normalize_handles_none(self) -> None: + assert _sanitize_tool_name(None) == "" + + def test_match_key_display_vs_sanitised(self) -> None: + assert _match_key("Web_Search", None, "Web Search") is True + + def test_match_key_id_wins_when_present(self) -> None: + assert _match_key("Web_Search", "webSearch1", "webSearch1") is True + # Strict kind: actual has id → display-name expected is rejected (no cross-kind). + assert _match_key("Web_Search", "webSearch1", "Web Search") is False + + def test_match_key_mismatch_after_sanitising(self) -> None: + assert _match_key("Web_Search", None, "Image_Search") is False + + def test_calls_match_display_vs_sanitised(self) -> None: + actual = ToolCall(name="Web_Search", args={}) + expected = ToolCall(name="Web Search", args={}) + assert _calls_match(actual, expected) is True + + def test_calls_match_id_equality_unchanged(self) -> None: + actual = ToolCall(name="Web_Search", id="webSearch1", args={}) + expected = ToolCall(name="totally different", id="webSearch1", args={}) + assert _calls_match(actual, expected) is True + + def test_calls_match_output_display_vs_sanitised(self) -> None: + actual = ToolOutput(name="Web_Search", output="x") + expected = ToolOutput(name="Web Search", output="x") + assert _calls_match(actual, expected) is True + + def test_count_score_display_name_matches_sanitised_actual(self) -> None: + actual = {"Web_Search": 2} + expected = {"Web Search": ("==", 2)} + score, _ = tool_calls_count_score(actual, expected) + assert score == 1.0 + + def test_count_score_id_keyed_expected_still_wins(self) -> None: + actual = {"Web_Search": 1, "webSearch1": 1} + expected = {"webSearch1": (">=", 1)} + score, _ = tool_calls_count_score(actual, expected) + assert score == 1.0 diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index ff53d1215..db1345c98 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.5" +version = "2.11.6" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From a99eaac18d2dabbcb73f631118b729344b57829c Mon Sep 17 00:00:00 2001 From: andrewwan-uipath <170454542+andrewwan-uipath@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:43:58 -0700 Subject: [PATCH 113/191] feat: optionally emit end exchange event to CAS [JAR-9933] (#1720) --- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/_cli/_chat/_bridge.py | 12 +++ packages/uipath/tests/cli/chat/test_bridge.py | 85 +++++++++++++++++++ packages/uipath/uv.lock | 10 +-- 4 files changed, 103 insertions(+), 6 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index fd088202e..581f9a3d2 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.11.6" +version = "2.11.7" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/_chat/_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_bridge.py index b4cddf3b8..442322e36 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_bridge.py @@ -106,6 +106,7 @@ def __init__( exchange_id: str, headers: dict[str, str], auth: dict[str, Any] | None = None, + end_exchange: bool = True, ): """Initialize the WebSocket chat bridge. @@ -115,6 +116,8 @@ def __init__( exchange_id: The exchange ID for this session headers: HTTP headers to send during connection auth: Optional authentication data to send during connection + end_exchange: Whether to send the exchange-end event to CAS on + completion. """ self.websocket_url = websocket_url self.websocket_path = websocket_path @@ -122,6 +125,7 @@ def __init__( self.exchange_id = exchange_id self.auth = auth self.headers = headers + self.end_exchange = end_exchange self._client: Any | None = None self._connected_event = asyncio.Event() @@ -283,9 +287,16 @@ async def emit_message_event( async def emit_exchange_end_event(self) -> None: """Send an exchange end event. + When end_exchange is False the exchange is left open — the event is not + sent to CAS so a downstream consumer can continue and end it later. + Raises: RuntimeError: If client is not connected """ + if not self.end_exchange: + logger.info("end_exchange is False; leaving the exchange open.") + return + if self._client is None: raise RuntimeError("WebSocket client not connected. Call connect() first.") @@ -540,6 +551,7 @@ def get_chat_bridge( conversation_id=context.conversation_id, exchange_id=context.exchange_id, headers=headers, + end_exchange=getattr(context, "end_exchange", True), ) diff --git a/packages/uipath/tests/cli/chat/test_bridge.py b/packages/uipath/tests/cli/chat/test_bridge.py index 41659ffe2..2c18640f3 100644 --- a/packages/uipath/tests/cli/chat/test_bridge.py +++ b/packages/uipath/tests/cli/chat/test_bridge.py @@ -22,11 +22,13 @@ def __init__( exchange_id: str = "test-exchange-id", tenant_id: str = "test-tenant-id", org_id: str = "test-org-id", + end_exchange: bool = True, ): self.conversation_id = conversation_id self.exchange_id = exchange_id self.tenant_id = tenant_id self.org_id = org_id + self.end_exchange = end_exchange class TestSocketIOChatBridgeDebugMode: @@ -357,6 +359,89 @@ async def test_emit_exchange_end_raises_without_client(self) -> None: assert "not connected" in str(exc_info.value).lower() +class TestSocketIOChatBridgeEndExchange: + """The bridge owns whether to honor the exchange-end event (CAS-specific).""" + + def _make_connected_bridge(self, end_exchange: bool) -> SocketIOChatBridge: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + end_exchange=end_exchange, + ) + bridge._websocket_disabled = False + bridge._client = AsyncMock() + bridge._connected_event.set() + return bridge + + def test_end_exchange_defaults_true(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + assert bridge.end_exchange is True + + @pytest.mark.anyio + async def test_emit_exchange_end_sends_when_end_exchange_true(self) -> None: + bridge = self._make_connected_bridge(end_exchange=True) + + await bridge.emit_exchange_end_event() + + cast(AsyncMock, bridge._client).emit.assert_awaited_once() + assert ( + cast(AsyncMock, bridge._client).emit.await_args.args[0] + == "ConversationEvent" + ) + + @pytest.mark.anyio + async def test_emit_exchange_end_suppressed_when_end_exchange_false(self) -> None: + bridge = self._make_connected_bridge(end_exchange=False) + + await bridge.emit_exchange_end_event() + + cast(AsyncMock, bridge._client).emit.assert_not_awaited() + + @pytest.mark.anyio + async def test_emit_exchange_end_false_does_not_require_client(self) -> None: + """With the exchange kept open, suppression happens before the connection check.""" + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + end_exchange=False, + ) + + # Should not raise even though _client is None. + await bridge.emit_exchange_end_event() + + def test_get_chat_bridge_propagates_end_exchange_false( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + context = MockRuntimeContext(end_exchange=False) + + bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) + + assert bridge.end_exchange is False + + def test_get_chat_bridge_defaults_end_exchange_true( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + context = MockRuntimeContext() + + bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) + + assert bridge.end_exchange is True + + class TestSignalRDebugBridgeSendMethod: """Tests for SignalRDebugBridge.""" diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index db1345c98..a805e5cdc 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-06-20T16:42:14.097008Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.6" +version = "2.11.7" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2729,14 +2729,14 @@ dev = [ [[package]] name = "uipath-runtime" -version = "0.11.0" +version = "0.11.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/8d/4d36d6a5dda4ca5f25e52508bc20dd82cb92fcdf2a36cd0adc4f9832d047/uipath_runtime-0.11.0.tar.gz", hash = "sha256:cc94f2fdab43b593ef678eff904fc6cdd4831963cffe39a83909ffcf9082d76f", size = 143685, upload-time = "2026-05-29T15:13:30.562Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/87/fed3a5bd3479b9e7dc6cba769b054f5f1c00e93762356a70010e32f1f03c/uipath_runtime-0.11.2.tar.gz", hash = "sha256:8b3cc986644d6c9f2365345c231577f97d3bad8fb105fe8a6c7e16508d00d9ef", size = 145770, upload-time = "2026-06-22T16:31:40.786Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/08/c7b90851d4544ff5e76ca7c55452597aae1619cf1ebc2c0aa7b098110f14/uipath_runtime-0.11.0-py3-none-any.whl", hash = "sha256:08bf53a0e38bb3d19edc6708d2ecb7d918aa96fdda13e35f3ad0e6f2a6c392b9", size = 43770, upload-time = "2026-05-29T15:13:29.282Z" }, + { url = "https://files.pythonhosted.org/packages/da/62/c649c18ac39f53e5603abbcfa6917f6e880ac08047b1ce69d4c3ee937de8/uipath_runtime-0.11.2-py3-none-any.whl", hash = "sha256:a3a14dc2378bc934437bbd7523cf884d0cee0eeef26c736a9d6ce504d8a9fea0", size = 43874, upload-time = "2026-06-22T16:31:39.328Z" }, ] [[package]] From 3753803cdcf6eef45eba96b1e47928817a824bbd Mon Sep 17 00:00:00 2001 From: AAgnihotry <95259907+AAgnihotry@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:23:42 -0700 Subject: [PATCH 114/191] feat(eval): add SimulateComponentMocker for per-component API-based simulation (#1732) --- packages/uipath/pyproject.toml | 2 +- .../simulate-component-agent/input.json | 4 + .../samples/simulate-component-agent/main.py | 118 +++++ .../simulate-component-agent/pyproject.toml | 17 + .../simulate-component-agent/simulation.json | 73 +++ .../simulate-component-agent/uipath.json | 6 + .../uipath/src/uipath/eval/mocks/__init__.py | 20 +- .../src/uipath/eval/mocks/_mock_context.py | 16 +- .../src/uipath/eval/mocks/_mock_runtime.py | 42 +- .../src/uipath/eval/mocks/_mocker_factory.py | 3 + .../eval/mocks/_simulate_component_mocker.py | 143 ++++++ .../eval/mocks/_simulate_component_service.py | 37 ++ .../uipath/src/uipath/eval/mocks/_types.py | 117 ++++- .../cli/eval/mocks/test_simulate_component.py | 442 ++++++++++++++++++ .../uipath/tests/cli/test_debug_simulation.py | 33 +- packages/uipath/uv.lock | 2 +- 16 files changed, 1054 insertions(+), 21 deletions(-) create mode 100644 packages/uipath/samples/simulate-component-agent/input.json create mode 100644 packages/uipath/samples/simulate-component-agent/main.py create mode 100644 packages/uipath/samples/simulate-component-agent/pyproject.toml create mode 100644 packages/uipath/samples/simulate-component-agent/simulation.json create mode 100644 packages/uipath/samples/simulate-component-agent/uipath.json create mode 100644 packages/uipath/src/uipath/eval/mocks/_simulate_component_mocker.py create mode 100644 packages/uipath/src/uipath/eval/mocks/_simulate_component_service.py create mode 100644 packages/uipath/tests/cli/eval/mocks/test_simulate_component.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 581f9a3d2..4038e1313 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.11.7" +version = "2.11.8" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/samples/simulate-component-agent/input.json b/packages/uipath/samples/simulate-component-agent/input.json new file mode 100644 index 000000000..68093d7d0 --- /dev/null +++ b/packages/uipath/samples/simulate-component-agent/input.json @@ -0,0 +1,4 @@ +{ + "city": "London", + "days": 3 +} diff --git a/packages/uipath/samples/simulate-component-agent/main.py b/packages/uipath/samples/simulate-component-agent/main.py new file mode 100644 index 000000000..0acd8f0cb --- /dev/null +++ b/packages/uipath/samples/simulate-component-agent/main.py @@ -0,0 +1,118 @@ +"""Weather forecast agent demonstrating per-component simulation. + +This sample shows the new ``components`` simulation format where each tool +has its own simulation strategy and instructions, routed to the +simulate-component API instead of a local LLM. + +Run with real tools (no weather API — returns hardcoded defaults): + uipath run main -f input.json + +Run with per-component simulation (routes each tool call to the API): + uipath run main -f input.json --simulation "$(cat simulation.json)" +""" + +from pydantic import BaseModel +from pydantic.dataclasses import dataclass + +from uipath.eval.mocks import mockable +from uipath.tracing import traced + +# --------------------------------------------------------------------------- +# Input / Output models +# --------------------------------------------------------------------------- + + +@dataclass +class WeatherInput: + city: str + days: int = 3 + + +class CurrentWeather(BaseModel): + city: str + temperature: float # Celsius + condition: str + humidity: int # percent + + +class ForecastDay(BaseModel): + date: str # YYYY-MM-DD + high: float + low: float + condition: str + + +class WeatherReport(BaseModel): + current: CurrentWeather + forecast: list[ForecastDay] + summary: str + + +# --------------------------------------------------------------------------- +# Mockable tool functions +# --------------------------------------------------------------------------- + + +@traced(name="get_current_weather", span_type="tool") +@mockable() +async def get_current_weather(city: str) -> CurrentWeather: + """Fetch current weather conditions for a city from an external weather API. + + Args: + city: Name of the city (e.g. "London", "New York"). + + Returns: + CurrentWeather with temperature, condition, and humidity. + """ + # Real implementation would call a weather API such as OpenWeatherMap. + # Returns hardcoded defaults when not simulated. + return CurrentWeather(city=city, temperature=20.0, condition="unknown", humidity=50) + + +@traced(name="get_forecast", span_type="tool") +@mockable() +async def get_forecast(city: str, days: int = 3) -> list[ForecastDay]: + """Retrieve a multi-day weather forecast for a city. + + Args: + city: Name of the city. + days: Number of forecast days to retrieve (default: 3). + + Returns: + List of ForecastDay objects, one per requested day. + """ + # Real implementation would call a forecast API. + # Returns an empty list when not simulated. + return [] + + +# --------------------------------------------------------------------------- +# Agent entry point +# --------------------------------------------------------------------------- + + +@traced(name="main") +async def main(input: WeatherInput) -> WeatherReport: + """Fetch current weather and forecast for a city and produce a report. + + Args: + input: WeatherInput with city name and number of forecast days. + + Returns: + WeatherReport combining current conditions, forecast, and a summary. + """ + current = await get_current_weather(input.city) + forecast = await get_forecast(input.city, input.days) + + issues = [] + if current.humidity > 80: + issues.append("high humidity") + if current.temperature < 0: + issues.append("freezing temperatures") + + alert = f" Alerts: {', '.join(issues)}." if issues else "" + summary = ( + f"{input.city}: {current.temperature}°C, {current.condition}." + f" {len(forecast)}-day forecast available.{alert}" + ) + return WeatherReport(current=current, forecast=forecast, summary=summary) diff --git a/packages/uipath/samples/simulate-component-agent/pyproject.toml b/packages/uipath/samples/simulate-component-agent/pyproject.toml new file mode 100644 index 000000000..c4228e861 --- /dev/null +++ b/packages/uipath/samples/simulate-component-agent/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "simulate-component-agent" +version = "0.0.1" +description = "Weather forecast agent demonstrating per-component simulation" +authors = [{ name = "UiPath", email = "python-sdk@uipath.com" }] +dependencies = [ + "uipath", +] +requires-python = ">=3.11" + +[dependency-groups] +dev = [ + "uipath-dev", +] + +[tool.uv.sources] +uipath = { path = "../..", editable = true } diff --git a/packages/uipath/samples/simulate-component-agent/simulation.json b/packages/uipath/samples/simulate-component-agent/simulation.json new file mode 100644 index 000000000..fc87bd527 --- /dev/null +++ b/packages/uipath/samples/simulate-component-agent/simulation.json @@ -0,0 +1,73 @@ +{ + "enabled": true, + "components": [ + { + "componentId": "get_current_weather", + "componentType": "tool", + "simulationStrategy": 0, + "simulationInstruction": "Return realistic current weather for the given city. Use typical seasonal temperatures for the Northern Hemisphere in winter. the humidity should always be 70%", + "outputSchema": { + "type": "object", + "properties": { + "city": { + "type": "string" + }, + "temperature": { + "type": "number", + "description": "Temperature in Celsius" + }, + "condition": { + "type": "string", + "description": "e.g. cloudy, rainy, sunny" + }, + "humidity": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + }, + "required": [ + "city", + "temperature", + "condition", + "humidity" + ] + } + }, + { + "componentId": "get_forecast", + "componentType": "tool", + "simulationStrategy": 0, + "simulationInstruction": "Return a realistic multi-day weather forecast for the given city.", + "outputSchema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "string", + "description": "YYYY-MM-DD" + }, + "high": { + "type": "number", + "description": "High temperature in Celsius" + }, + "low": { + "type": "number", + "description": "Low temperature in Celsius" + }, + "condition": { + "type": "string" + } + }, + "required": [ + "date", + "high", + "low", + "condition" + ] + } + } + } + ] +} \ No newline at end of file diff --git a/packages/uipath/samples/simulate-component-agent/uipath.json b/packages/uipath/samples/simulate-component-agent/uipath.json new file mode 100644 index 000000000..a991b6914 --- /dev/null +++ b/packages/uipath/samples/simulate-component-agent/uipath.json @@ -0,0 +1,6 @@ +{ + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "functions": { + "main": "main.py:main" + } +} diff --git a/packages/uipath/src/uipath/eval/mocks/__init__.py b/packages/uipath/src/uipath/eval/mocks/__init__.py index f9e7da177..ddb76ca70 100644 --- a/packages/uipath/src/uipath/eval/mocks/__init__.py +++ b/packages/uipath/src/uipath/eval/mocks/__init__.py @@ -6,13 +6,31 @@ build_mocking_context, build_mocking_context_from_dict, ) -from ._types import ExampleCall, MockingContext, SimulationConfig +from ._types import ( + ComponentSimulationConfig, + ExampleCall, + MockingContext, + RuleOperator, + SimulationAnswer, + SimulationAnswerType, + SimulationBehavior, + SimulationCondition, + SimulationConfig, + SimulationStrategy, +) from .mockable import mockable __all__ = [ + "ComponentSimulationConfig", "ExampleCall", "MockingContext", + "RuleOperator", + "SimulationAnswer", + "SimulationAnswerType", + "SimulationBehavior", + "SimulationCondition", "SimulationConfig", + "SimulationStrategy", "UiPathMockRuntime", "build_mocking_context", "build_mocking_context_from_dict", diff --git a/packages/uipath/src/uipath/eval/mocks/_mock_context.py b/packages/uipath/src/uipath/eval/mocks/_mock_context.py index a50df9cba..bd2f80df6 100644 --- a/packages/uipath/src/uipath/eval/mocks/_mock_context.py +++ b/packages/uipath/src/uipath/eval/mocks/_mock_context.py @@ -43,17 +43,25 @@ def is_tool_simulated(tool_name: str) -> bool: to be simulated, False otherwise. """ ctx = mocking_context.get() - strategy = ctx.strategy if ctx else None - if strategy is None: + if ctx is None: return False normalized_tool_name = _normalize_tool_name(tool_name) + if ctx.components: + return any( + _normalize_tool_name(c.component_id) == normalized_tool_name + for c in ctx.components + ) + + strategy = ctx.strategy + if strategy is None: + return False + if isinstance(strategy, LLMMockingStrategy): - simulated_names = [ + return normalized_tool_name in [ _normalize_tool_name(t.name) for t in strategy.tools_to_simulate ] - return normalized_tool_name in simulated_names elif isinstance(strategy, MockitoMockingStrategy): return any( _normalize_tool_name(b.function) == normalized_tool_name diff --git a/packages/uipath/src/uipath/eval/mocks/_mock_runtime.py b/packages/uipath/src/uipath/eval/mocks/_mock_runtime.py index 71036215b..60cc78c2b 100644 --- a/packages/uipath/src/uipath/eval/mocks/_mock_runtime.py +++ b/packages/uipath/src/uipath/eval/mocks/_mock_runtime.py @@ -37,16 +37,32 @@ def build_mocking_context( config: SimulationConfig, agent_model: str | None = None ) -> MockingContext | None: - """Build a MockingContext from a validated SimulationConfig. + """Build a MockingContext from a validated SimulationConfig.""" + if not config.enabled: + return None - Args: - config: Validated simulation config. - agent_model: Optional agent model name to use as fallback. + # New per-component format → routes to simulate-component API + if config.components: + from uipath.platform.common._config import UiPathConfig - Returns: - MockingContext if enabled and tools are specified, None otherwise. - """ - if not config.enabled or not config.tools_to_simulate: + workload_id = ( + getattr(UiPathConfig, "agent_id", None) + or getattr(UiPathConfig, "project_id", None) + or str(uuid.uuid4()) + ) + logger.debug( + f"Loaded simulation config for {len(config.components)} component(s)" + ) + return MockingContext( + strategy=None, + name="debug-simulation", + inputs={}, + components=config.components, + workload_id=workload_id, + ) + + # Legacy format (toolsToSimulate + instructions) → routes to local LLM mocker + if not config.tools_to_simulate: return None model = ( @@ -125,12 +141,16 @@ def set_execution_context( mocking_context.set(context) try: - if context and context.strategy: - mocker_context.set(MockerFactory.create(context)) + if context and (context.strategy or context.components): + mocker = MockerFactory.create(context) + mocker_context.set(mocker) + logger.info( + "simulate-component: mocker created (%s)", type(mocker).__name__ + ) else: mocker_context.set(None) except Exception: - logger.warning("Failed to create mocker.") + logger.warning("Failed to create mocker.", exc_info=True) mocker_context.set(None) span_collector_context.set(span_collector) diff --git a/packages/uipath/src/uipath/eval/mocks/_mocker_factory.py b/packages/uipath/src/uipath/eval/mocks/_mocker_factory.py index 4c001bfe0..2f61162a4 100644 --- a/packages/uipath/src/uipath/eval/mocks/_mocker_factory.py +++ b/packages/uipath/src/uipath/eval/mocks/_mocker_factory.py @@ -3,6 +3,7 @@ from ._llm_mocker import LLMMocker from ._mocker import Mocker from ._mockito_mocker import MockitoMocker +from ._simulate_component_mocker import SimulateComponentMocker from ._types import ( LLMMockingStrategy, MockingContext, @@ -16,6 +17,8 @@ class MockerFactory: @staticmethod def create(context: MockingContext) -> Mocker: """Create a mocker instance.""" + if context.components: + return SimulateComponentMocker(context) match context.strategy: case LLMMockingStrategy(): return LLMMocker(context) diff --git a/packages/uipath/src/uipath/eval/mocks/_simulate_component_mocker.py b/packages/uipath/src/uipath/eval/mocks/_simulate_component_mocker.py new file mode 100644 index 000000000..f35359ec4 --- /dev/null +++ b/packages/uipath/src/uipath/eval/mocks/_simulate_component_mocker.py @@ -0,0 +1,143 @@ +"""Mocker that routes tool calls through the simulate-component API.""" + +from __future__ import annotations + +import logging +from typing import Any, Callable, cast + +from pydantic import TypeAdapter + +from uipath.platform.chat._llm_gateway_service import _cleanup_schema + +from .._execution_context import execution_id_context, span_collector_context +from ._llm_mocker import LLMMocker +from ._mocker import ( + Mocker, + R, + T, + UiPathMockResponseGenerationError, + UiPathNoMockFoundError, +) +from ._simulate_component_service import _create_simulate_component_service +from ._types import ComponentSimulationConfig, MockingContext + +logger = logging.getLogger(__name__) + + +class SimulateComponentMocker(Mocker): + """Routes each tool call to the simulate-component API based on per-component config.""" + + def __init__(self, context: MockingContext) -> None: + self._context = context + self._components: dict[str, ComponentSimulationConfig] = { + c.component_id: c for c in (context.components or []) + } + self._normalized: dict[str, ComponentSimulationConfig] = { + c.component_id.replace("_", " "): c for c in (context.components or []) + } + self._workload_id = context.workload_id or "" + + def _find_component(self, tool_name: str) -> ComponentSimulationConfig | None: + return self._components.get(tool_name) or self._normalized.get( + tool_name.replace("_", " ") + ) + + async def response( + self, + func: Callable[[T], R], + params: dict[str, Any], + invocation: tuple[tuple[Any, ...], dict[str, Any]], + ) -> R: + tool_name = params.get("name") or func.__name__ + component = self._find_component(tool_name) + + if component is None: + raise UiPathNoMockFoundError(f"No simulation config for '{tool_name}'.") + + args, kwargs = invocation + + return_type: Any = func.__annotations__.get("return", None) or Any + raw_output_schema = ( + params.get("output_schema") or TypeAdapter(return_type).json_schema() + ) + output_schema = component.output_schema or _cleanup_schema(raw_output_schema) + input_payload = {"args": list(args), "kwargs": kwargs} + input_schema = component.input_schema or params.get("input_schema") + + execution_history = self._build_execution_history() + trace_id, parent_span_id = self._get_span_context() + workload_info = { + "name": self._context.name, + "userInput": self._context.inputs, + } + + example_calls = [ + {"id": ex.id, "input": ex.input, "output": ex.output} + for ex in (params.get("example_calls") or []) + ] + + payload: dict[str, Any] = { + "workloadId": self._workload_id, + "componentId": component.component_id, + "componentType": component.component_type or "tool", + "componentDescription": component.component_description + or params.get("description"), + "input": input_payload, + "inputSchema": input_schema, + "outputSchema": output_schema, + "simulationInstruction": component.simulation_instruction, + "simulationStrategy": int(component.simulation_strategy), + "mockValue": component.mock_value, + "behaviors": ( + [b.model_dump() for b in component.behaviors] + if component.behaviors + else None + ), + "exampleCalls": example_calls or None, + "executionHistory": execution_history or None, + "workloadInfo": workload_info, + "traceId": trace_id, + "parentSpanId": parent_span_id, + } + + logger.info("simulate-component: calling API for '%s'", tool_name) + try: + service = _create_simulate_component_service() + result = await service.simulate(payload) + except Exception as e: + logger.error( + "simulate-component: API call failed for '%s': %s", tool_name, e + ) + raise UiPathMockResponseGenerationError( + f"simulate-component API call failed for '{tool_name}'" + ) from e + + status = result.get("status") + if status == 1: # Completed + logger.info("simulate-component: '%s' simulated successfully", tool_name) + return cast(R, result.get("simulatedOutput")) + + error = result.get("error") or {} + error_message = error.get("message", f"Simulation failed for '{tool_name}'") + logger.error("simulate-component: '%s' failed — %s", tool_name, error_message) + raise UiPathMockResponseGenerationError(error_message) + + def _build_execution_history(self) -> str | None: + span_collector = span_collector_context.get() + execution_id = execution_id_context.get() + if span_collector and execution_id: + spans = span_collector.get_spans(execution_id) + return LLMMocker.spans_to_llm_context(spans) if spans else None + return None + + @staticmethod + def _get_span_context() -> tuple[str | None, str | None]: + """Return (traceId, parentSpanId) from the current OTel span, or (None, None).""" + from opentelemetry import trace + + span_ctx = trace.get_current_span().get_span_context() + if not span_ctx.is_valid: + return None, None + trace_id = f"{span_ctx.trace_id:032x}" + span_id = f"{span_ctx.span_id:016x}" + return trace_id, span_id diff --git a/packages/uipath/src/uipath/eval/mocks/_simulate_component_service.py b/packages/uipath/src/uipath/eval/mocks/_simulate_component_service.py new file mode 100644 index 000000000..87a08bd37 --- /dev/null +++ b/packages/uipath/src/uipath/eval/mocks/_simulate_component_service.py @@ -0,0 +1,37 @@ +"""Service for calling the simulate-component API.""" + +from typing import Any + +from uipath._utils import Endpoint +from uipath.platform.common import BaseService + + +class SimulateComponentService(BaseService): + async def simulate(self, payload: dict[str, Any]) -> dict[str, Any]: + from uipath.platform.common import UiPathConfig + + headers: dict[str, str] = {} + if UiPathConfig.tenant_id: + headers["X-UiPath-Internal-TenantId"] = UiPathConfig.tenant_id + if UiPathConfig.organization_id: + headers["X-UiPath-Internal-AccountId"] = UiPathConfig.organization_id + + response = await self.request_async( + "POST", + url=Endpoint( + "/agentsruntime_/api/execution/simulations/simulate-component" + ), + json=payload, + headers=headers, + ) + return response.json() + + +def _create_simulate_component_service() -> SimulateComponentService: + from uipath.platform import UiPath + + uipath = UiPath() + return SimulateComponentService( + config=uipath._config, + execution_context=uipath._execution_context, + ) diff --git a/packages/uipath/src/uipath/eval/mocks/_types.py b/packages/uipath/src/uipath/eval/mocks/_types.py index 070040b65..f1a15312c 100644 --- a/packages/uipath/src/uipath/eval/mocks/_types.py +++ b/packages/uipath/src/uipath/eval/mocks/_types.py @@ -121,18 +121,133 @@ class UnknownMockingStrategy(BaseMockingStrategy): MockingStrategy = Union[KnownMockingStrategy, UnknownMockingStrategy] +# --------------------------------------------------------------------------- +# Per-component simulation types — mirror the simulate-component API contract +# --------------------------------------------------------------------------- + + +class SimulationStrategy(int, Enum): + """Simulation strategy matching the simulate-component API. + + Integer values are part of the cross-language API contract — do not reorder. + """ + + LLM = 0 + MOCKITO = 1 + STATIC = 2 + + +class RuleOperator(int, Enum): + """Comparison operator for Mockito condition matching. + + Integer values are part of the cross-language API contract — do not reorder. + """ + + EQ = 0 + NE = 1 + GT = 2 + GTE = 3 + LT = 4 + LTE = 5 + CONTAINS = 6 + + +class SimulationAnswerType(int, Enum): + """Answer type for a Mockito simulation behavior. + + Integer values are part of the cross-language API contract — do not reorder. + """ + + RETURN = 0 + RAISE = 1 + + +class SimulationAnswer(BaseModel): + type: SimulationAnswerType = SimulationAnswerType.RETURN + value: Any = None + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class SimulationCondition(BaseModel): + field: str + op: RuleOperator = RuleOperator.EQ + value: Any = None + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class SimulationBehavior(BaseModel): + when: list[SimulationCondition] | None = None + then: list[SimulationAnswer] + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class ComponentSimulationConfig(BaseModel): + """Per-component simulation config matching the simulate-component API request schema. + + Runtime-injected fields (workloadId, runId, input, traceId, parentSpanId, + folderKey) are supplied at call time. inputSchema and outputSchema can be + overridden here; if omitted they are derived from the function annotations. + """ + + component_id: str = Field(..., alias="componentId") + component_type: str | None = Field(None, alias="componentType") + component_description: str | None = Field(None, alias="componentDescription") + simulation_instruction: str | None = Field(None, alias="simulationInstruction") + simulation_strategy: SimulationStrategy = Field( + SimulationStrategy.LLM, alias="simulationStrategy" + ) + mock_value: Any = Field(None, alias="mockValue") + behaviors: list[SimulationBehavior] | None = None + input_schema: dict[str, Any] | None = Field(None, alias="inputSchema") + output_schema: dict[str, Any] | None = Field(None, alias="outputSchema") + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + class MockingContext(BaseModel): """Execution context for mocking, holding strategy and inputs.""" strategy: MockingStrategy | None inputs: dict[str, Any] = Field(default_factory=lambda: {}) name: str = Field(default="debug") + # When set, SimulateComponentMocker routes each tool call to the simulate-component API. + components: list[ComponentSimulationConfig] | None = None + workload_id: str | None = None class SimulationConfig(BaseModel): - """Top-level schema for simulation.json / --simulation flag.""" + """Top-level schema for simulation.json / --simulation flag. + + New format (routes to simulate-component API): + { + "enabled": true, + "components": [ + { + "componentId": "my_tool", + "componentType": "tool", + "simulationStrategy": 0, + "simulationInstruction": "Simulate this tool by..." + } + ] + } + + Legacy format (routes to local LLM mocker): + { + "enabled": true, + "toolsToSimulate": [{"name": "my_tool"}], + "instructions": "Simulate these tools by..." + } + """ enabled: bool = True + # New per-component format — when non-empty, routes to simulate-component API. + components: list[ComponentSimulationConfig] = Field(default_factory=list) + # Legacy flat format — used when components is empty; routes to local LLM mocker. tools_to_simulate: list[ToolSimulation] = Field( default_factory=list, alias="toolsToSimulate" ) diff --git a/packages/uipath/tests/cli/eval/mocks/test_simulate_component.py b/packages/uipath/tests/cli/eval/mocks/test_simulate_component.py new file mode 100644 index 000000000..f4131cf48 --- /dev/null +++ b/packages/uipath/tests/cli/eval/mocks/test_simulate_component.py @@ -0,0 +1,442 @@ +"""Tests for SimulateComponentMocker and SimulateComponentService.""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from _pytest.monkeypatch import MonkeyPatch +from pytest_httpx import HTTPXMock + +from uipath.eval.mocks._mock_context import is_tool_simulated +from uipath.eval.mocks._mock_runtime import ( + clear_execution_context, + set_execution_context, +) +from uipath.eval.mocks._mocker import ( + UiPathMockResponseGenerationError, +) +from uipath.eval.mocks._simulate_component_mocker import SimulateComponentMocker +from uipath.eval.mocks._simulate_component_service import ( + SimulateComponentService, + _create_simulate_component_service, +) +from uipath.eval.mocks._types import ( + ComponentSimulationConfig, + MockingContext, + SimulationStrategy, + UnknownMockingStrategy, +) +from uipath.eval.mocks.mockable import mockable + +_mock_span_collector = MagicMock() + +BASE_URL = "https://example.com" +_SIMULATE_PATH = ( + "uipath.eval.mocks._simulate_component_mocker._create_simulate_component_service" +) + + +def _make_context( + component_id: str = "my_tool", + strategy: SimulationStrategy = SimulationStrategy.LLM, + instruction: str = "simulate it", + workload_id: str = "wl-123", +) -> MockingContext: + return MockingContext( + strategy=None, + name="test-run", + inputs={"q": "hello"}, + workload_id=workload_id, + components=[ + ComponentSimulationConfig( + component_id=component_id, + component_type="tool", + simulation_strategy=strategy, + simulation_instruction=instruction, + ) + ], + ) + + +def _make_service_mock(result: dict[str, Any]) -> MagicMock: + svc = MagicMock() + svc.simulate = AsyncMock(return_value=result) + return svc + + +# --------------------------------------------------------------------------- +# is_tool_simulated with components format +# --------------------------------------------------------------------------- + + +class TestIsToolSimulatedWithComponents: + def setup_method(self): + clear_execution_context() + + def teardown_method(self): + clear_execution_context() + + def test_returns_true_for_listed_component(self): + set_execution_context(_make_context("search_tool"), _mock_span_collector, "x") + assert is_tool_simulated("search_tool") is True + + def test_returns_false_for_unlisted_component(self): + set_execution_context(_make_context("search_tool"), _mock_span_collector, "x") + assert is_tool_simulated("other_tool") is False + + def test_underscore_space_normalisation(self): + ctx = MockingContext( + strategy=None, + name="run", + inputs={}, + components=[ + ComponentSimulationConfig( + component_id="web search", + simulation_strategy=SimulationStrategy.LLM, + ) + ], + ) + set_execution_context(ctx, _mock_span_collector, "x") + assert is_tool_simulated("web_search") is True + + def test_returns_false_when_components_list_is_empty(self): + ctx = MockingContext(strategy=None, name="run", inputs={}, components=[]) + set_execution_context(ctx, _mock_span_collector, "x") + # components is set but empty — MockerFactory won't create a mocker (components is not None) + # is_tool_simulated: ctx.components is not None → iterates empty list → False + assert is_tool_simulated("any_tool") is False + + +# --------------------------------------------------------------------------- +# SimulateComponentMocker._find_component +# --------------------------------------------------------------------------- + + +class TestFindComponent: + def test_finds_by_exact_id(self): + mocker = SimulateComponentMocker(_make_context("my_tool")) + assert mocker._find_component("my_tool") is not None + + def test_finds_by_underscore_to_space_normalisation(self): + ctx = MockingContext( + strategy=None, + name="run", + inputs={}, + components=[ + ComponentSimulationConfig( + component_id="web search", + simulation_strategy=SimulationStrategy.LLM, + ) + ], + ) + mocker = SimulateComponentMocker(ctx) + assert mocker._find_component("web_search") is not None + + def test_returns_none_for_unknown_tool(self): + mocker = SimulateComponentMocker(_make_context("my_tool")) + assert mocker._find_component("unknown") is None + + +# --------------------------------------------------------------------------- +# SimulateComponentMocker.response — success path +# --------------------------------------------------------------------------- + + +class TestSimulateComponentMockerResponse: + @pytest.mark.asyncio + async def test_returns_simulated_output_on_status_1(self): + ctx = _make_context("my_tool") + svc_mock = _make_service_mock({"status": 1, "simulatedOutput": "hello"}) + + @mockable() + async def my_tool() -> str: + raise NotImplementedError() + + set_execution_context(ctx, _mock_span_collector, "exec-1") + with patch(_SIMULATE_PATH, return_value=svc_mock): + result = await my_tool() + + assert result == "hello" + clear_execution_context() + + @pytest.mark.asyncio + async def test_raises_generation_error_on_non_1_status(self): + ctx = _make_context("my_tool") + svc_mock = _make_service_mock( + {"status": 2, "error": {"message": "LLM timeout"}} + ) + + @mockable() + async def my_tool() -> str: + raise NotImplementedError() + + set_execution_context(ctx, _mock_span_collector, "exec-2") + with patch(_SIMULATE_PATH, return_value=svc_mock): + with pytest.raises(UiPathMockResponseGenerationError, match="LLM timeout"): + await my_tool() + + clear_execution_context() + + @pytest.mark.asyncio + async def test_raises_generic_error_when_error_message_missing(self): + ctx = _make_context("my_tool") + svc_mock = _make_service_mock({"status": 0, "error": {}}) + + @mockable() + async def my_tool() -> str: + raise NotImplementedError() + + set_execution_context(ctx, _mock_span_collector, "exec-3") + with patch(_SIMULATE_PATH, return_value=svc_mock): + with pytest.raises( + UiPathMockResponseGenerationError, match="Simulation failed" + ): + await my_tool() + + clear_execution_context() + + @pytest.mark.asyncio + async def test_raises_generation_error_when_api_throws(self): + ctx = _make_context("my_tool") + svc_mock = MagicMock() + svc_mock.simulate = AsyncMock(side_effect=RuntimeError("network error")) + + @mockable() + async def my_tool() -> str: + raise NotImplementedError() + + set_execution_context(ctx, _mock_span_collector, "exec-4") + with patch(_SIMULATE_PATH, return_value=svc_mock): + with pytest.raises( + UiPathMockResponseGenerationError, + match="simulate-component API call failed", + ): + await my_tool() + + clear_execution_context() + + @pytest.mark.asyncio + async def test_raises_no_mock_found_for_unconfigured_tool(self): + ctx = _make_context("my_tool") + + @mockable() + async def other_tool() -> str: + raise NotImplementedError() + + set_execution_context(ctx, _mock_span_collector, "exec-5") + # other_tool is not in components → falls through to real function + with pytest.raises(NotImplementedError): + await other_tool() + + clear_execution_context() + + +# --------------------------------------------------------------------------- +# Payload construction +# --------------------------------------------------------------------------- + + +class TestPayloadConstruction: + @pytest.mark.asyncio + async def test_payload_fields_sent_to_service(self): + ctx = _make_context("my_tool", instruction="Do something", workload_id="wl-99") + captured: list[dict[str, Any]] = [] + + async def _capture(payload, **kwargs): + captured.append(payload) + return {"status": 1, "simulatedOutput": "ok"} + + svc_mock = MagicMock() + svc_mock.simulate = _capture + + @mockable() + async def my_tool(x: int) -> str: + raise NotImplementedError() + + set_execution_context(ctx, _mock_span_collector, "exec-6") + with patch(_SIMULATE_PATH, return_value=svc_mock): + await my_tool(x=42) + + assert len(captured) == 1 + p = captured[0] + assert p["workloadId"] == "wl-99" + assert p["componentId"] == "my_tool" + assert p["componentType"] == "tool" + assert p["simulationInstruction"] == "Do something" + assert p["simulationStrategy"] == int(SimulationStrategy.LLM) + assert p["workloadInfo"] == {"name": "test-run", "userInput": {"q": "hello"}} + + clear_execution_context() + + @pytest.mark.asyncio + async def test_sync_mockable_also_works(self): + ctx = _make_context("sync_tool") + svc_mock = _make_service_mock({"status": 1, "simulatedOutput": 42}) + + @mockable() + def sync_tool() -> int: + raise NotImplementedError() + + set_execution_context(ctx, _mock_span_collector, "exec-7") + with patch(_SIMULATE_PATH, return_value=svc_mock): + result = sync_tool() + + assert result == 42 + clear_execution_context() + + @pytest.mark.asyncio + async def test_payload_uses_configured_component_id_not_invoked_name(self): + """componentId in payload must be the configured ID, not the normalised call name.""" + ctx = MockingContext( + strategy=None, + name="run", + inputs={}, + components=[ + ComponentSimulationConfig( + component_id="web search", + simulation_strategy=SimulationStrategy.LLM, + ) + ], + ) + captured: list[dict[str, Any]] = [] + + async def _capture(payload, **kwargs): + captured.append(payload) + return {"status": 1, "simulatedOutput": "ok"} + + svc_mock = MagicMock() + svc_mock.simulate = _capture + + @mockable() + async def web_search() -> str: + raise NotImplementedError() + + set_execution_context(ctx, _mock_span_collector, "exec-8") + with patch(_SIMULATE_PATH, return_value=svc_mock): + await web_search() + + assert captured[0]["componentId"] == "web search" + clear_execution_context() + + +# --------------------------------------------------------------------------- +# _build_execution_history — uncovered branch (no context vars set) +# --------------------------------------------------------------------------- + + +class TestBuildExecutionHistory: + def test_returns_none_when_context_vars_not_set(self): + clear_execution_context() + mocker = SimulateComponentMocker(_make_context()) + assert mocker._build_execution_history() is None + + def test_returns_none_when_spans_empty(self): + from uipath.eval._execution_context import ( + execution_id_context, + span_collector_context, + ) + + span_collector = MagicMock() + span_collector.get_spans = MagicMock(return_value=[]) + span_collector_context.set(span_collector) + execution_id_context.set("exec-id") + + mocker = SimulateComponentMocker(_make_context()) + assert mocker._build_execution_history() is None + + clear_execution_context() + + +# --------------------------------------------------------------------------- +# MockerFactory — unknown strategy raises ValueError +# --------------------------------------------------------------------------- + + +class TestMockerFactory: + def test_raises_for_unknown_strategy(self): + from uipath.eval.mocks._mocker_factory import MockerFactory + + ctx = MockingContext( + strategy=UnknownMockingStrategy(type="future_strategy"), + name="test", + inputs={}, + components=None, + ) + with pytest.raises(ValueError, match="Unknown mocking strategy"): + MockerFactory.create(ctx) + + def test_raises_for_none_strategy_and_no_components(self): + from uipath.eval.mocks._mocker_factory import MockerFactory + + ctx = MockingContext(strategy=None, name="test", inputs={}, components=None) + with pytest.raises(ValueError, match="Unknown mocking strategy"): + MockerFactory.create(ctx) + + +# --------------------------------------------------------------------------- +# is_tool_simulated — unknown strategy falls through to False +# --------------------------------------------------------------------------- + + +class TestIsToolSimulatedUnknownStrategy: + def setup_method(self): + clear_execution_context() + + def teardown_method(self): + clear_execution_context() + + def test_returns_false_for_unknown_strategy(self): + + ctx = MockingContext( + strategy=UnknownMockingStrategy(type="future_strategy"), + name="test", + inputs={}, + components=None, + ) + set_execution_context(ctx, _mock_span_collector, "x") + assert is_tool_simulated("any_tool") is False + + +# --------------------------------------------------------------------------- +# SimulateComponentService — actual HTTP call +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@pytest.mark.httpx_mock(assert_all_responses_were_requested=False) +async def test_simulate_component_service_http_call( + httpx_mock: HTTPXMock, monkeypatch: MonkeyPatch +): + monkeypatch.setenv("UIPATH_URL", "https://example.com/myorg/mytenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "token") + + httpx_mock.add_response( + url="https://example.com/myorg/mytenant/agentsruntime_/api/execution/simulations/simulate-component", + method="POST", + json={"status": 1, "simulatedOutput": "result"}, + ) + + service = _create_simulate_component_service() + assert isinstance(service, SimulateComponentService) + + result = await service.simulate({"componentId": "my_tool"}) + assert result == {"status": 1, "simulatedOutput": "result"} + + +@pytest.mark.asyncio +@pytest.mark.httpx_mock(assert_all_responses_were_requested=False) +async def test_simulate_component_service_no_headers( + httpx_mock: HTTPXMock, monkeypatch: MonkeyPatch +): + monkeypatch.setenv("UIPATH_URL", "https://example.com/myorg/mytenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "token") + + httpx_mock.add_response( + url="https://example.com/myorg/mytenant/agentsruntime_/api/execution/simulations/simulate-component", + method="POST", + json={"status": 0, "error": {"message": "boom"}}, + ) + + service = _create_simulate_component_service() + result = await service.simulate({"componentId": "my_tool"}) + assert result["status"] == 0 diff --git a/packages/uipath/tests/cli/test_debug_simulation.py b/packages/uipath/tests/cli/test_debug_simulation.py index d9266327b..9e66a1a24 100644 --- a/packages/uipath/tests/cli/test_debug_simulation.py +++ b/packages/uipath/tests/cli/test_debug_simulation.py @@ -82,11 +82,12 @@ def test_loads_valid_simulation_config( assert result is not None assert isinstance(result, MockingContext) assert result.name == "debug-simulation" - assert result.strategy is not None + # Legacy format routes to local LLM mocker via strategy + assert result.components is None or len(result.components) == 0 assert isinstance(result.strategy, LLMMockingStrategy) - assert result.strategy.prompt == valid_simulation_config["instructions"] assert len(result.strategy.tools_to_simulate) == 3 assert result.strategy.tools_to_simulate[0].name == "Web Reader" + assert result.strategy.prompt == valid_simulation_config["instructions"] def test_returns_none_when_disabled( self, temp_dir: str, disabled_simulation_config: dict[str, Any] @@ -422,6 +423,34 @@ def test_enabled_defaults_to_true_when_missing(self, temp_dir: str): # Should load successfully since enabled defaults to true assert result is not None + def test_new_format_loads_components(self, temp_dir: str): + """Test that new per-component format routes to API-based mocker (components set).""" + config = { + "enabled": True, + "components": [ + { + "componentId": "my_tool", + "componentType": "tool", + "simulationStrategy": 0, + "simulationInstruction": "Simulate this tool", + } + ], + } + simulation_path = Path(temp_dir) / "simulation.json" + with open(simulation_path, "w", encoding="utf-8") as f: + json.dump(config, f) + + with patch(f"{MOCK_RUNTIME_PATCH_PATH}.Path.cwd", return_value=Path(temp_dir)): + result = load_simulation_config() + + assert result is not None + assert isinstance(result, MockingContext) + # New format: components set, strategy is None + assert result.components is not None + assert len(result.components) == 1 + assert result.components[0].component_id == "my_tool" + assert result.strategy is None + def test_handles_tool_name_normalization(self, temp_dir: str): """Test that tool names with underscores work correctly.""" config = { diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index a805e5cdc..e8b8f6bad 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.7" +version = "2.11.8" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 6ac022a9b02438f8cea81054ca19bee30612b313 Mon Sep 17 00:00:00 2001 From: Radu Mihai Gheorghe Date: Tue, 23 Jun 2026 12:32:52 +0300 Subject: [PATCH 115/191] feat: add refreshSchemaBeforeCall to cached MCP tools config (#1730) --- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/agent/models/agent.py | 11 ++++- .../uipath/tests/agent/models/test_agent.py | 48 +++++++++++++++++++ packages/uipath/uv.lock | 2 +- 4 files changed, 60 insertions(+), 3 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 4038e1313..827befcfb 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.11.8" +version = "2.11.9" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index a14066969..916417c94 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -483,9 +483,18 @@ class DynamicToolsMode(str, CaseInsensitiveEnum): class CachedToolsConfig(BaseCfg): - """Cached tools configuration: use the tools saved in the agent definition snapshot.""" + """Cached tools configuration: use the tools saved in the agent definition snapshot. + + When ``refresh_schema_before_call`` is true, the live tool schema is fetched + from the MCP server immediately before a tool is invoked. The agent still uses + the cached schema to decide which tool to call; the fresh schema is applied only + at invocation time. + """ type: Literal["cached"] = Field(default="cached", frozen=True) + refresh_schema_before_call: bool = Field( + default=True, alias="refreshSchemaBeforeCall" + ) class DynamicToolsConfig(BaseCfg): diff --git a/packages/uipath/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py index ab0e6a557..202962c64 100644 --- a/packages/uipath/tests/agent/models/test_agent.py +++ b/packages/uipath/tests/agent/models/test_agent.py @@ -50,6 +50,7 @@ AssetRecipient, BatchTransformFileExtension, BatchTransformWebSearchGrounding, + CachedToolsConfig, CitationMode, CustomAssigneesRecipient, DeepRagFileExtension, @@ -2038,6 +2039,53 @@ def test_mcp_resource_with_output_schema(self): assert tool2.output_schema is not None assert "content" in tool2.output_schema["properties"] + def test_cached_tools_config_refresh_schema_default(self): + """CachedToolsConfig defaults refresh_schema_before_call to True.""" + + config = CachedToolsConfig() + assert config.type == "cached" + assert config.refresh_schema_before_call is True + + def test_cached_tools_config_refresh_schema_alias_roundtrip(self): + """refresh_schema_before_call parses from and serializes to refreshSchemaBeforeCall.""" + + config = TypeAdapter(CachedToolsConfig).validate_python( + {"type": "cached", "refreshSchemaBeforeCall": False} + ) + assert config.refresh_schema_before_call is False + assert config.model_dump(by_alias=True)["refreshSchemaBeforeCall"] is False + + def test_mcp_resource_with_cached_tools_configuration(self): + """AgentMcpResourceConfig parses a cached toolsConfiguration with the refresh flag.""" + + json_data = { + "$resourceType": "mcp", + "folderPath": "solution_folder", + "slug": "tavily-mcp", + "name": "tavily", + "description": "Tavily search tools", + "isEnabled": True, + "availableTools": [ + { + "name": "tavily-search", + "description": "Search the web", + "inputSchema": {"type": "object", "properties": {}}, + } + ], + "toolsConfiguration": { + "discoveryMode": { + "type": "cached", + "refreshSchemaBeforeCall": False, + } + }, + } + + mcp_resource = TypeAdapter(AgentMcpResourceConfig).validate_python(json_data) + assert mcp_resource.tools_configuration is not None + discovery_mode = mcp_resource.tools_configuration.discovery_mode + assert isinstance(discovery_mode, CachedToolsConfig) + assert discovery_mode.refresh_schema_before_call is False + @pytest.mark.parametrize( "recipient_type_int,value,expected_type", [ diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index e8b8f6bad..15873d997 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.8" +version = "2.11.9" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 968e4ae4afd7cf9b5085e7983415818c06bf1093 Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan <84002867+viswa-uipath@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:45:55 +0530 Subject: [PATCH 116/191] feat(platform): add GovernanceService for agenticgovernance_ ingress (#1738) Co-authored-by: Claude Opus 4.7 (1M context) --- packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/governance/__init__.py | 15 + .../src/uipath/core/governance/providers.py | 153 +++++ .../tests/governance/test_providers.py | 149 +++++ packages/uipath-core/uv.lock | 2 +- packages/uipath-platform/pyproject.toml | 4 +- .../src/uipath/platform/_uipath.py | 5 + .../src/uipath/platform/common/__init__.py | 3 +- .../uipath/platform/common/_base_service.py | 54 ++ .../uipath/platform/governance/__init__.py | 20 + .../governance/_governance_provider.py | 81 +++ .../governance/_governance_service.py | 356 +++++++++++ .../uipath/platform/governance/compensate.py | 10 + .../src/uipath/platform/governance/policy.py | 10 + .../services/test_governance_provider.py | 166 +++++ .../tests/services/test_governance_service.py | 594 ++++++++++++++++++ packages/uipath-platform/uv.lock | 4 +- packages/uipath/pyproject.toml | 4 +- packages/uipath/uv.lock | 6 +- 19 files changed, 1626 insertions(+), 12 deletions(-) create mode 100644 packages/uipath-core/src/uipath/core/governance/providers.py create mode 100644 packages/uipath-core/tests/governance/test_providers.py create mode 100644 packages/uipath-platform/src/uipath/platform/governance/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/governance/_governance_provider.py create mode 100644 packages/uipath-platform/src/uipath/platform/governance/_governance_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/governance/compensate.py create mode 100644 packages/uipath-platform/src/uipath/platform/governance/policy.py create mode 100644 packages/uipath-platform/tests/services/test_governance_provider.py create mode 100644 packages/uipath-platform/tests/services/test_governance_service.py diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index ef97c0d24..56e0c4d4f 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.20" +version = "0.5.21" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/governance/__init__.py b/packages/uipath-core/src/uipath/core/governance/__init__.py index 3a06b82df..e3dcab741 100644 --- a/packages/uipath-core/src/uipath/core/governance/__init__.py +++ b/packages/uipath-core/src/uipath/core/governance/__init__.py @@ -19,6 +19,14 @@ Severity, ) from .models import Action, AuditRecord, EnforcementMode, LifecycleHook, RuleEvaluation +from .providers import ( + FiredRule, + GovernanceCompensationProvider, + GovernancePolicyProvider, + GovernRequest, + PolicyContext, + PolicyResponse, +) __all__ = [ # Output models (cross adapter boundary) @@ -35,4 +43,11 @@ "GovernanceConfigError", "GovernanceViolation", "Severity", + # Provider protocols + wire models + "FiredRule", + "GovernanceCompensationProvider", + "GovernancePolicyProvider", + "GovernRequest", + "PolicyContext", + "PolicyResponse", ] diff --git a/packages/uipath-core/src/uipath/core/governance/providers.py b/packages/uipath-core/src/uipath/core/governance/providers.py new file mode 100644 index 000000000..29f435edb --- /dev/null +++ b/packages/uipath-core/src/uipath/core/governance/providers.py @@ -0,0 +1,153 @@ +"""Provider protocols for governance backend interactions. + +The runtime needs two backend interactions to function: + +- Fetching the policy pack at startup. +- Firing the compensating ``/runtime/govern`` POST when a + ``guardrail_fallback`` rule matches so the server can run the disabled + centralised guardrail and write the per-rule LLMOps audit records. + +Both have wire formats owned by the ``agenticgovernance_`` ingress. +Defining the contracts here — alongside :class:`EvaluatorProtocol` — +lets runtime consumers depend on stable protocols and receive a +concrete provider via constructor injection. Concrete providers live +outside this package; ``uipath-core`` does not import them. +""" + +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from .models import EnforcementMode + +# ---------------------------------------------------------------------- +# Wire-format models +# ---------------------------------------------------------------------- + + +class PolicyContext(BaseModel): + """Caller-supplied selectors for the policy fetch. + + Wrapping the selectors in a model keeps the protocol surface stable + when the server grows new selector dimensions — adding a field here + doesn't change :meth:`GovernancePolicyProvider.get_policy`. + + Today carries only :attr:`is_conversational`; future selectors land + here. + """ + + model_config = ConfigDict(extra="ignore") + + is_conversational: bool | None = None + + +class PolicyResponse(BaseModel): + """Parsed governance backend response. + + Wire envelope:: + + { + "mode": "audit" | "enforce" | "disabled", + "policies": "" + } + + Attributes: + mode: Platform-controlled enforcement mode for the tenant. May + be ``None`` when the backend omits it. A wire value the SDK + doesn't know about parses as ``None`` rather than raising, + so a server-side mode addition can't break agent startup. + policies: Policy pack YAML the caller compiles into its policy + index. May be an empty string when no rules are configured. + """ + + model_config = ConfigDict(extra="ignore") + + mode: EnforcementMode | None = Field(default=None) + policies: str = Field(default="") + + @field_validator("mode", mode="before") + @classmethod + def _coerce_mode(cls, value: object) -> EnforcementMode | None: + if value is None or isinstance(value, EnforcementMode): + return value + try: + return EnforcementMode(value) + except ValueError: + return None + + +class FiredRule(BaseModel): + """Per-rule metadata carried in the ``/runtime/govern`` payload. + + One entry per matching ``guardrail_fallback`` condition. The server + writes one LLMOps trace record per entry, so callers must include + every fired rule even when multiple share the same ``validator``. + """ + + model_config = ConfigDict(populate_by_name=True) + + rule_id: str = Field(alias="ruleId") + rule_name: str = Field(alias="ruleName") + pack_name: str = Field(alias="packName") + validator: str + + +class GovernRequest(BaseModel): + """Request body for the ``/runtime/govern`` compensating governance POST. + + Field aliases match the on-the-wire JSON keys. ``src_timestamp`` is + snake_case on the wire (intentional — preserved verbatim); every + other key is camelCase. + + Job-context fields (``folder_key`` / ``job_key`` / ``process_key`` / + ``reference_id`` / ``agent_version``) are optional; callers omit + them by leaving them ``None``. How unset fields are resolved (e.g. + auto-filled from environment) is the concrete provider's concern, + not part of this wire contract. + """ + + model_config = ConfigDict(populate_by_name=True) + + validators: list[str] = Field(alias="type") + rules: list[FiredRule] + data: dict[str, Any] + hook: str + trace_id: str = Field(alias="traceId") + src_timestamp: str # wire key is intentionally snake_case + agent_name: str = Field(alias="agentName") + runtime_id: str = Field(alias="runtimeId") + + folder_key: str | None = Field(default=None, alias="folderKey") + job_key: str | None = Field(default=None, alias="jobKey") + process_key: str | None = Field(default=None, alias="processKey") + reference_id: str | None = Field(default=None, alias="referenceId") + agent_version: str | None = Field(default=None, alias="agentVersion") + + +# ---------------------------------------------------------------------- +# Provider protocols +# ---------------------------------------------------------------------- + + +@runtime_checkable +class GovernancePolicyProvider(Protocol): + """Contract for fetching the governance policy pack. + + Any object exposing a ``get_policy(context) -> PolicyResponse`` + method satisfies this protocol. + """ + + def get_policy(self, context: PolicyContext) -> PolicyResponse: + """Fetch the policy pack for the active org/tenant.""" + ... + + +@runtime_checkable +class GovernanceCompensationProvider(Protocol): + """Contract for firing the compensating ``/runtime/govern`` POST.""" + + def compensate(self, request: GovernRequest) -> None: + """Fire the compensating governance POST. Fire-and-forget.""" + ... diff --git a/packages/uipath-core/tests/governance/test_providers.py b/packages/uipath-core/tests/governance/test_providers.py new file mode 100644 index 000000000..083b62663 --- /dev/null +++ b/packages/uipath-core/tests/governance/test_providers.py @@ -0,0 +1,149 @@ +"""Tests for the governance provider protocols + wire-format models.""" + +from __future__ import annotations + +import pytest + +from uipath.core.governance import ( + EnforcementMode, + FiredRule, + GovernanceCompensationProvider, + GovernancePolicyProvider, + GovernRequest, + PolicyContext, + PolicyResponse, +) + + +class _FakePolicyProvider: + def __init__(self) -> None: + self.calls: list[PolicyContext] = [] + + def get_policy(self, context: PolicyContext) -> PolicyResponse: + self.calls.append(context) + return PolicyResponse(mode=EnforcementMode.ENFORCE, policies="rules: []") + + +class _FakeCompensationProvider: + def __init__(self) -> None: + self.calls: list[GovernRequest] = [] + + def compensate(self, request: GovernRequest) -> None: + self.calls.append(request) + + +def _make_request() -> GovernRequest: + return GovernRequest( + validators=["pii_detection"], + rules=[ + FiredRule( + rule_id="ASI-01", + rule_name="Block PII in flight", + pack_name="agent-safety", + validator="pii_detection", + ) + ], + data={"prompt": "hi"}, + hook="before_model", + trace_id="0123456789abcdef0123456789abcdef", + src_timestamp="2026-06-22T10:00:00Z", + agent_name="my-agent", + runtime_id="runtime-1", + ) + + +class TestPolicyContext: + def test_defaults(self) -> None: + ctx = PolicyContext() + assert ctx.is_conversational is None + + def test_ignores_unknown_fields(self) -> None: + ctx = PolicyContext.model_validate( + {"is_conversational": True, "future_selector": "x"} + ) + assert ctx.is_conversational is True + + +class TestPolicyResponse: + def test_defaults(self) -> None: + response = PolicyResponse() + assert response.mode is None + assert response.policies == "" + + @pytest.mark.parametrize( + ("wire_value", "expected"), + [ + ("audit", EnforcementMode.AUDIT), + ("enforce", EnforcementMode.ENFORCE), + ("disabled", EnforcementMode.DISABLED), + ], + ) + def test_parses_known_modes( + self, wire_value: str, expected: EnforcementMode + ) -> None: + response = PolicyResponse.model_validate({"mode": wire_value}) + assert response.mode is expected + + def test_unknown_mode_falls_back_to_none(self) -> None: + # Forward-compat: a server-added mode the SDK doesn't know about + # must not break agent startup. Parses as None so the runtime + # falls back to its safe default rather than raising. + response = PolicyResponse.model_validate({"mode": "ludicrous"}) + assert response.mode is None + + +class TestGovernRequest: + def test_serializes_wire_aliases(self) -> None: + payload = _make_request().model_dump(by_alias=True, exclude_none=True) + assert payload["type"] == ["pii_detection"] + assert payload["traceId"] == "0123456789abcdef0123456789abcdef" + assert payload["agentName"] == "my-agent" + assert payload["runtimeId"] == "runtime-1" + # src_timestamp is intentionally snake_case on the wire. + assert payload["src_timestamp"] == "2026-06-22T10:00:00Z" + # Optional job-context fields left None → excluded. + for absent in ( + "folderKey", + "jobKey", + "processKey", + "referenceId", + "agentVersion", + ): + assert absent not in payload + + +class TestProtocolConformance: + """`runtime_checkable` Protocols should accept structurally-matching objects.""" + + def test_fake_policy_provider_satisfies_protocol(self) -> None: + provider = _FakePolicyProvider() + assert isinstance(provider, GovernancePolicyProvider) + + def test_fake_compensation_provider_satisfies_protocol(self) -> None: + provider = _FakeCompensationProvider() + assert isinstance(provider, GovernanceCompensationProvider) + + def test_object_without_methods_rejected(self) -> None: + class _NotAProvider: + pass + + assert not isinstance(_NotAProvider(), GovernancePolicyProvider) + assert not isinstance(_NotAProvider(), GovernanceCompensationProvider) + + +class TestEndToEndDispatch: + """Caller passes a provider directly to the consumer (no global registry).""" + + def test_policy_round_trip(self) -> None: + provider = _FakePolicyProvider() + response = provider.get_policy(PolicyContext(is_conversational=True)) + + assert response.mode is EnforcementMode.ENFORCE + assert provider.calls == [PolicyContext(is_conversational=True)] + + def test_compensation_round_trip(self) -> None: + provider = _FakeCompensationProvider() + request = _make_request() + provider.compensate(request) + + assert provider.calls == [request] diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index ee08896ea..005bf9018 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -1011,7 +1011,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.20" +version = "0.5.21" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 37d6fcdaa..b8effe0f0 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.71" +version = "0.1.73" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" @@ -8,7 +8,7 @@ dependencies = [ "httpx>=0.28.1", "tenacity>=9.0.0", "truststore>=0.10.1", - "uipath-core>=0.5.20, <0.6.0", + "uipath-core>=0.5.21, <0.6.0", "pydantic-function-models>=0.1.11", "sqlparse>=0.5.5", ] diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index 8e0e23867..0083388db 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -22,6 +22,7 @@ from .documents import DocumentsService from .entities import EntitiesService from .errors import BaseUrlMissingError, SecretMissingError +from .governance import GovernanceService from .guardrails import GuardrailsService from .memory import MemoryService from .orchestrator import ( @@ -169,6 +170,10 @@ def mcp(self) -> McpService: def guardrails(self) -> GuardrailsService: return GuardrailsService(self._config, self._execution_context) + @cached_property + def governance(self) -> GovernanceService: + return GovernanceService(self._config, self._execution_context) + @property def agenthub(self) -> AgentHubService: return AgentHubService(self._config, self._execution_context, self.folders) diff --git a/packages/uipath-platform/src/uipath/platform/common/__init__.py b/packages/uipath-platform/src/uipath/platform/common/__init__.py index cefd92075..555d6901d 100644 --- a/packages/uipath-platform/src/uipath/platform/common/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/common/__init__.py @@ -4,7 +4,7 @@ """ from ._api_client import ApiClient -from ._base_service import BaseService +from ._base_service import BaseService, resolve_trace_id from ._bindings import ( ConnectionResourceOverwrite, EntityResourceOverwrite, @@ -112,6 +112,7 @@ "_SpanUtils", "resolve_service_url", "inject_routing_headers", + "resolve_trace_id", ] from .validation import validate_pagination_params diff --git a/packages/uipath-platform/src/uipath/platform/common/_base_service.py b/packages/uipath-platform/src/uipath/platform/common/_base_service.py index 21d8fa404..95035b852 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_base_service.py +++ b/packages/uipath-platform/src/uipath/platform/common/_base_service.py @@ -66,6 +66,60 @@ def _get_caller_component() -> str: _TRACE_PARENT_HEADER = "x-uipath-traceparent-id" +def resolve_trace_id(fallback: str | None = None) -> str | None: + """Resolve the current UiPath trace id as a 32-char hex string. + + Same lookup chain :func:`_inject_trace_context` uses to compose the + ``x-uipath-traceparent-id`` header, exposed as a public helper so + callers can capture the value when they need it in a request body + (e.g. governance compensation) or before hopping to a background + thread that won't inherit the OpenTelemetry context. + + Resolution order (first hit wins): + + 1. :attr:`UiPathConfig.trace_id` (``UIPATH_TRACE_ID`` env var), + normalized via :meth:`_SpanUtils.normalize_trace_id`. This is the + canonical agent trace id the LLMOps exporter binds spans to. + 2. The LLMOps external span trace id, when a provider is registered + via :meth:`UiPathSpanUtils.register_current_span_provider`. + 3. The current OpenTelemetry span trace id. + 4. The caller-supplied ``fallback``. + + Args: + fallback: Returned when nothing above resolves. + + Returns: + Lower-case 32-char hex trace id, or ``fallback`` (which may be + ``None``) when no source yields a usable value. + + Thread Safety: + Steps 2 and 3 read OpenTelemetry's thread-local context. Call this + on the thread that owns the live span (e.g. the agent's hook + thread) and capture the result before submitting work to a + background pool — worker threads do not inherit the context. + """ + from uipath.core.tracing.span_utils import UiPathSpanUtils + + from ._config import UiPathConfig + from ._span_utils import _SpanUtils + + config_trace_id = UiPathConfig.trace_id + if config_trace_id: + try: + return _SpanUtils.normalize_trace_id(config_trace_id) + except ValueError: + # Malformed UIPATH_TRACE_ID — fall through to OTel context. + pass + + llmops_span = UiPathSpanUtils.get_external_current_span() + span = llmops_span or trace.get_current_span() + ctx = span.get_span_context() + if ctx.trace_id: + return format_trace_id(ctx.trace_id) + + return fallback + + def _inject_trace_context(headers: dict[str, str]) -> None: """Inject UiPath trace context header. diff --git a/packages/uipath-platform/src/uipath/platform/governance/__init__.py b/packages/uipath-platform/src/uipath/platform/governance/__init__.py new file mode 100644 index 000000000..e1f587606 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/governance/__init__.py @@ -0,0 +1,20 @@ +"""Governance services for the UiPath Platform. + +Exposes the agenticgovernance_ ingress: tenant-controlled policy packs +served centrally so policy decisions can change without redeploying +agents. +""" + +from ._governance_provider import UiPathPlatformGovernanceProvider +from ._governance_service import GovernanceService +from .compensate import FiredRule, GovernRequest +from .policy import PolicyContext, PolicyResponse + +__all__ = [ + "FiredRule", + "GovernRequest", + "GovernanceService", + "PolicyContext", + "PolicyResponse", + "UiPathPlatformGovernanceProvider", +] diff --git a/packages/uipath-platform/src/uipath/platform/governance/_governance_provider.py b/packages/uipath-platform/src/uipath/platform/governance/_governance_provider.py new file mode 100644 index 000000000..23f1464bb --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/governance/_governance_provider.py @@ -0,0 +1,81 @@ +"""Platform-backed implementation of the core governance provider protocols. + +Thin adapter around :class:`GovernanceService` that exposes only the +methods required by +:class:`uipath.core.governance.GovernancePolicyProvider` and +:class:`uipath.core.governance.GovernanceCompensationProvider`. + +Wrap an existing :class:`GovernanceService` (e.g. +``UiPathPlatformGovernanceProvider(service=UiPath().governance)``) or +pass ``config``/``execution_context`` to construct one inline. +""" + +from __future__ import annotations + +from uipath.core.governance import GovernRequest, PolicyContext, PolicyResponse + +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ._governance_service import GovernanceService + + +class UiPathPlatformGovernanceProvider: + """Platform-backed governance provider. + + Implements both + :class:`uipath.core.governance.GovernancePolicyProvider` and + :class:`uipath.core.governance.GovernanceCompensationProvider` by + delegating to :class:`GovernanceService`. + + Args: + service: Existing :class:`GovernanceService` to delegate to. + Useful for tests and for sharing an SDK service across + consumers. When omitted, a fresh service is built from the + ``config`` and ``execution_context`` kwargs. + config: Required when ``service`` is not supplied. + execution_context: Required when ``service`` is not supplied. + """ + + def __init__( + self, + service: GovernanceService | None = None, + *, + config: UiPathApiConfig | None = None, + execution_context: UiPathExecutionContext | None = None, + ) -> None: + if service is None: + if config is None or execution_context is None: + raise ValueError( + "UiPathPlatformGovernanceProvider requires either a " + "GovernanceService instance or both config and " + "execution_context." + ) + service = GovernanceService( + config=config, execution_context=execution_context + ) + self._service = service + + @property + def service(self) -> GovernanceService: + """The underlying :class:`GovernanceService` instance.""" + return self._service + + # ── GovernancePolicyProvider ───────────────────────────────────── + + def get_policy(self, context: PolicyContext) -> PolicyResponse: + """Fetch the policy pack — delegates to ``GovernanceService``.""" + return self._service.get_policy(context) + + async def get_policy_async(self, context: PolicyContext) -> PolicyResponse: + """Async variant of :meth:`get_policy`.""" + return await self._service.get_policy_async(context) + + # ── GovernanceCompensationProvider ─────────────────────────────── + + def compensate(self, request: GovernRequest) -> None: + """Fire the compensating ``/runtime/govern`` POST.""" + self._service._compensate(request) + + async def compensate_async(self, request: GovernRequest) -> None: + """Async variant of :meth:`compensate`.""" + await self._service._compensate_async(request) diff --git a/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py b/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py new file mode 100644 index 000000000..5ceabf479 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py @@ -0,0 +1,356 @@ +"""Service for the ``agenticgovernance_`` ingress. + +Wraps the two governance backend endpoints UiPath exposes: + +- ``GET /{org}/agenticgovernance_/api/v1/runtime/policy`` — fetch the + tenant-managed policy pack (see :meth:`GovernanceService.retrieve_policy`). +- ``POST /{org}/agenticgovernance_/api/v1/runtime/govern`` — compensating + governance call fired when a ``guardrail_fallback`` rule matches + (see :meth:`GovernanceService.compensate`). + +Org/tenant scoping is read from :class:`UiPathConfig`; auth, retries, +trace context, and error enrichment come from :class:`BaseService`. +""" + +from typing import Any, Optional + +from uipath.core import traced +from uipath.core.governance import ( + FiredRule, + GovernRequest, + PolicyContext, + PolicyResponse, +) + +from ..common._base_service import BaseService +from ..common._config import UiPathConfig +from ..common._service_url_overrides import ( + inject_routing_headers, + resolve_service_url, +) +from ..common.constants import HEADER_INTERNAL_TENANT_ID + +# The agenticgovernance_ ingress lives at a separate org-scoped path that +# uses the organization UUID (not the slug exposed by ``UIPATH_URL``). +GOVERNANCE_SERVICE_PREFIX = "agenticgovernance_" +POLICY_API_PATH = "api/v1/runtime/policy" +GOVERN_API_PATH = "api/v1/runtime/govern" +AGENT_TYPE_PARAM = "agentType" + + +class GovernanceService(BaseService): + """Service for the agenticgovernance_ ingress. + + Exposes two endpoints: + + - :meth:`retrieve_policy` — GET the tenant-managed policy pack. + - :meth:`compensate` — POST a compensating ``/runtime/govern`` call + so the server can run a disabled centralized guardrail and write + the per-rule LLMOps audit records itself. + + Org and tenant scoping come from :attr:`UiPathConfig.organization_id` + and :attr:`UiPathConfig.tenant_id`; the tenant travels in the + ``x-uipath-internal-tenantid`` header (the URL is org-scoped only). + + !!! info "Version Availability" + This service is available starting from **uipath** version **2.2.13**. + """ + + # ── Policy fetch ───────────────────────────────────────────────── + + @traced(name="governance_retrieve_policy", run_type="uipath") + def retrieve_policy( + self, + *, + is_conversational: Optional[bool] = None, + ) -> PolicyResponse: + """Fetch the governance policy pack for the active org/tenant. + + Args: + is_conversational: When the hosted agent's type is known, + selects the conversational (``True``) or autonomous + (``False``) policy view. ``None`` (default) omits the + ``agentType`` query param so the server applies its + default. + + Returns: + PolicyResponse: ``mode`` and the YAML ``policies`` string. + + Raises: + ValueError: If ``UiPathConfig.organization_id`` or + ``UiPathConfig.tenant_id`` is not set. + EnrichedException: If the backend returns a non-2xx response. + + Examples: + ```python + from uipath.platform import UiPath + + client = UiPath() + response = client.governance.retrieve_policy() + print(response.mode, len(response.policies)) + ``` + """ + url, headers = self._build_org_scoped_request(POLICY_API_PATH) + params = self._policy_params(is_conversational) + response = self.request("GET", url=url, params=params, headers=headers) + return PolicyResponse.model_validate(response.json()) + + @traced(name="governance_retrieve_policy", run_type="uipath") + async def retrieve_policy_async( + self, + *, + is_conversational: Optional[bool] = None, + ) -> PolicyResponse: + """Asynchronously fetch the governance policy pack. + + See :meth:`retrieve_policy` for parameter and return semantics. + """ + url, headers = self._build_org_scoped_request(POLICY_API_PATH) + params = self._policy_params(is_conversational) + response = await self.request_async( + "GET", url=url, params=params, headers=headers + ) + return PolicyResponse.model_validate(response.json()) + + # ── Policy provider adapter (GovernancePolicyProvider protocol) ─ + + def get_policy(self, context: PolicyContext) -> PolicyResponse: + """Fetch the policy pack — :class:`GovernancePolicyProvider` adapter. + + Thin wrapper over :meth:`retrieve_policy` that accepts the + context model the core protocol uses. Lets the runtime consume + governance through :class:`uipath.core.governance.GovernancePolicyProvider` + without importing this module. + """ + return self.retrieve_policy(is_conversational=context.is_conversational) + + async def get_policy_async(self, context: PolicyContext) -> PolicyResponse: + """Async variant of :meth:`get_policy`.""" + return await self.retrieve_policy_async( + is_conversational=context.is_conversational + ) + + # ── Compensating governance call ───────────────────────────────── + + def compensate( + self, + *, + hook: str, + validators: list[str], + rules: list[FiredRule], + data: dict[str, Any], + trace_id: str, + src_timestamp: str, + agent_name: str, + runtime_id: str, + folder_key: str | None = None, + job_key: str | None = None, + process_key: str | None = None, + reference_id: str | None = None, + agent_version: str | None = None, + ) -> None: + """POST a compensating ``/runtime/govern`` call. + + Fired when a ``guardrail_fallback`` rule matches: the centralized + guardrail is disabled, so the server is asked to run the + guardrail check server-side and write the per-rule LLMOps audit + records bound to ``trace_id``. The agent does not inspect the + response body. + + Job-context fields (``folder_key`` / ``job_key`` / + ``process_key`` / ``reference_id`` / ``agent_version``) are + auto-populated from :class:`UiPathConfig` when omitted. + Caller-supplied values — including the empty string — take + precedence. + + Args: + hook: Identifier of the agent hook that fired the rule + (e.g. ``"before_model"``). + validators: Validator names attached to the fired rules. + rules: Each rule that fired — one LLMOps audit record is + written per entry. + data: Hook payload the server replays through the + centralized guardrail. + trace_id: Canonical 32-char hex trace id. Capture via + :func:`resolve_trace_id` on the hook thread before + hopping to a background pool. + src_timestamp: ISO-8601 timestamp on the source side. + agent_name: Agent identifier as known to the platform. + runtime_id: Runtime instance identifier. + folder_key: Override the env-backed folder key. + job_key: Override the env-backed job key. + process_key: Override the env-backed process key. + reference_id: Override the env-backed agent id. + agent_version: Override the env-backed agent version. + + Raises: + ValueError: If ``UiPathConfig.organization_id`` or + ``UiPathConfig.tenant_id`` is not set. + EnrichedException: If the backend returns a non-2xx response. + + Threading: + ``trace_id`` must be the agent's canonical trace id, and + OpenTelemetry context is thread-local; capture it on the + hook thread (via :func:`resolve_trace_id`) before hopping + to a background pool. + """ + self._compensate( + GovernRequest( + hook=hook, + validators=validators, + rules=rules, + data=data, + trace_id=trace_id, + src_timestamp=src_timestamp, + agent_name=agent_name, + runtime_id=runtime_id, + folder_key=folder_key, + job_key=job_key, + process_key=process_key, + reference_id=reference_id, + agent_version=agent_version, + ) + ) + + async def compensate_async( + self, + *, + hook: str, + validators: list[str], + rules: list[FiredRule], + data: dict[str, Any], + trace_id: str, + src_timestamp: str, + agent_name: str, + runtime_id: str, + folder_key: str | None = None, + job_key: str | None = None, + process_key: str | None = None, + reference_id: str | None = None, + agent_version: str | None = None, + ) -> None: + """Asynchronously POST a compensating ``/runtime/govern`` call. + + See :meth:`compensate` for parameter semantics. + """ + await self._compensate_async( + GovernRequest( + hook=hook, + validators=validators, + rules=rules, + data=data, + trace_id=trace_id, + src_timestamp=src_timestamp, + agent_name=agent_name, + runtime_id=runtime_id, + folder_key=folder_key, + job_key=job_key, + process_key=process_key, + reference_id=reference_id, + agent_version=agent_version, + ) + ) + + # ── Internal worker for GovernRequest-shaped callers ───────────── + + @traced(name="governance_compensate", run_type="uipath") + def _compensate(self, request: GovernRequest) -> None: + """Fire a compensation call from a pre-built :class:`GovernRequest`. + + Internal helper used by the provider adapter + (:class:`uipath.platform.governance.UiPathPlatformGovernanceProvider`) + to satisfy :class:`uipath.core.governance.GovernanceCompensationProvider` + without unpacking the request. The public ergonomic counterpart + is :meth:`compensate`. + """ + url, headers = self._build_org_scoped_request(GOVERN_API_PATH) + payload = self._build_govern_payload(request) + self.request("POST", url=url, headers=headers, json=payload) + + @traced(name="governance_compensate", run_type="uipath") + async def _compensate_async(self, request: GovernRequest) -> None: + """Async variant of :meth:`_compensate`.""" + url, headers = self._build_org_scoped_request(GOVERN_API_PATH) + payload = self._build_govern_payload(request) + await self.request_async("POST", url=url, headers=headers, json=payload) + + # ── Internals ──────────────────────────────────────────────────── + + def _build_org_scoped_request(self, path: str) -> tuple[str, dict[str, str]]: + """Compose the agenticgovernance_ URL and the tenant header. + + Both governance endpoints share the same URL shape + (``{origin}/{org_id_uuid}/agenticgovernance_/{path}``) and the + same ``x-uipath-internal-tenantid`` header — neither matches + ``UiPathUrl.scope_url`` (slug-based), so the URL is composed + directly here. + + Honors ``UIPATH_SERVICE_URL_AGENTICGOVERNANCE`` for local dev: + when set, redirects to the override and injects routing headers + so the local server sees what the platform router would have + carried. ``BaseService.request`` does this same dance for paths + that fit ``scope_url``; the org-UUID-in-path shape forces us to + run it ourselves before composing the absolute URL. + """ + organization_id = UiPathConfig.organization_id + if not organization_id: + raise ValueError( + "Governance call requires UIPATH_ORGANIZATION_ID " + "to be set in the environment." + ) + tenant_id = UiPathConfig.tenant_id + if not tenant_id: + raise ValueError( + "Governance call requires UIPATH_TENANT_ID " + "to be set in the environment." + ) + + override = resolve_service_url(f"{GOVERNANCE_SERVICE_PREFIX}/{path}") + if override: + headers: dict[str, str] = {} + inject_routing_headers(headers) + return override, headers + + url = ( + f"{self._url.base_url}/{organization_id}/{GOVERNANCE_SERVICE_PREFIX}/{path}" + ) + return url, {HEADER_INTERNAL_TENANT_ID: tenant_id} + + @staticmethod + def _policy_params(is_conversational: Optional[bool]) -> dict[str, str]: + if is_conversational is None: + return {} + return { + AGENT_TYPE_PARAM: "conversational" if is_conversational else "autonomous" + } + + @staticmethod + def _build_govern_payload(request: GovernRequest) -> dict[str, Any]: + """Serialize the request and fill missing job-context from UiPathConfig. + + Auto-fill resolution order for each job-context field: caller + value > ``UiPathConfig`` (env-var-backed) > omit. + + ``model_dump(exclude_none=True)`` already drops fields the caller + left ``None``, so key presence — not truthiness — is the right + "was it supplied?" signal: a caller-supplied empty string is + still a caller value and must not be overridden by the env. + """ + payload = request.model_dump(by_alias=True, exclude_none=True) + for wire_key, config_attr in _JOB_CONTEXT_FIELDS: + if wire_key in payload: + continue + value = getattr(UiPathConfig, config_attr, None) + if value: + payload[wire_key] = value + return payload + + +# Wire-key → UiPathConfig attribute, for compensation payload auto-fill. +_JOB_CONTEXT_FIELDS: tuple[tuple[str, str], ...] = ( + ("folderKey", "folder_key"), + ("jobKey", "job_key"), + ("processKey", "process_uuid"), + ("referenceId", "agent_id"), + ("agentVersion", "process_version"), +) diff --git a/packages/uipath-platform/src/uipath/platform/governance/compensate.py b/packages/uipath-platform/src/uipath/platform/governance/compensate.py new file mode 100644 index 000000000..bad4845f9 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/governance/compensate.py @@ -0,0 +1,10 @@ +"""Re-exports of compensation models from :mod:`uipath.core.governance`. + +The wire-shape models live in ``uipath-core`` so the runtime can depend on +the protocol contract without importing ``uipath-platform``. This module +keeps the existing ``uipath.platform.governance`` import paths working. +""" + +from uipath.core.governance import FiredRule, GovernRequest + +__all__ = ["FiredRule", "GovernRequest"] diff --git a/packages/uipath-platform/src/uipath/platform/governance/policy.py b/packages/uipath-platform/src/uipath/platform/governance/policy.py new file mode 100644 index 000000000..27de1c9e7 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/governance/policy.py @@ -0,0 +1,10 @@ +"""Re-exports of governance policy models from :mod:`uipath.core.governance`. + +The wire-shape models live in ``uipath-core`` so the runtime can depend on +the protocol contract without importing ``uipath-platform``. This module +keeps the existing ``uipath.platform.governance`` import paths working. +""" + +from uipath.core.governance import PolicyContext, PolicyResponse + +__all__ = ["PolicyContext", "PolicyResponse"] diff --git a/packages/uipath-platform/tests/services/test_governance_provider.py b/packages/uipath-platform/tests/services/test_governance_provider.py new file mode 100644 index 000000000..24e489f65 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_governance_provider.py @@ -0,0 +1,166 @@ +"""Tests for UiPathPlatformGovernanceProvider.""" + +from __future__ import annotations + +import pytest +from pytest_httpx import HTTPXMock +from uipath.core.governance import ( + EnforcementMode, + FiredRule, + GovernanceCompensationProvider, + GovernancePolicyProvider, + GovernRequest, + PolicyContext, +) + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.governance import ( + GovernanceService, + UiPathPlatformGovernanceProvider, +) + +ORG_ID = "11111111-1111-1111-1111-111111111111" +TENANT_ID = "22222222-2222-2222-2222-222222222222" + + +def _make_request() -> GovernRequest: + return GovernRequest( + validators=["pii_detection"], + rules=[ + FiredRule( + rule_id="ASI-01", + rule_name="Block PII in flight", + pack_name="agent-safety", + validator="pii_detection", + ) + ], + data={"prompt": "hello"}, + hook="before_model", + trace_id="0123456789abcdef0123456789abcdef", + src_timestamp="2026-06-22T10:00:00Z", + agent_name="my-agent", + runtime_id="runtime-1", + ) + + +@pytest.fixture +def provider( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, +) -> UiPathPlatformGovernanceProvider: + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", ORG_ID) + monkeypatch.setenv("UIPATH_TENANT_ID", TENANT_ID) + service = GovernanceService(config=config, execution_context=execution_context) + return UiPathPlatformGovernanceProvider(service=service) + + +class TestConstruction: + def test_accepts_existing_service( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + service = GovernanceService(config=config, execution_context=execution_context) + provider = UiPathPlatformGovernanceProvider(service=service) + assert provider.service is service + + def test_builds_service_from_config( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + provider = UiPathPlatformGovernanceProvider( + config=config, execution_context=execution_context + ) + assert isinstance(provider.service, GovernanceService) + + def test_requires_service_or_full_kwargs(self) -> None: + with pytest.raises(ValueError, match="GovernanceService"): + UiPathPlatformGovernanceProvider() + + +class TestProtocolConformance: + def test_satisfies_policy_provider_protocol( + self, provider: UiPathPlatformGovernanceProvider + ) -> None: + assert isinstance(provider, GovernancePolicyProvider) + + def test_satisfies_compensation_provider_protocol( + self, provider: UiPathPlatformGovernanceProvider + ) -> None: + assert isinstance(provider, GovernanceCompensationProvider) + + +class TestDelegation: + def test_get_policy_delegates_to_service( + self, + httpx_mock: HTTPXMock, + provider: UiPathPlatformGovernanceProvider, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=( + f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/policy" + "?agentType=conversational" + ), + status_code=200, + json={"mode": "enforce", "policies": "rules: []"}, + ) + + response = provider.get_policy(PolicyContext(is_conversational=True)) + + assert response.mode is EnforcementMode.ENFORCE + assert response.policies == "rules: []" + + async def test_get_policy_async_delegates_to_service( + self, + httpx_mock: HTTPXMock, + provider: UiPathPlatformGovernanceProvider, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/policy", + status_code=200, + json={"mode": "audit", "policies": ""}, + ) + + response = await provider.get_policy_async(PolicyContext()) + + assert response.mode is EnforcementMode.AUDIT + + def test_compensate_delegates_to_service( + self, + httpx_mock: HTTPXMock, + provider: UiPathPlatformGovernanceProvider, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/govern", + status_code=200, + json={}, + ) + + provider.compensate(_make_request()) + + requests = httpx_mock.get_requests() + assert len(requests) == 1 + assert requests[0].method == "POST" + + async def test_compensate_async_delegates_to_service( + self, + httpx_mock: HTTPXMock, + provider: UiPathPlatformGovernanceProvider, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/govern", + status_code=200, + json={}, + ) + + await provider.compensate_async(_make_request()) + + requests = httpx_mock.get_requests() + assert len(requests) == 1 + assert requests[0].method == "POST" diff --git a/packages/uipath-platform/tests/services/test_governance_service.py b/packages/uipath-platform/tests/services/test_governance_service.py new file mode 100644 index 000000000..e437fdda0 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_governance_service.py @@ -0,0 +1,594 @@ +"""Tests for GovernanceService.""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx +import pytest +from pytest_httpx import HTTPXMock +from uipath.core.governance import GovernancePolicyProvider, PolicyContext + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.common import resolve_trace_id +from uipath.platform.governance import ( + FiredRule, + GovernanceService, + PolicyResponse, +) + +ORG_ID = "11111111-1111-1111-1111-111111111111" +TENANT_ID = "22222222-2222-2222-2222-222222222222" +TENANT_ID_HEX = TENANT_ID.replace("-", "").lower() + + +def _compensate_kwargs(**overrides: Any) -> dict[str, Any]: + """Default kwargs for ``service.compensate(...)``.""" + defaults: dict[str, Any] = dict( + validators=["pii_detection"], + rules=[ + FiredRule( + rule_id="ASI-01", + rule_name="Block PII in flight", + pack_name="agent-safety", + validator="pii_detection", + ) + ], + data={"prompt": "hello"}, + hook="before_model", + trace_id="0123456789abcdef0123456789abcdef", + src_timestamp="2026-06-22T10:00:00Z", + agent_name="my-agent", + runtime_id="runtime-1", + ) + defaults.update(overrides) + return defaults + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, +) -> GovernanceService: + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", ORG_ID) + monkeypatch.setenv("UIPATH_TENANT_ID", TENANT_ID) + return GovernanceService(config=config, execution_context=execution_context) + + +class TestGovernanceService: + """Test GovernanceService functionality.""" + + class TestRetrievePolicy: + """Test retrieve_policy (sync).""" + + def test_returns_parsed_policy( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/policy", + status_code=200, + json={"mode": "enforce", "policies": "rules: []"}, + ) + + result = service.retrieve_policy() + + assert isinstance(result, PolicyResponse) + assert result.mode == "enforce" + assert result.policies == "rules: []" + + def test_defaults_when_fields_missing( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/policy", + status_code=200, + json={}, + ) + + result = service.retrieve_policy() + + assert result.mode is None + assert result.policies == "" + + def test_sends_tenant_header_and_bearer_token( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + secret: str, + ) -> None: + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={"mode": "audit", "policies": ""}) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/policy", + ) + + service.retrieve_policy() + + request = captured["request"] + assert request.method == "GET" + assert request.headers["x-uipath-internal-tenantid"] == TENANT_ID + assert request.headers["authorization"] == f"Bearer {secret}" + # No agentType query param when caller omits it. + assert "agentType" not in request.url.params + + @pytest.mark.parametrize( + ("is_conversational", "expected"), + [(True, "conversational"), (False, "autonomous")], + ) + def test_appends_agent_type_query_param( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + is_conversational: bool, + expected: str, + ) -> None: + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={"mode": "audit", "policies": ""}) + + httpx_mock.add_callback( + capture, + url=( + f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/policy" + f"?agentType={expected}" + ), + ) + + service.retrieve_policy(is_conversational=is_conversational) + + assert captured["request"].url.params["agentType"] == expected + + def test_raises_when_organization_id_missing( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_ORGANIZATION_ID", raising=False) + monkeypatch.setenv("UIPATH_TENANT_ID", TENANT_ID) + service = GovernanceService( + config=config, execution_context=execution_context + ) + + with pytest.raises(ValueError, match="UIPATH_ORGANIZATION_ID"): + service.retrieve_policy() + + def test_raises_when_tenant_id_missing( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", ORG_ID) + monkeypatch.delenv("UIPATH_TENANT_ID", raising=False) + service = GovernanceService( + config=config, execution_context=execution_context + ) + + with pytest.raises(ValueError, match="UIPATH_TENANT_ID"): + service.retrieve_policy() + + def test_raises_on_http_error( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + from uipath.platform.errors import EnrichedException + + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/policy", + status_code=500, + text="boom", + ) + + with pytest.raises(EnrichedException): + service.retrieve_policy() + + class TestRetrievePolicyAsync: + """Test retrieve_policy_async.""" + + async def test_returns_parsed_policy( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=( + f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/policy" + "?agentType=autonomous" + ), + status_code=200, + json={"mode": "audit", "policies": "rules: []"}, + ) + + result = await service.retrieve_policy_async(is_conversational=False) + + assert result.mode == "audit" + assert result.policies == "rules: []" + + class TestCompensate: + """Test compensate (sync).""" + + def test_posts_aliased_payload( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + secret: str, + ) -> None: + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={}) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/govern", + ) + + service.compensate(**_compensate_kwargs()) + + request = captured["request"] + assert request.method == "POST" + assert request.headers["x-uipath-internal-tenantid"] == TENANT_ID + assert request.headers["authorization"] == f"Bearer {secret}" + + body = json.loads(request.content) + assert body["type"] == ["pii_detection"] + assert body["rules"] == [ + { + "ruleId": "ASI-01", + "ruleName": "Block PII in flight", + "packName": "agent-safety", + "validator": "pii_detection", + } + ] + assert body["traceId"] == "0123456789abcdef0123456789abcdef" + assert body["src_timestamp"] == "2026-06-22T10:00:00Z" + assert body["agentName"] == "my-agent" + assert body["runtimeId"] == "runtime-1" + assert body["hook"] == "before_model" + assert body["data"] == {"prompt": "hello"} + + def test_autofills_job_context_from_config( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "folder-from-env") + monkeypatch.setenv("UIPATH_JOB_KEY", "job-from-env") + monkeypatch.setenv("UIPATH_PROCESS_UUID", "process-from-env") + monkeypatch.setenv("UIPATH_AGENT_ID", "agent-from-env") + monkeypatch.setenv("UIPATH_PROCESS_VERSION", "1.2.3") + + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={}) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/govern", + ) + + service.compensate(**_compensate_kwargs()) + + body = json.loads(captured["request"].content) + assert body["folderKey"] == "folder-from-env" + assert body["jobKey"] == "job-from-env" + assert body["processKey"] == "process-from-env" + assert body["referenceId"] == "agent-from-env" + assert body["agentVersion"] == "1.2.3" + + def test_caller_overrides_take_precedence( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder") + monkeypatch.setenv("UIPATH_JOB_KEY", "env-job") + + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={}) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/govern", + ) + + service.compensate(**_compensate_kwargs(folder_key="explicit-folder")) + + body = json.loads(captured["request"].content) + # Caller-supplied value wins. + assert body["folderKey"] == "explicit-folder" + # Env-backed fallback fills the unset one. + assert body["jobKey"] == "env-job" + # Unset and unbacked → key omitted. + assert "processKey" not in body + + def test_caller_empty_string_is_not_overridden_by_env( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder") + + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={}) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/govern", + ) + + # Explicit empty string is still a caller value — must not be + # silently replaced by the env-backed UiPathConfig fallback. + service.compensate(**_compensate_kwargs(folder_key="")) + + body = json.loads(captured["request"].content) + assert body["folderKey"] == "" + + def test_omits_job_context_keys_with_no_value( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + for env_key in ( + "UIPATH_FOLDER_KEY", + "UIPATH_JOB_KEY", + "UIPATH_PROCESS_UUID", + "UIPATH_AGENT_ID", + "UIPATH_PROCESS_VERSION", + "UIPATH_PROJECT_ID", + ): + monkeypatch.delenv(env_key, raising=False) + + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={}) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/govern", + ) + + service.compensate(**_compensate_kwargs()) + + body = json.loads(captured["request"].content) + for absent in ( + "folderKey", + "jobKey", + "processKey", + "referenceId", + "agentVersion", + ): + assert absent not in body + + def test_raises_on_http_error( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + from uipath.platform.errors import EnrichedException + + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/govern", + status_code=400, + text="bad payload", + ) + + with pytest.raises(EnrichedException): + service.compensate(**_compensate_kwargs()) + + def test_raises_when_org_id_missing( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_ORGANIZATION_ID", raising=False) + monkeypatch.setenv("UIPATH_TENANT_ID", TENANT_ID) + service = GovernanceService( + config=config, execution_context=execution_context + ) + + with pytest.raises(ValueError, match="UIPATH_ORGANIZATION_ID"): + service.compensate(**_compensate_kwargs()) + + class TestCompensateAsync: + """Test compensate_async.""" + + async def test_posts_aliased_payload( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/govern", + status_code=200, + json={}, + ) + + await service.compensate_async(**_compensate_kwargs()) + + requests = httpx_mock.get_requests() + assert len(requests) == 1 + assert requests[0].method == "POST" + + class TestProtocolConformance: + """``get_policy`` adapter is the only protocol-shaped surface left + on :class:`GovernanceService`; compensation conformance is tested + against :class:`UiPathPlatformGovernanceProvider`. + """ + + def test_satisfies_policy_provider_protocol( + self, service: GovernanceService + ) -> None: + assert isinstance(service, GovernancePolicyProvider) + + def test_get_policy_delegates_to_retrieve_policy( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=( + f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/policy" + "?agentType=conversational" + ), + status_code=200, + json={"mode": "enforce", "policies": "rules: []"}, + ) + + response = service.get_policy(PolicyContext(is_conversational=True)) + + assert response.mode == "enforce" + assert response.policies == "rules: []" + + async def test_get_policy_async_delegates( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/policy", + status_code=200, + json={"mode": "audit", "policies": ""}, + ) + + response = await service.get_policy_async(PolicyContext()) + + assert response.mode == "audit" + + class TestServiceUrlOverride: + """Honor UIPATH_SERVICE_URL_AGENTICGOVERNANCE for local dev.""" + + def test_redirects_policy_fetch_to_override( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv( + "UIPATH_SERVICE_URL_AGENTICGOVERNANCE", "http://localhost:8123" + ) + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={"mode": "audit", "policies": ""}) + + httpx_mock.add_callback( + capture, url="http://localhost:8123/api/v1/runtime/policy" + ) + + service.retrieve_policy() + + request = captured["request"] + # Routing headers replace the platform router, org-UUID path is dropped. + assert request.headers["X-UiPath-Internal-TenantId"] == TENANT_ID + assert request.headers["X-UiPath-Internal-AccountId"] == ORG_ID + assert ORG_ID not in str(request.url) + + def test_redirects_compensate_to_override( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv( + "UIPATH_SERVICE_URL_AGENTICGOVERNANCE", "http://localhost:8123" + ) + httpx_mock.add_response( + url="http://localhost:8123/api/v1/runtime/govern", + method="POST", + status_code=200, + json={}, + ) + + service.compensate(**_compensate_kwargs()) + + sent = httpx_mock.get_requests()[-1] + assert sent.method == "POST" + assert sent.headers["X-UiPath-Internal-AccountId"] == ORG_ID + + +class TestResolveTraceId: + """Test the resolve_trace_id helper.""" + + def test_returns_fallback_when_no_source_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + + assert resolve_trace_id(fallback="fallback-id") == "fallback-id" + + def test_returns_none_when_no_fallback( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + + assert resolve_trace_id() is None + + def test_reads_uipath_trace_id_in_hex_form( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_TRACE_ID", "0123456789abcdef0123456789abcdef") + + assert resolve_trace_id() == "0123456789abcdef0123456789abcdef" + + def test_normalizes_uipath_trace_id_in_uuid_form( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_TRACE_ID", TENANT_ID) + + assert resolve_trace_id() == TENANT_ID_HEX + + def test_falls_through_when_uipath_trace_id_is_malformed( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_TRACE_ID", "not-a-valid-trace-id") + + # No OTel context active → falls through to caller-supplied fallback. + assert resolve_trace_id(fallback="recovered") == "recovered" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index df0dc786a..9f696bfe4 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1063,7 +1063,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.20" +version = "0.5.21" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.71" +version = "0.1.73" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 827befcfb..1bf92e0e1 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -5,9 +5,9 @@ description = "Python SDK and CLI for UiPath Platform, enabling programmatic int readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath-core>=0.5.17, <0.6.0", + "uipath-core>=0.5.21, <0.6.0", "uipath-runtime>=0.11.0, <0.12.0", - "uipath-platform>=0.1.68, <0.2.0", + "uipath-platform>=0.1.73, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 15873d997..94b6b38fb 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-06-20T16:42:14.097008Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2659,7 +2659,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.20" +version = "0.5.21" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.71" +version = "0.1.73" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 9038bc6be05709259937c271324d747e86d980a9 Mon Sep 17 00:00:00 2001 From: Radu Mihai Gheorghe Date: Tue, 23 Jun 2026 18:32:05 +0300 Subject: [PATCH 117/191] fix(agent-models): add a2aUrl to AgentA2aResourceConfig (#1743) --- packages/uipath/pyproject.toml | 2 +- packages/uipath/src/uipath/agent/models/agent.py | 1 + packages/uipath/tests/agent/models/test_agent.py | 5 +++++ packages/uipath/uv.lock | 4 ++-- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 1bf92e0e1..573dba7bb 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.11.9" +version = "2.11.10" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 916417c94..719a698aa 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -544,6 +544,7 @@ class AgentA2aResourceConfig(BaseAgentResourceConfig): id: str slug: str = Field(..., alias="slug") folder_path: str = Field(alias="folderPath") + a2a_url: str = Field(..., alias="a2aUrl") cached_agent_card: Optional[Dict[str, Any]] = Field( default=None, alias="cachedAgentCard" ) diff --git a/packages/uipath/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py index 202962c64..0d99dad5a 100644 --- a/packages/uipath/tests/agent/models/test_agent.py +++ b/packages/uipath/tests/agent/models/test_agent.py @@ -3995,6 +3995,7 @@ def test_a2a_resource(self): "slug": "philosopher-agent", "description": "A philosophical agent that answers questions with wisdom and philosopher quotes", "folderPath": "shared", + "a2a_url": "https://cloud.uipath.com/a2a/5045dca3", "cachedAgentCard": { "name": "Philosopher Agent", "description": "Philosopher Agent assistant", @@ -4070,6 +4071,7 @@ def test_a2a_resource(self): ) assert a2a_resource.id == "755e2f7d-5a3d-47f3-8e9d-7ff0bf226357" assert a2a_resource.folder_path == "shared" + assert a2a_resource.a2a_url == "https://cloud.uipath.com/a2a/5045dca3" # Validate cached agent card is a plain dict card = a2a_resource.cached_agent_card @@ -4108,6 +4110,7 @@ def test_a2a_resource_without_cached_card(self): "slug": "minimal-a2a", "description": "A minimal A2A agent", "folderPath": "shared", + "a2a_url": "https://cloud.uipath.com/a2a/abc-123", } ], "features": [], @@ -4127,6 +4130,7 @@ def test_a2a_resource_without_cached_card(self): assert a2a_resource.name == "Minimal A2A Agent" assert a2a_resource.slug == "minimal-a2a" assert a2a_resource.folder_path == "shared" + assert a2a_resource.a2a_url == "https://cloud.uipath.com/a2a/abc-123" assert a2a_resource.cached_agent_card is None def test_a2a_resource_case_insensitive(self): @@ -4154,6 +4158,7 @@ def test_a2a_resource_case_insensitive(self): "slug": "case-test", "description": "Testing case insensitive parsing", "folderPath": "shared", + "a2a_url": "https://cloud.uipath.com/a2a/case-test-id", } ], "features": [], diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 94b6b38fb..be6c3b203 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-06-21T14:10:18.2328398Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.9" +version = "2.11.10" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 7dec553e614be9e60ead63a0780422dcd0333640 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Tue, 23 Jun 2026 19:17:45 +0300 Subject: [PATCH 118/191] feat(core): add workspace hydration service protocols (#1744) --- packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/workspace/__init__.py | 8 +++ .../src/uipath/core/workspace/protocols.py | 56 +++++++++++++++ .../uipath-core/tests/workspace/__init__.py | 0 .../tests/workspace/test_protocols.py | 68 +++++++++++++++++++ packages/uipath-core/uv.lock | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 8 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 packages/uipath-core/src/uipath/core/workspace/__init__.py create mode 100644 packages/uipath-core/src/uipath/core/workspace/protocols.py create mode 100644 packages/uipath-core/tests/workspace/__init__.py create mode 100644 packages/uipath-core/tests/workspace/test_protocols.py diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 56e0c4d4f..473d485a3 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.21" +version = "0.5.22" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/workspace/__init__.py b/packages/uipath-core/src/uipath/core/workspace/__init__.py new file mode 100644 index 000000000..c05992aae --- /dev/null +++ b/packages/uipath-core/src/uipath/core/workspace/__init__.py @@ -0,0 +1,8 @@ +"""UiPath workspace hydration shared contracts.""" + +from .protocols import AttachmentsProtocol, JobsProtocol + +__all__ = [ + "AttachmentsProtocol", + "JobsProtocol", +] diff --git a/packages/uipath-core/src/uipath/core/workspace/protocols.py b/packages/uipath-core/src/uipath/core/workspace/protocols.py new file mode 100644 index 000000000..c3f39c170 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/workspace/protocols.py @@ -0,0 +1,56 @@ +"""Service protocols for workspace hydration backend interactions.""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable +from uuid import UUID + + +@runtime_checkable +class AttachmentsProtocol(Protocol): + """Subset of the UiPath attachments service used by workspace hydration.""" + + async def download_async( + self, + *, + key: UUID, + destination_path: str, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> str: + """Download an attachment to a local path.""" + + async def upload_async( + self, + *, + name: str, + content: str | bytes | None = None, + source_path: str | None = None, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> UUID: + """Upload content or a local file and return its attachment key.""" + + +@runtime_checkable +class JobsProtocol(Protocol): + """Subset of the UiPath jobs service used by workspace hydration.""" + + async def list_attachments_async( + self, + *, + job_key: UUID, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> list[str]: + """List the attachment ids linked to a job.""" + + async def link_attachment_async( + self, + *, + job_key: UUID, + attachment_key: UUID, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> None: + """Link an existing attachment to a job.""" diff --git a/packages/uipath-core/tests/workspace/__init__.py b/packages/uipath-core/tests/workspace/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/uipath-core/tests/workspace/test_protocols.py b/packages/uipath-core/tests/workspace/test_protocols.py new file mode 100644 index 000000000..0c9cd11c4 --- /dev/null +++ b/packages/uipath-core/tests/workspace/test_protocols.py @@ -0,0 +1,68 @@ +"""Structural-conformance tests for the workspace hydration protocols.""" + +from __future__ import annotations + +from uuid import UUID, uuid4 + +from uipath.core.workspace import AttachmentsProtocol, JobsProtocol + + +class _FakeAttachments: + async def download_async( + self, + *, + key: UUID, + destination_path: str, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> str: + return destination_path + + async def upload_async( + self, + *, + name: str, + content: str | bytes | None = None, + source_path: str | None = None, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> UUID: + return uuid4() + + +class _FakeJobs: + async def list_attachments_async( + self, + *, + job_key: UUID, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> list[str]: + return [] + + async def link_attachment_async( + self, + *, + job_key: UUID, + attachment_key: UUID, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> None: + return None + + +class _NotAService: + pass + + +def test_attachments_service_satisfies_protocol() -> None: + assert isinstance(_FakeAttachments(), AttachmentsProtocol) + + +def test_jobs_service_satisfies_protocol() -> None: + assert isinstance(_FakeJobs(), JobsProtocol) + + +def test_unrelated_object_does_not_satisfy_protocols() -> None: + assert not isinstance(_NotAService(), AttachmentsProtocol) + assert not isinstance(_NotAService(), JobsProtocol) diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index 005bf9018..cfb903454 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -1011,7 +1011,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.21" +version = "0.5.22" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 9f696bfe4..d6799b717 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1063,7 +1063,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.21" +version = "0.5.22" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index be6c3b203..e4a954bba 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2659,7 +2659,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.21" +version = "0.5.22" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, From 233f4e3d1d7f7acaaf2b6c2ffea906a3a9462806 Mon Sep 17 00:00:00 2001 From: Radu Mihai Gheorghe Date: Wed, 24 Jun 2026 16:24:16 +0300 Subject: [PATCH 119/191] feat(remote-a2a): resolve a2a agent via resource-override bindings (#1748) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/agenthub/_remote_a2a_service.py | 3 +++ packages/uipath-platform/uv.lock | 4 ++-- packages/uipath/pyproject.toml | 4 ++-- packages/uipath/src/uipath/agent/models/agent.py | 1 - packages/uipath/tests/agent/models/test_agent.py | 5 ----- packages/uipath/uv.lock | 6 +++--- 7 files changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index b8effe0f0..868a66130 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.73" +version = "0.1.74" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/agenthub/_remote_a2a_service.py b/packages/uipath-platform/src/uipath/platform/agenthub/_remote_a2a_service.py index c9475976c..2caf3f2f0 100644 --- a/packages/uipath-platform/src/uipath/platform/agenthub/_remote_a2a_service.py +++ b/packages/uipath-platform/src/uipath/platform/agenthub/_remote_a2a_service.py @@ -9,6 +9,7 @@ from typing import Any, List from ..common._base_service import BaseService +from ..common._bindings import resource_override from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import FolderContext, header_folder @@ -149,6 +150,7 @@ async def main(): data = response.json() return [RemoteA2aAgent.model_validate(agent) for agent in data.get("value", [])] + @resource_override(resource_type="remoteA2aAgent", resource_identifier="slug") def retrieve( self, slug: str, @@ -190,6 +192,7 @@ def retrieve( ) return RemoteA2aAgent.model_validate(response.json()) + @resource_override(resource_type="remoteA2aAgent", resource_identifier="slug") async def retrieve_async( self, slug: str, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index d6799b717..1cb0ed18b 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-06-22T12:09:32.9206897Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.73" +version = "0.1.74" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 573dba7bb..434af1c00 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.11.10" +version = "2.11.11" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.21, <0.6.0", "uipath-runtime>=0.11.0, <0.12.0", - "uipath-platform>=0.1.73, <0.2.0", + "uipath-platform>=0.1.74, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 719a698aa..916417c94 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -544,7 +544,6 @@ class AgentA2aResourceConfig(BaseAgentResourceConfig): id: str slug: str = Field(..., alias="slug") folder_path: str = Field(alias="folderPath") - a2a_url: str = Field(..., alias="a2aUrl") cached_agent_card: Optional[Dict[str, Any]] = Field( default=None, alias="cachedAgentCard" ) diff --git a/packages/uipath/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py index 0d99dad5a..202962c64 100644 --- a/packages/uipath/tests/agent/models/test_agent.py +++ b/packages/uipath/tests/agent/models/test_agent.py @@ -3995,7 +3995,6 @@ def test_a2a_resource(self): "slug": "philosopher-agent", "description": "A philosophical agent that answers questions with wisdom and philosopher quotes", "folderPath": "shared", - "a2a_url": "https://cloud.uipath.com/a2a/5045dca3", "cachedAgentCard": { "name": "Philosopher Agent", "description": "Philosopher Agent assistant", @@ -4071,7 +4070,6 @@ def test_a2a_resource(self): ) assert a2a_resource.id == "755e2f7d-5a3d-47f3-8e9d-7ff0bf226357" assert a2a_resource.folder_path == "shared" - assert a2a_resource.a2a_url == "https://cloud.uipath.com/a2a/5045dca3" # Validate cached agent card is a plain dict card = a2a_resource.cached_agent_card @@ -4110,7 +4108,6 @@ def test_a2a_resource_without_cached_card(self): "slug": "minimal-a2a", "description": "A minimal A2A agent", "folderPath": "shared", - "a2a_url": "https://cloud.uipath.com/a2a/abc-123", } ], "features": [], @@ -4130,7 +4127,6 @@ def test_a2a_resource_without_cached_card(self): assert a2a_resource.name == "Minimal A2A Agent" assert a2a_resource.slug == "minimal-a2a" assert a2a_resource.folder_path == "shared" - assert a2a_resource.a2a_url == "https://cloud.uipath.com/a2a/abc-123" assert a2a_resource.cached_agent_card is None def test_a2a_resource_case_insensitive(self): @@ -4158,7 +4154,6 @@ def test_a2a_resource_case_insensitive(self): "slug": "case-test", "description": "Testing case insensitive parsing", "folderPath": "shared", - "a2a_url": "https://cloud.uipath.com/a2a/case-test-id", } ], "features": [], diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index e4a954bba..e519d3074 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-06-21T14:10:18.2328398Z" +exclude-newer = "2026-06-22T12:09:29.4029891Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.10" +version = "2.11.11" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.73" +version = "0.1.74" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 3d0cfdc939e6a6fc59f1c31c6d2da839649406f7 Mon Sep 17 00:00:00 2001 From: Radu Mihai Gheorghe Date: Wed, 24 Jun 2026 17:51:00 +0300 Subject: [PATCH 120/191] fix(remote-a2a): fall back to folder context when folder_path is missing (#1749) --- packages/uipath-platform/pyproject.toml | 2 +- .../platform/agenthub/_remote_a2a_service.py | 9 ++- .../tests/services/test_remote_a2a_service.py | 55 +++++++++++++++++++ packages/uipath-platform/uv.lock | 4 +- packages/uipath/pyproject.toml | 4 +- packages/uipath/uv.lock | 6 +- 6 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 packages/uipath-platform/tests/services/test_remote_a2a_service.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 868a66130..eec99ca30 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.74" +version = "0.1.75" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/agenthub/_remote_a2a_service.py b/packages/uipath-platform/src/uipath/platform/agenthub/_remote_a2a_service.py index 2caf3f2f0..c8993e3ee 100644 --- a/packages/uipath-platform/src/uipath/platform/agenthub/_remote_a2a_service.py +++ b/packages/uipath-platform/src/uipath/platform/agenthub/_remote_a2a_service.py @@ -242,6 +242,13 @@ async def main(): def custom_headers(self) -> dict[str, str]: return self.folder_headers + def _resolve_folder_key(self, folder_path: str | None) -> str | None: + """Resolve folder key from folder_path, falling back to FolderContext.""" + if folder_path is not None: + return self._folders_service.retrieve_folder_key(folder_path) + + return self._folder_key + def _list_spec( self, *, @@ -276,7 +283,7 @@ def _retrieve_spec( *, folder_path: str | None, ) -> RequestSpec: - folder_key = self._folders_service.retrieve_folder_key(folder_path) + folder_key = self._resolve_folder_key(folder_path) return RequestSpec( method="GET", endpoint=Endpoint(f"/agenthub_/api/remote-a2a-agents/{slug}"), diff --git a/packages/uipath-platform/tests/services/test_remote_a2a_service.py b/packages/uipath-platform/tests/services/test_remote_a2a_service.py new file mode 100644 index 000000000..1f4239d4d --- /dev/null +++ b/packages/uipath-platform/tests/services/test_remote_a2a_service.py @@ -0,0 +1,55 @@ +import pytest + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.agenthub._remote_a2a_service import RemoteA2aService +from uipath.platform.common.constants import HEADER_FOLDER_KEY +from uipath.platform.orchestrator._folder_service import FolderService + + +@pytest.fixture +def folders_service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, +) -> FolderService: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "context-folder-key") + return FolderService(config=config, execution_context=execution_context) + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: FolderService, + monkeypatch: pytest.MonkeyPatch, +) -> RemoteA2aService: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "context-folder-key") + return RemoteA2aService( + config=config, + execution_context=execution_context, + folders_service=folders_service, + ) + + +class TestRetrieveSpecFolderResolution: + def test_falls_back_to_folder_context_when_folder_path_missing( + self, service: RemoteA2aService + ) -> None: + """No folder_path (e.g. local debug) must not raise; it falls back to context.""" + spec = service._retrieve_spec(slug="weather", folder_path=None) + + assert "remote-a2a-agents/weather" in str(spec.endpoint) + assert spec.headers[HEADER_FOLDER_KEY] == "context-folder-key" + + def test_resolves_explicit_folder_path( + self, service: RemoteA2aService, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr( + service._folders_service, + "retrieve_folder_key", + lambda folder_path: "resolved-folder-key", + ) + + spec = service._retrieve_spec(slug="weather", folder_path="MyFolder") + + assert spec.headers[HEADER_FOLDER_KEY] == "resolved-folder-key" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 1cb0ed18b..a9a4d2abc 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-06-22T12:09:32.9206897Z" +exclude-newer = "2026-06-22T13:55:56.0776194Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.74" +version = "0.1.75" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 434af1c00..fc45cd4be 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.11.11" +version = "2.11.12" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.21, <0.6.0", "uipath-runtime>=0.11.0, <0.12.0", - "uipath-platform>=0.1.74, <0.2.0", + "uipath-platform>=0.1.75, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index e519d3074..b4b11488e 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-06-22T12:09:29.4029891Z" +exclude-newer = "2026-06-22T13:56:19.8527915Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.11" +version = "2.11.12" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.74" +version = "0.1.75" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From ab8b7722dbb104e87f33576ac0ef4000a2fb8615 Mon Sep 17 00:00:00 2001 From: ctiliescuuipath Date: Wed, 24 Jun 2026 18:41:28 +0300 Subject: [PATCH 121/191] feat(platform): add guardrailName to guardrails validation payload (#1746) Co-authored-by: Claude Opus 4.6 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/guardrails/_guardrails_service.py | 1 + .../tests/services/test_guardrails_service.py | 5 ++++- packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 2 +- packages/uipath/uv.lock | 2 +- 6 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index eec99ca30..cfe85a61e 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.75" +version = "0.1.76" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py index 424b35dad..86856a6b4 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py @@ -116,6 +116,7 @@ def evaluate_guardrail( "validator": guardrail.validator_type, "input": input_data if isinstance(input_data, str) else str(input_data), "parameters": parameters, + "guardrailName": guardrail.name, } spec = RequestSpec( method="POST", diff --git a/packages/uipath-platform/tests/services/test_guardrails_service.py b/packages/uipath-platform/tests/services/test_guardrails_service.py index dd20d5646..e9d73a06f 100644 --- a/packages/uipath-platform/tests/services/test_guardrails_service.py +++ b/packages/uipath-platform/tests/services/test_guardrails_service.py @@ -262,15 +262,18 @@ def capture_request(request): # Parse the request payload request_payload = json.loads(captured_request.content) - # Verify the payload structure matches the reverted format: + # Verify the payload structure: # { # "validator": guardrail.validator_type, # "input": input_data, # "parameters": parameters, + # "guardrailName": guardrail.name, # } assert "validator" in request_payload assert "input" in request_payload assert "parameters" in request_payload + assert "guardrailName" in request_payload + assert request_payload["guardrailName"] == "PII detection guardrail" # Verify validator is a string (not an object) assert isinstance(request_payload["validator"], str) diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index a9a4d2abc..2c3e5d025 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.75" +version = "0.1.76" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index fc45cd4be..7acd8465d 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.21, <0.6.0", "uipath-runtime>=0.11.0, <0.12.0", - "uipath-platform>=0.1.75, <0.2.0", + "uipath-platform>=0.1.76, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index b4b11488e..989e4b5ea 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.75" +version = "0.1.76" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 8cfb8226215665f55485769b41a2a8e27e5ff3df Mon Sep 17 00:00:00 2001 From: Popescu Tudor-Cristian <94108303+PopescuTudor@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:31:57 +0300 Subject: [PATCH 122/191] fix(platform): add Swagger (type 7) to McpServerType (#1762) Co-authored-by: Claude Opus 4.8 --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/orchestrator/mcp.py | 1 + .../tests/services/test_mcp_service.py | 18 ++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 5 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index cfe85a61e..e4da0a0ce 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.76" +version = "0.1.77" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/mcp.py b/packages/uipath-platform/src/uipath/platform/orchestrator/mcp.py index dd96353a0..ca2050da3 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/mcp.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/mcp.py @@ -18,6 +18,7 @@ class McpServerType(IntEnum): Remote = 4 # HTTP connection to remote MCP server ProcessAssistant = 5 # Dynamic user process assistant Platform = 6 # Platform MCP server (e.g: Orchestrator, TestManager) + Swagger = 7 # User-provided Swagger/OpenAPI spec exposed as MCP server class McpServerStatus(IntEnum): diff --git a/packages/uipath-platform/tests/services/test_mcp_service.py b/packages/uipath-platform/tests/services/test_mcp_service.py index fdc5d8ee1..d40082610 100644 --- a/packages/uipath-platform/tests/services/test_mcp_service.py +++ b/packages/uipath-platform/tests/services/test_mcp_service.py @@ -551,3 +551,21 @@ async def test_retrieve_async_passes_all_kwargs( call_kwargs.kwargs["headers"][HEADER_FOLDER_KEY] == "test-folder-key" ) + + +class TestMcpServerType: + """Tests for the McpServerType enum and McpServer validation.""" + + def test_swagger_type_value(self) -> None: + from uipath.platform.orchestrator.mcp import McpServerType + + assert McpServerType.Swagger == 7 + + def test_validate_swagger_server(self) -> None: + """A Swagger (type=7) server must validate — regression for backend + server types newer than the SDK's enum.""" + server = McpServer.model_validate( + {"slug": "contoso-directory", "name": "Employee Directory", "type": 7} + ) + assert server.type == 7 + assert server.slug == "contoso-directory" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 2c3e5d025..19a00447a 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.76" +version = "0.1.77" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 989e4b5ea..0d745bb8f 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.76" +version = "0.1.77" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 84fbb52d558cbbd1906a0d17f707ee105a20c4de Mon Sep 17 00:00:00 2001 From: Valentina Bojan Date: Thu, 25 Jun 2026 17:38:23 +0300 Subject: [PATCH 123/191] feat(guardrails): send execution source and job key headers (#1739) Co-authored-by: Valentina Bojan Co-authored-by: Claude Opus 4.8 --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/common/__init__.py | 3 +- .../platform/common/_execution_context.py | 37 +++++++ .../src/uipath/platform/common/constants.py | 1 + .../guardrails/_guardrails_service.py | 18 +++- .../tests/common/test_execution_context.py | 26 +++++ .../tests/services/test_guardrails_service.py | 98 +++++++++++++++++++ packages/uipath-platform/uv.lock | 4 +- packages/uipath/pyproject.toml | 6 +- packages/uipath/src/uipath/_cli/cli_debug.py | 11 ++- packages/uipath/src/uipath/_cli/cli_dev.py | 38 +++---- packages/uipath/src/uipath/_cli/cli_eval.py | 11 ++- packages/uipath/src/uipath/_cli/cli_run.py | 8 +- packages/uipath/tests/cli/test_dev.py | 70 +++++++++++++ packages/uipath/uv.lock | 14 +-- 15 files changed, 306 insertions(+), 41 deletions(-) create mode 100644 packages/uipath-platform/tests/common/test_execution_context.py create mode 100644 packages/uipath/tests/cli/test_dev.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index e4da0a0ce..21c693e7b 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.77" +version = "0.1.78" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/common/__init__.py b/packages/uipath-platform/src/uipath/platform/common/__init__.py index 555d6901d..2407263ee 100644 --- a/packages/uipath-platform/src/uipath/platform/common/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/common/__init__.py @@ -16,7 +16,7 @@ ) from ._config import UiPathApiConfig, UiPathConfig from ._endpoints_manager import EndpointManager -from ._execution_context import UiPathExecutionContext +from ._execution_context import ExecutionSourceContext, UiPathExecutionContext from ._external_application_service import ExternalApplicationService from ._folder_context import FolderContext, header_folder from ._http_config import get_ca_bundle_path, get_httpx_client_kwargs @@ -61,6 +61,7 @@ "BaseService", "UiPathApiConfig", "UiPathExecutionContext", + "ExecutionSourceContext", "ExternalApplicationService", "FolderContext", "TokenData", diff --git a/packages/uipath-platform/src/uipath/platform/common/_execution_context.py b/packages/uipath-platform/src/uipath/platform/common/_execution_context.py index de54c0c99..184a626db 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_execution_context.py +++ b/packages/uipath-platform/src/uipath/platform/common/_execution_context.py @@ -1,7 +1,34 @@ +from contextvars import ContextVar, Token from os import environ as env from uipath.platform.common.constants import ENV_JOB_ID, ENV_JOB_KEY, ENV_ROBOT_KEY +_execution_source: ContextVar[str | None] = ContextVar("execution_source", default=None) + + +class ExecutionSourceContext: + """Scope the execution source for the duration of a run. + + Carries the source (e.g. ``runtime``/``playground``/``eval``) via a context + variable and releases it on exit so it stays correctly scoped in concurrent + runs. The CLI enters this with ``UiPathRuntimeContext.execution_source`` so + platform clients can read it via + :attr:`UiPathExecutionContext.execution_source`. + """ + + def __init__(self, execution_source: str | None) -> None: + self._execution_source = execution_source + self._token: Token[str | None] | None = None + + def __enter__(self) -> "ExecutionSourceContext": + self._token = _execution_source.set(self._execution_source) + return self + + def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None: + if self._token is not None: + _execution_source.reset(self._token) + self._token = None + class UiPathExecutionContext: """Manages the execution context for UiPath automation processes. @@ -76,3 +103,13 @@ def robot_key(self) -> str | None: raise ValueError(f"Robot key is not set ({ENV_ROBOT_KEY})") return self._robot_key + + @property + def execution_source(self) -> str | None: + """Get the execution source for the current run. + + Identifies the run context (e.g. ``runtime``/``playground``/``eval``), + derived from the CLI command and carried via + :class:`ExecutionSourceContext`. Returns ``None`` when not set. + """ + return _execution_source.get() diff --git a/packages/uipath-platform/src/uipath/platform/common/constants.py b/packages/uipath-platform/src/uipath/platform/common/constants.py index 304ef64a6..fa710993c 100644 --- a/packages/uipath-platform/src/uipath/platform/common/constants.py +++ b/packages/uipath-platform/src/uipath/platform/common/constants.py @@ -39,6 +39,7 @@ HEADER_PROCESS_KEY = "x-uipath-processkey" HEADER_TRACE_ID = "x-uipath-traceid" HEADER_AGENTHUB_CONFIG = "x-uipath-agenthub-config" +HEADER_GUARDRAILS_SOURCE = "x-uipath-guardrails-source" HEADER_LLMGATEWAY_BYO_CONNECTION_ID = "x-uipath-llmgateway-byoisconnectionid" HEADER_SW_LOCK_KEY = "x-uipath-sw-lockkey" HEADER_LICENSING_CONTEXT = "x-uipath-licensing-context" diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py index 86856a6b4..5082843ab 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py @@ -12,7 +12,9 @@ from ..common._base_service import BaseService from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext +from ..common._job_context import header_job_key from ..common._models import Endpoint, RequestSpec +from ..common.constants import HEADER_GUARDRAILS_SOURCE from ..errors import EnrichedException from .guardrails import BuiltInValidatorGuardrail @@ -123,9 +125,21 @@ def evaluate_guardrail( endpoint=Endpoint("/agentsruntime_/api/execution/guardrails/validate"), json=payload, ) - # Include trace context headers for server-side span correlation + # Include trace context headers for server-side span correlation, plus + # the execution source (x-uipath-guardrails-source) and job key headers + # for licensing/metering correlation. The execution source is read from + # the execution context, propagated from the runtime context. trace_headers = build_trace_context_headers() - request_headers = {**(spec.headers or {}), **trace_headers} + source_headers: dict[str, str] = {} + execution_source = self._execution_context.execution_source + if execution_source: + source_headers[HEADER_GUARDRAILS_SOURCE] = execution_source + request_headers = { + **(spec.headers or {}), + **trace_headers, + **source_headers, + **header_job_key(), + } span_id = None try: response = self.request( diff --git a/packages/uipath-platform/tests/common/test_execution_context.py b/packages/uipath-platform/tests/common/test_execution_context.py new file mode 100644 index 000000000..25b43c9b2 --- /dev/null +++ b/packages/uipath-platform/tests/common/test_execution_context.py @@ -0,0 +1,26 @@ +from uipath.platform.common import ExecutionSourceContext, UiPathExecutionContext + + +def test_execution_source_none_by_default() -> None: + assert UiPathExecutionContext().execution_source is None + + +def test_execution_source_set_within_context() -> None: + ctx = UiPathExecutionContext() + + with ExecutionSourceContext("runtime"): + assert ctx.execution_source == "runtime" + + assert ctx.execution_source is None + + +def test_execution_source_context_restores_previous_value() -> None: + ctx = UiPathExecutionContext() + + with ExecutionSourceContext("eval"): + assert ctx.execution_source == "eval" + with ExecutionSourceContext("playground"): + assert ctx.execution_source == "playground" + assert ctx.execution_source == "eval" + + assert ctx.execution_source is None diff --git a/packages/uipath-platform/tests/services/test_guardrails_service.py b/packages/uipath-platform/tests/services/test_guardrails_service.py index e9d73a06f..87fb7bb8c 100644 --- a/packages/uipath-platform/tests/services/test_guardrails_service.py +++ b/packages/uipath-platform/tests/services/test_guardrails_service.py @@ -10,6 +10,7 @@ ) from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.common import ExecutionSourceContext from uipath.platform.guardrails import ( BuiltInValidatorGuardrail, EnumListParameterValue, @@ -356,6 +357,103 @@ def capture_request(request): # header merging works even when no active span exists) assert "content-type" in headers + def test_evaluate_guardrail_sends_source_and_job_key_headers( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Outgoing request includes execution source and job key headers.""" + monkeypatch.setenv("UIPATH_JOB_KEY", "job-123") + + captured_request = None + + def capture_request(request): + nonlocal captured_request + captured_request = request + return httpx.Response( + status_code=200, + json={"result": "PASSED", "details": "OK"}, + ) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", + callback=capture_request, + ) + + pii_guardrail = BuiltInValidatorGuardrail( + id="test-id", + name="PII guardrail", + description="Test", + enabled_for_evals=True, + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["tool1"] + ), + guardrail_type="builtInValidator", + validator_type="pii_detection", + validator_parameters=[], + ) + + with ExecutionSourceContext("runtime"): + service.evaluate_guardrail("test input", pii_guardrail) + + assert captured_request is not None + headers = dict(captured_request.headers) + assert headers.get("x-uipath-guardrails-source") == "runtime" + assert headers.get("x-uipath-jobkey") == "job-123" + + def test_evaluate_guardrail_omits_source_and_job_key_when_unset( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Source/job key headers are absent when unset.""" + monkeypatch.delenv("UIPATH_JOB_KEY", raising=False) + + captured_request = None + + def capture_request(request): + nonlocal captured_request + captured_request = request + return httpx.Response( + status_code=200, + json={"result": "PASSED", "details": "OK"}, + ) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", + callback=capture_request, + ) + + pii_guardrail = BuiltInValidatorGuardrail( + id="test-id", + name="PII guardrail", + description="Test", + enabled_for_evals=True, + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["tool1"] + ), + guardrail_type="builtInValidator", + validator_type="pii_detection", + validator_parameters=[], + ) + + service.evaluate_guardrail("test input", pii_guardrail) + + assert captured_request is not None + headers = dict(captured_request.headers) + assert "x-uipath-guardrails-source" not in headers + assert "x-uipath-jobkey" not in headers + def test_evaluate_guardrail_extracts_span_id_from_traceparent( self, httpx_mock: HTTPXMock, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 19a00447a..0f0f36b5a 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-06-22T13:55:56.0776194Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.77" +version = "0.1.78" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 7acd8465d..62bb9a5e1 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.11.12" +version = "2.11.13" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.21, <0.6.0", - "uipath-runtime>=0.11.0, <0.12.0", - "uipath-platform>=0.1.76, <0.2.0", + "uipath-runtime>=0.11.4, <0.12.0", + "uipath-platform>=0.1.78, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/src/uipath/_cli/cli_debug.py b/packages/uipath/src/uipath/_cli/cli_debug.py index 92b8ea454..1e2df4770 100644 --- a/packages/uipath/src/uipath/_cli/cli_debug.py +++ b/packages/uipath/src/uipath/_cli/cli_debug.py @@ -11,7 +11,11 @@ from uipath.core.tracing import UiPathTraceManager from uipath.eval.mocks import UiPathMockRuntime from uipath.eval.mocks._mock_runtime import load_simulation_config -from uipath.platform.common import ResourceOverwritesContext, UiPathConfig +from uipath.platform.common import ( + ExecutionSourceContext, + ResourceOverwritesContext, + UiPathConfig, +) from uipath.runtime import ( UiPathExecuteOptions, UiPathRuntimeContext, @@ -122,14 +126,15 @@ def debug( async def execute_debug_runtime(): trace_manager = UiPathTraceManager() - with UiPathRuntimeContext.with_defaults( + ctx = UiPathRuntimeContext.with_defaults( input=input, input_file=input_file, output_file=output_file, resume=resume, trace_manager=trace_manager, command="debug", - ) as ctx: + ) + with ExecutionSourceContext(ctx.execution_source), ctx: factory: UiPathRuntimeFactoryProtocol | None = None try: diff --git a/packages/uipath/src/uipath/_cli/cli_dev.py b/packages/uipath/src/uipath/_cli/cli_dev.py index 62740dc4b..f6d7021bb 100644 --- a/packages/uipath/src/uipath/_cli/cli_dev.py +++ b/packages/uipath/src/uipath/_cli/cli_dev.py @@ -7,6 +7,7 @@ from uipath._cli._utils._debug import setup_debugging from uipath._cli.middlewares import Middlewares from uipath.core.tracing import UiPathTraceManager +from uipath.platform.common import ExecutionSourceContext from uipath.runtime import UiPathRuntimeContext, UiPathRuntimeFactoryRegistry from ._telemetry import track_command @@ -14,6 +15,12 @@ console = ConsoleLogger() +def _create_dev_context_and_factory(trace_manager: UiPathTraceManager): + """Build the dev runtime context and its factory.""" + context = UiPathRuntimeContext(trace_manager=trace_manager, command="dev") + return context, UiPathRuntimeFactoryRegistry.get(context=context) + + def _check_dev_dependency(interface: str) -> None: """Check if uipath-dev is installed and raise helpful error if not.""" import importlib.util @@ -80,17 +87,14 @@ async def run_terminal() -> None: factory = None try: trace_manager = UiPathTraceManager() - factory = UiPathRuntimeFactoryRegistry.get( - context=UiPathRuntimeContext( - trace_manager=trace_manager, command="dev" - ) - ) + context, factory = _create_dev_context_and_factory(trace_manager) app = UiPathDeveloperConsole( runtime_factory=factory, trace_manager=trace_manager ) - await app.run_async() + with ExecutionSourceContext(context.execution_source): + await app.run_async() except KeyboardInterrupt: console.info("Debug session interrupted by user") @@ -124,11 +128,7 @@ def signal_handler(sig, frame): try: trace_manager = UiPathTraceManager() - factory = UiPathRuntimeFactoryRegistry.get( - context=UiPathRuntimeContext( - trace_manager=trace_manager, command="dev" - ) - ) + context, factory = _create_dev_context_and_factory(trace_manager) app = UiPathDeveloperServer( runtime_factory=factory, @@ -140,13 +140,17 @@ def signal_handler(sig, frame): ), ) - server_task = asyncio.create_task(app.run_async()) - shutdown_task = asyncio.create_task(shutdown_event.wait()) + # Enter the execution source context before creating the server + # task so request tasks spawned during the run inherit it. + with ExecutionSourceContext(context.execution_source): + server_task = asyncio.create_task(app.run_async()) + shutdown_task = asyncio.create_task(shutdown_event.wait()) - # Wait for either server to complete or shutdown signal - done, pending = await asyncio.wait( - {server_task, shutdown_task}, return_when=asyncio.FIRST_COMPLETED - ) + # Wait for either server to complete or shutdown signal + done, pending = await asyncio.wait( + {server_task, shutdown_task}, + return_when=asyncio.FIRST_COMPLETED, + ) for task in pending: task.cancel() diff --git a/packages/uipath/src/uipath/_cli/cli_eval.py b/packages/uipath/src/uipath/_cli/cli_eval.py index e101717d6..86e028c67 100644 --- a/packages/uipath/src/uipath/_cli/cli_eval.py +++ b/packages/uipath/src/uipath/_cli/cli_eval.py @@ -21,7 +21,11 @@ from uipath.eval.models.evaluation_set import EvaluationSet from uipath.eval.runtime import UiPathEvalContext, evaluate from uipath.platform.chat import set_llm_concurrency -from uipath.platform.common import ResourceOverwritesContext, UiPathConfig +from uipath.platform.common import ( + ExecutionSourceContext, + ResourceOverwritesContext, + UiPathConfig, +) from uipath.runtime import ( UiPathRuntimeContext, UiPathRuntimeFactoryRegistry, @@ -309,12 +313,13 @@ async def execute_eval(): trace_manager = UiPathTraceManager() - with UiPathRuntimeContext.with_defaults( + ctx = UiPathRuntimeContext.with_defaults( output_file=output_file, trace_manager=trace_manager, command="eval", resume=resume, - ) as ctx: + ) + with ExecutionSourceContext(ctx.execution_source), ctx: # Set job_id in eval context for single runtime runs eval_context.job_id = ctx.job_id diff --git a/packages/uipath/src/uipath/_cli/cli_run.py b/packages/uipath/src/uipath/_cli/cli_run.py index 48f42018b..9a8571d03 100644 --- a/packages/uipath/src/uipath/_cli/cli_run.py +++ b/packages/uipath/src/uipath/_cli/cli_run.py @@ -9,7 +9,11 @@ from uipath._cli._utils._debug import setup_debugging from uipath.core.tracing import UiPathTraceManager from uipath.eval.mocks import SimulationConfig, UiPathMockRuntime, build_mocking_context -from uipath.platform.common import ResourceOverwritesContext, UiPathConfig +from uipath.platform.common import ( + ExecutionSourceContext, + ResourceOverwritesContext, + UiPathConfig, +) from uipath.runtime import ( UiPathExecuteOptions, UiPathRuntimeFactoryProtocol, @@ -209,7 +213,7 @@ async def execute() -> None: async with ResourceOverwritesContext( lambda: read_resource_overwrites_from_file(ctx.runtime_dir) ): - with ctx: + with ExecutionSourceContext(ctx.execution_source), ctx: base_runtime: UiPathRuntimeProtocol | None = None runtime: UiPathRuntimeProtocol | None = None chat_runtime: UiPathRuntimeProtocol | None = None diff --git a/packages/uipath/tests/cli/test_dev.py b/packages/uipath/tests/cli/test_dev.py new file mode 100644 index 000000000..8b2a93c04 --- /dev/null +++ b/packages/uipath/tests/cli/test_dev.py @@ -0,0 +1,70 @@ +import sys +import types +from unittest.mock import AsyncMock, MagicMock + +import pytest +from click.testing import CliRunner + +from uipath._cli import cli, cli_dev +from uipath._cli.middlewares import MiddlewareResult +from uipath.platform.common import UiPathExecutionContext + + +def test_create_dev_context_and_factory_uses_dev_command( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The helper builds a 'dev' context (source 'playground') and its factory.""" + sentinel_factory = MagicMock(name="factory") + captured: dict[str, object] = {} + + def fake_get(context: object) -> object: + captured["command"] = context.command # type: ignore[attr-defined] + return sentinel_factory + + monkeypatch.setattr( + "uipath._cli.cli_dev.UiPathRuntimeFactoryRegistry.get", fake_get + ) + + context, factory = cli_dev._create_dev_context_and_factory(None) # type: ignore[arg-type] + + assert factory is sentinel_factory + assert captured["command"] == "dev" + assert context.execution_source == "playground" + + +def test_dev_terminal_sets_execution_source_during_run( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Running `dev terminal` scopes the execution source to 'playground'.""" + seen: dict[str, object] = {} + + async def fake_run_async() -> None: + seen["source"] = UiPathExecutionContext().execution_source + + fake_console = MagicMock() + fake_console.run_async = fake_run_async + + fake_module = types.ModuleType("uipath.dev") + fake_module.UiPathDeveloperConsole = MagicMock(return_value=fake_console) # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "uipath.dev", fake_module) + + mock_factory = MagicMock() + mock_factory.dispose = AsyncMock() + + monkeypatch.setattr(cli_dev, "_check_dev_dependency", lambda interface: None) + monkeypatch.setattr(cli_dev, "setup_debugging", lambda debug, port: True) + monkeypatch.setattr( + "uipath._cli.cli_dev.Middlewares.next", + lambda *a, **k: MiddlewareResult(should_continue=True), + ) + monkeypatch.setattr( + "uipath._cli.cli_dev.UiPathRuntimeFactoryRegistry.get", + lambda context: mock_factory, + ) + + result = CliRunner().invoke(cli, ["dev", "terminal"]) + + assert result.exit_code == 0, result.output + assert seen["source"] == "playground" + # token released once the run completes + assert UiPathExecutionContext().execution_source is None diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 0d745bb8f..59e93a39d 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-06-22T13:56:19.8527915Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.12" +version = "2.11.13" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2625,7 +2625,7 @@ requires-dist = [ { name = "truststore", specifier = ">=0.10.1" }, { name = "uipath-core", editable = "../uipath-core" }, { name = "uipath-platform", editable = "../uipath-platform" }, - { name = "uipath-runtime", specifier = ">=0.11.0,<0.12.0" }, + { name = "uipath-runtime", specifier = ">=0.11.4,<0.12.0" }, ] [package.metadata.requires-dev] @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.77" +version = "0.1.78" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, @@ -2729,14 +2729,14 @@ dev = [ [[package]] name = "uipath-runtime" -version = "0.11.2" +version = "0.11.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/87/fed3a5bd3479b9e7dc6cba769b054f5f1c00e93762356a70010e32f1f03c/uipath_runtime-0.11.2.tar.gz", hash = "sha256:8b3cc986644d6c9f2365345c231577f97d3bad8fb105fe8a6c7e16508d00d9ef", size = 145770, upload-time = "2026-06-22T16:31:40.786Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/a4/944a7d6ef63aedea3592cdd73f2477b156562f3fb094ecf613117decbec5/uipath_runtime-0.11.4.tar.gz", hash = "sha256:7094b63f259249c763774d971ce0f3e611ca5abc160f2e4157e888b1690d9aa8", size = 152254, upload-time = "2026-06-24T14:26:07.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/62/c649c18ac39f53e5603abbcfa6917f6e880ac08047b1ce69d4c3ee937de8/uipath_runtime-0.11.2-py3-none-any.whl", hash = "sha256:a3a14dc2378bc934437bbd7523cf884d0cee0eeef26c736a9d6ce504d8a9fea0", size = 43874, upload-time = "2026-06-22T16:31:39.328Z" }, + { url = "https://files.pythonhosted.org/packages/c4/73/730469eb53fcb1bc6b05251323830bc6012993f8cc12b2bb302deeea5679/uipath_runtime-0.11.4-py3-none-any.whl", hash = "sha256:072a620a45584d745da7c7b0ab43d590768acfed846c1b860b15aaa8fb93071a", size = 49826, upload-time = "2026-06-24T14:26:05.831Z" }, ] [[package]] From 90489328f9d35a811e2589cd407903c5b4403e16 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Fri, 26 Jun 2026 17:24:23 +0300 Subject: [PATCH 124/191] fix(core): match AttachmentsProtocol.upload_async to the concrete overloads (#1765) --- packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/workspace/protocols.py | 24 ++++++++++++++++++- packages/uipath-core/uv.lock | 2 +- .../services/test_attachments_service.py | 14 +++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 6 files changed, 41 insertions(+), 5 deletions(-) diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 473d485a3..93be7ebc0 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.22" +version = "0.5.23" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/workspace/protocols.py b/packages/uipath-core/src/uipath/core/workspace/protocols.py index c3f39c170..c49c7bc59 100644 --- a/packages/uipath-core/src/uipath/core/workspace/protocols.py +++ b/packages/uipath-core/src/uipath/core/workspace/protocols.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Protocol, runtime_checkable +from typing import Protocol, overload, runtime_checkable from uuid import UUID @@ -20,6 +20,28 @@ async def download_async( ) -> str: """Download an attachment to a local path.""" + # Overloads mirror the concrete AttachmentsService: content XOR source_path, + # so the real service is a structural subtype of this protocol. + @overload + async def upload_async( + self, + *, + name: str, + content: str | bytes, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> UUID: ... + + @overload + async def upload_async( + self, + *, + name: str, + source_path: str, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> UUID: ... + async def upload_async( self, *, diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index cfb903454..67d937f3a 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -1011,7 +1011,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.22" +version = "0.5.23" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/tests/services/test_attachments_service.py b/packages/uipath-platform/tests/services/test_attachments_service.py index dfde1a304..0623858e2 100644 --- a/packages/uipath-platform/tests/services/test_attachments_service.py +++ b/packages/uipath-platform/tests/services/test_attachments_service.py @@ -1193,3 +1193,17 @@ async def test_open_async_write_mode( assert upload_request is not None assert upload_request.method == "PUT" assert upload_request.url == blob_uri_response["BlobFileAccess"]["Uri"] + + +def test_attachments_service_conforms_to_attachments_protocol( + service: AttachmentsService, +) -> None: + """AttachmentsService must satisfy AttachmentsProtocol (workspace hydration). + + The static annotation makes mypy verify the upload_async overloads line up; + the isinstance check covers the runtime_checkable contract. + """ + from uipath.core.workspace import AttachmentsProtocol + + conforming: AttachmentsProtocol = service + assert isinstance(conforming, AttachmentsProtocol) diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 0f0f36b5a..c0552fa41 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1063,7 +1063,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.22" +version = "0.5.23" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 59e93a39d..f99dd06a0 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2659,7 +2659,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.22" +version = "0.5.23" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, From aa844568148c564a044005020c4738cc033a9791 Mon Sep 17 00:00:00 2001 From: ctiliescuuipath Date: Mon, 29 Jun 2026 12:30:27 +0300 Subject: [PATCH 125/191] feat(platform): add source=agents baggage to guardrails trace headers (#1770) Co-authored-by: Claude Opus 4.6 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/guardrails/_guardrails_service.py | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 4 ++-- packages/uipath/uv.lock | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 21c693e7b..d46a6de4b 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.78" +version = "0.1.79" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py index 5082843ab..1d860b8d1 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py @@ -129,7 +129,7 @@ def evaluate_guardrail( # the execution source (x-uipath-guardrails-source) and job key headers # for licensing/metering correlation. The execution source is read from # the execution context, propagated from the runtime context. - trace_headers = build_trace_context_headers() + trace_headers = build_trace_context_headers(extra_baggage=["source=agents"]) source_headers: dict[str, str] = {} execution_source = self._execution_context.execution_source if execution_source: diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index c0552fa41..da9ec91d6 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.78" +version = "0.1.79" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 62bb9a5e1..37b64b956 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.11.13" +version = "2.11.14" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.21, <0.6.0", "uipath-runtime>=0.11.4, <0.12.0", - "uipath-platform>=0.1.78, <0.2.0", + "uipath-platform>=0.1.79, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index f99dd06a0..1a3771d8b 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.13" +version = "2.11.14" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.78" +version = "0.1.79" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 355f69086991ea8f597bb6b3ec6960a3997e3968 Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan <84002867+viswa-uipath@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:30:32 +0530 Subject: [PATCH 126/191] refactor(core): drop AdapterRegistry + BaseAdapter; keep EvaluatorProtocol (#1761) Co-authored-by: Claude Opus 4.7 (1M context) --- packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/adapters/__init__.py | 27 +- .../src/uipath/core/adapters/base.py | 116 ----- .../src/uipath/core/adapters/evaluator.py | 37 +- .../src/uipath/core/adapters/registry.py | 176 ------- .../src/uipath/core/governance/__init__.py | 11 +- .../src/uipath/core/governance/config.py | 8 +- .../src/uipath/core/governance/models.py | 15 +- .../src/uipath/core/governance/providers.py | 29 +- .../uipath-core/tests/adapters/test_base.py | 163 ------ .../tests/adapters/test_evaluator.py | 12 +- .../tests/adapters/test_registry.py | 492 ------------------ .../tests/governance/test_exceptions.py | 1 - .../tests/governance/test_providers.py | 23 + packages/uipath-core/uv.lock | 2 +- packages/uipath-platform/pyproject.toml | 4 +- .../governance/_governance_service.py | 58 ++- .../tests/services/test_governance_service.py | 93 ++++ packages/uipath-platform/uv.lock | 4 +- packages/uipath/uv.lock | 4 +- 20 files changed, 243 insertions(+), 1034 deletions(-) delete mode 100644 packages/uipath-core/src/uipath/core/adapters/base.py delete mode 100644 packages/uipath-core/src/uipath/core/adapters/registry.py delete mode 100644 packages/uipath-core/tests/adapters/test_base.py delete mode 100644 packages/uipath-core/tests/adapters/test_registry.py diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 93be7ebc0..22d665720 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.23" +version = "0.5.25" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/adapters/__init__.py b/packages/uipath-core/src/uipath/core/adapters/__init__.py index 5906b1b39..c3675b404 100644 --- a/packages/uipath-core/src/uipath/core/adapters/__init__.py +++ b/packages/uipath-core/src/uipath/core/adapters/__init__.py @@ -2,34 +2,19 @@ This package holds only the abstract contracts — concrete adapter implementations live in framework-specific plugin packages (e.g. -``uipath-langchain``, ``uipath-openai``) that target the framework they -integrate with. Plugin packages register their concrete adapters with -the global :class:`AdapterRegistry` via the -``uipath.governance.adapters`` entry-point group. +``uipath-langchain``, ``uipath-openai``). A framework plugin is the one +that knows its own native wiring seam (callback handler list, hook +registry, …) and installs governance there directly; uipath-core only +defines the protocol an evaluator must satisfy. Public surface: -- :class:`BaseAdapter` – abstract base every adapter inherits from. -- :class:`GovernedAgentBase` – proxy base for governed agent wrappers. -- :class:`EvaluatorProtocol` – structural protocol the adapter expects - from any policy evaluator. -- :class:`AdapterRegistry` – ordered list of adapters that resolves - the first match for a given agent. +- :class:`EvaluatorProtocol` – structural protocol the framework + plugin expects from any policy evaluator. """ -from .base import BaseAdapter, GovernedAgentBase from .evaluator import EvaluatorProtocol -from .registry import ( - AdapterRegistry, - get_adapter_registry, - reset_adapter_registry, -) __all__ = [ - "BaseAdapter", - "GovernedAgentBase", "EvaluatorProtocol", - "AdapterRegistry", - "get_adapter_registry", - "reset_adapter_registry", ] diff --git a/packages/uipath-core/src/uipath/core/adapters/base.py b/packages/uipath-core/src/uipath/core/adapters/base.py deleted file mode 100644 index 3afaad6a7..000000000 --- a/packages/uipath-core/src/uipath/core/adapters/base.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Base adapter contracts for framework-specific integrations. - -An adapter's job: - -1. Detect whether it can handle a given agent object. -2. Attach hooks to that agent (framework-specific). -3. Publish events to a policy evaluator when those hooks fire. - -The evaluator subscribes to events and runs policy checks; it never -knows or cares which adapter fired the event. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import Any -from uuid import uuid4 - -from .evaluator import EvaluatorProtocol - - -class BaseAdapter(ABC): - """Base class for framework-specific governance adapters.""" - - #: Higher value = more specific = inserted earlier in the registry. - #: Plugin authors should set this above ``0`` on adapters that target - #: a narrower agent type than another already-registered adapter, so - #: the specific one wins ``can_handle`` resolution regardless of the - #: order in which plugins happen to be imported. Among adapters with - #: the same priority, registration order is preserved (stable). - priority: int = 0 - - #: Set to True on a catch-all adapter that should always sort last in - #: the registry. The registry uses this flag (not the class name or - #: :attr:`priority`) to keep the fallback in last position when new - #: adapters register. - is_fallback: bool = False - - @property - def name(self) -> str: - """Return adapter name for logging.""" - return self.__class__.__name__ - - @abstractmethod - def can_handle(self, agent: Any) -> bool: - """Return True if this adapter knows how to hook into this agent type.""" - - @abstractmethod - def attach( - self, - agent: Any, - agent_id: str, - session_id: str, - evaluator: EvaluatorProtocol, - ) -> Any: - """Attach governance hooks to the agent. - - Args: - agent: The agent to govern. - agent_id: Unique identifier for the agent. - session_id: Session identifier for tracing. - evaluator: Policy evaluator implementing - :class:`EvaluatorProtocol`. - - Returns: - A governed proxy (or the original agent with hooks installed). - """ - - def detach(self, governed: Any) -> Any: - """Detach governance and return the original agent. - - Default implementation uses the public :attr:`GovernedAgentBase.unwrapped` - contract; non-proxy adapters that return the original agent from - :meth:`attach` get back ``governed`` unchanged. - """ - return getattr(governed, "unwrapped", governed) - - def _generate_trace_id(self) -> str: - """Generate a trace ID for governance events.""" - return str(uuid4()) - - -class GovernedAgentBase: - """Base class for governed agent proxies. - - Provides common functionality for all governed agents: - - - Stores reference to original agent - - Forwards unknown attributes to original agent - - Tracks governance metadata - """ - - def __init__( - self, - agent: Any, - adapter: BaseAdapter, - agent_id: str, - session_id: str, - evaluator: EvaluatorProtocol, - ) -> None: - """Initialize with the wrapped agent and governance metadata.""" - self._agent = agent - self._adapter = adapter - self._agent_id = agent_id - self._session_id = session_id - self._evaluator = evaluator - self._trace_id = adapter._generate_trace_id() - - @property - def unwrapped(self) -> Any: - """Get the original unwrapped agent.""" - return self._agent - - def __getattr__(self, name: str) -> Any: - """Forward attribute access to the original agent.""" - return getattr(self._agent, name) diff --git a/packages/uipath-core/src/uipath/core/adapters/evaluator.py b/packages/uipath-core/src/uipath/core/adapters/evaluator.py index ee5b92dad..4f25097c5 100644 --- a/packages/uipath-core/src/uipath/core/adapters/evaluator.py +++ b/packages/uipath-core/src/uipath/core/adapters/evaluator.py @@ -1,9 +1,9 @@ -"""Structural contract for the policy evaluator an adapter talks to. +"""Structural contract for the policy evaluator a framework plugin talks to. -Framework adapters call into a policy evaluator at each lifecycle hook. +Framework plugins call into a policy evaluator at each lifecycle hook. Concrete evaluator implementations (the native runtime evaluator, a Microsoft AGT bridge, a composite, …) live in packages outside -``uipath-core`` — adapters depend only on this structural protocol so +``uipath-core`` — plugins depend only on this structural protocol so they can be swapped against any of them without code change. ``EvaluatorProtocol`` is a :class:`typing.Protocol` so any class whose @@ -15,15 +15,18 @@ from typing import Any, Protocol, runtime_checkable +from uipath.core.governance.models import AuditRecord + @runtime_checkable class EvaluatorProtocol(Protocol): - """Structural protocol an adapter expects from a policy evaluator. + """Structural protocol a framework plugin expects from a policy evaluator. - Return types are intentionally :class:`typing.Any`: the concrete - audit record shape lives in the plugin package that owns the - evaluator and the policy model. Adapters in that package cast the - return value back to the concrete type they know. + Every ``evaluate_*`` method returns an :class:`AuditRecord` — the + per-hook audit envelope holding the per-rule + :class:`RuleEvaluation` list, the final action, and the trace / + agent metadata. Callers get a typed result; no downcasting is + required. """ def evaluate_before_agent( @@ -31,10 +34,9 @@ def evaluate_before_agent( agent_input: str, agent_name: str, runtime_id: str, - trace_id: str, model_name: str = "", **kwargs: Any, - ) -> Any: + ) -> AuditRecord: """Evaluate BEFORE_AGENT rules.""" ... @@ -43,9 +45,8 @@ def evaluate_after_agent( agent_output: str, agent_name: str, runtime_id: str, - trace_id: str, **kwargs: Any, - ) -> Any: + ) -> AuditRecord: """Evaluate AFTER_AGENT rules.""" ... @@ -54,11 +55,10 @@ def evaluate_before_model( model_input: str, agent_name: str, runtime_id: str, - trace_id: str, messages: list[dict[str, Any]] | None = None, model_name: str = "", **kwargs: Any, - ) -> Any: + ) -> AuditRecord: """Evaluate BEFORE_MODEL rules.""" ... @@ -67,9 +67,8 @@ def evaluate_after_model( model_output: str, agent_name: str, runtime_id: str, - trace_id: str, **kwargs: Any, - ) -> Any: + ) -> AuditRecord: """Evaluate AFTER_MODEL rules.""" ... @@ -79,10 +78,9 @@ def evaluate_tool_call( tool_args: dict[str, Any], agent_name: str, runtime_id: str, - trace_id: str, session_state: dict[str, Any] | None = None, **kwargs: Any, - ) -> Any: + ) -> AuditRecord: """Evaluate TOOL_CALL rules.""" ... @@ -92,8 +90,7 @@ def evaluate_after_tool( tool_result: str, agent_name: str, runtime_id: str, - trace_id: str, **kwargs: Any, - ) -> Any: + ) -> AuditRecord: """Evaluate AFTER_TOOL rules.""" ... diff --git a/packages/uipath-core/src/uipath/core/adapters/registry.py b/packages/uipath-core/src/uipath/core/adapters/registry.py deleted file mode 100644 index adebe780a..000000000 --- a/packages/uipath-core/src/uipath/core/adapters/registry.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Ordered registry of framework adapters. - -The registry is a pure, implementation-agnostic container — it does -**not** know about any concrete adapter. Plugin packages (e.g. -``uipath-langchain``) populate it by either: - -1. Declaring a ``uipath.governance.adapters`` entry point whose value - is a zero-arg callable that calls :meth:`AdapterRegistry.register`. - These are auto-discovered on first call to - :func:`get_adapter_registry`. -2. Calling :meth:`AdapterRegistry.register` directly at import time - (e.g. side-effect on importing the plugin's governance submodule). - -Adapters are checked in priority order (highest first): more specific -adapters get a higher :attr:`BaseAdapter.priority` so they win -``can_handle`` resolution over generic ones, regardless of the order in -which plugin packages happen to be imported. Among adapters with the -same priority, registration order is preserved. Adapters with -``is_fallback=True`` sort last when registered without an explicit -``position`` — passing ``position`` to :meth:`AdapterRegistry.register` -is an escape hatch that bypasses both priority and fallback ordering, -so callers using it own the resulting list order. -""" - -from __future__ import annotations - -import logging -from typing import Any - -from .base import BaseAdapter - -logger = logging.getLogger(__name__) - -ENTRY_POINT_GROUP = "uipath.governance.adapters" - - -class AdapterRegistry: - """Ordered list of adapters; resolves the first match for an agent.""" - - def __init__(self) -> None: - """Initialize an empty registry.""" - self._adapters: list[BaseAdapter] = [] - - def register(self, adapter: BaseAdapter, position: int | None = None) -> None: - """Register an adapter. - - Args: - adapter: The adapter to register. - position: Explicit insertion index (``0`` = highest priority) - that bypasses both priority-based ordering AND fallback - semantics — the adapter is inserted blindly at the given - index, so callers using ``position`` are responsible for - not placing a fallback before a specific adapter (or a - specific adapter after an existing fallback). When - ``None`` the adapter is inserted by - :attr:`BaseAdapter.priority` (higher first, stable on - ties) and before any adapter marked - :attr:`BaseAdapter.is_fallback`; adapters whose own - ``is_fallback`` is set are appended last. - """ - if position is not None: - self._adapters.insert(position, adapter) - elif adapter.is_fallback: - self._adapters.append(adapter) - else: - insert_at = len(self._adapters) - for i, existing in enumerate(self._adapters): - if existing.is_fallback or existing.priority < adapter.priority: - insert_at = i - break - self._adapters.insert(insert_at, adapter) - logger.debug("Registered adapter: %s", adapter.name) - - def resolve(self, agent: Any) -> BaseAdapter | None: - """Return the first adapter that can handle ``agent`` (or ``None``).""" - for adapter in self._adapters: - try: - if adapter.can_handle(agent): - logger.debug( - "AdapterRegistry: %s -> %s", - type(agent).__name__, - adapter.name, - ) - return adapter - except Exception as exc: - logger.warning( - "Adapter %s.can_handle() failed: %s", - adapter.name, - exc, - ) - continue - return None - - def get_all(self) -> list[BaseAdapter]: - """Return a copy of the registered adapters in priority order.""" - return self._adapters.copy() - - def clear(self) -> None: - """Remove all registered adapters.""" - self._adapters.clear() - - -_registry: AdapterRegistry | None = None - - -def _discover_entry_point_adapters() -> None: - """Load every adapter advertised under the ``uipath.governance.adapters`` group. - - Each entry-point value must be a zero-arg callable (typically a - ``register_*`` function in the plugin package) that calls - :meth:`AdapterRegistry.register`. A failure to load or invoke any - one entry point is logged and skipped — a single broken plugin - must never block governance startup. - """ - try: - from importlib.metadata import entry_points - except ImportError: # pragma: no cover - importlib.metadata is stdlib in py3.11+ - return - - try: - eps = entry_points(group=ENTRY_POINT_GROUP) - except Exception as exc: # noqa: BLE001 - discovery failures must never raise - logger.debug("Adapter entry-point discovery failed: %s", exc, exc_info=True) - return - - for ep in eps: - try: - registrar = ep.load() - except Exception as exc: # noqa: BLE001 - one broken plugin must not block others - logger.debug( - "Failed to load governance adapter entry point '%s' (%s): %s", - ep.name, - ep.value, - exc, - exc_info=True, - ) - continue - if not callable(registrar): - logger.warning( - "Governance adapter entry point '%s' is not callable: %r", - ep.name, - registrar, - ) - continue - try: - registrar() - except Exception as exc: # noqa: BLE001 - one broken plugin must not block others - logger.debug( - "Governance adapter '%s' register call failed: %s", - ep.name, - exc, - exc_info=True, - ) - - -def get_adapter_registry() -> AdapterRegistry: - """Return the process-wide adapter registry singleton. - - On first call, discovers and registers every adapter declared under - the ``uipath.governance.adapters`` entry-point group, so framework - SDKs (``uipath-langchain``, ``uipath-openai``, …) just need to be - installed — no explicit import is required. - """ - global _registry - if _registry is None: - _registry = AdapterRegistry() - _discover_entry_point_adapters() - return _registry - - -def reset_adapter_registry() -> None: - """Drop the singleton registry (intended for tests).""" - global _registry - if _registry is not None: - _registry.clear() - _registry = None diff --git a/packages/uipath-core/src/uipath/core/governance/__init__.py b/packages/uipath-core/src/uipath/core/governance/__init__.py index e3dcab741..4bf855b82 100644 --- a/packages/uipath-core/src/uipath/core/governance/__init__.py +++ b/packages/uipath-core/src/uipath/core/governance/__init__.py @@ -1,11 +1,10 @@ """UiPath governance shared contracts. -Evaluator-agnostic types every governance consumer references — -adapter packages (``uipath-langchain``, ``uipath-openai``, …), the -runtime layer (``uipath.runtime.governance``), and customer code that -catches :class:`GovernanceBlockException`. The full runtime / audit / -native-evaluator implementation lives in ``uipath.runtime.governance``; -this core surface is just the contracts. +Evaluator-agnostic types every governance consumer references — the +runtime layer, adapter packages, and customer code that catches +:class:`GovernanceBlockException`. The full runtime / audit / +native-evaluator implementation lives outside this package; this +core surface is just the contracts. """ from .config import ( diff --git a/packages/uipath-core/src/uipath/core/governance/config.py b/packages/uipath-core/src/uipath/core/governance/config.py index 7fec33848..cbcbd577a 100644 --- a/packages/uipath-core/src/uipath/core/governance/config.py +++ b/packages/uipath-core/src/uipath/core/governance/config.py @@ -5,7 +5,7 @@ :class:`uipath.core.governance.EnforcementMode` value type is defined in :mod:`uipath.core.governance.models`; the per-policy runtime state that selects a mode (backend-supplied via the ``/runtime/policy`` -client) lives in the ``uipath-runtime`` package. +client) lives outside this package. """ from __future__ import annotations @@ -13,9 +13,9 @@ from uipath.core.feature_flags import FeatureFlags # Feature flag name controlling whether governance runs. -# Mirrors the gate in ``uipath-runtime`` so the platform-injection path -# and direct callers (agents constructing an evaluator themselves) -# honour the same toggle. +# A single shared gate so the host-driven injection path and direct +# callers (agents constructing an evaluator themselves) honour the +# same toggle. GOVERNANCE_FEATURE_FLAG = "EnablePythonGovernanceChecker" diff --git a/packages/uipath-core/src/uipath/core/governance/models.py b/packages/uipath-core/src/uipath/core/governance/models.py index 9fc5e2084..29dccc121 100644 --- a/packages/uipath-core/src/uipath/core/governance/models.py +++ b/packages/uipath-core/src/uipath/core/governance/models.py @@ -9,9 +9,9 @@ boundary at evaluation time: every evaluator implementation (native, AGT, composite, …) produces them, and every adapter consumes them. - **Configuration value types** (:class:`EnforcementMode`) — describe - governance configuration shared by core, runtime, and consumers. The - runtime state that selects an enforcement mode lives in - ``uipath-runtime``; only the value type lives here. + governance configuration shared by core and its consumers. The + per-policy runtime state that selects a mode lives outside this + package; only the value type lives here. """ from __future__ import annotations @@ -66,12 +66,17 @@ class RuleEvaluation: @dataclass class AuditRecord: - """Complete audit record for a governance evaluation.""" + """Complete audit record for a governance evaluation. + + ``trace_id`` is intentionally absent. Trace correlation is resolved + by the concrete provider at request time (via OpenTelemetry's + native span identity) — per-evaluation trace ids aren't part of + the audit-record contract. + """ timestamp: datetime agent_name: str runtime_id: str - trace_id: str hook: LifecycleHook evaluations: list[RuleEvaluation] final_action: Action diff --git a/packages/uipath-core/src/uipath/core/governance/providers.py b/packages/uipath-core/src/uipath/core/governance/providers.py index 29f435edb..2a0b7155a 100644 --- a/packages/uipath-core/src/uipath/core/governance/providers.py +++ b/packages/uipath-core/src/uipath/core/governance/providers.py @@ -106,6 +106,11 @@ class GovernRequest(BaseModel): them by leaving them ``None``. How unset fields are resolved (e.g. auto-filled from environment) is the concrete provider's concern, not part of this wire contract. + + ``trace_id`` is optional. When ``None`` the field is omitted from + the wire JSON (via ``exclude_none=True`` at serialisation). Whether + a concrete provider chooses to populate a missing value before + sending is the provider's concern, not part of this contract. """ model_config = ConfigDict(populate_by_name=True) @@ -114,7 +119,7 @@ class GovernRequest(BaseModel): rules: list[FiredRule] data: dict[str, Any] hook: str - trace_id: str = Field(alias="traceId") + trace_id: str | None = Field(default=None, alias="traceId") src_timestamp: str # wire key is intentionally snake_case agent_name: str = Field(alias="agentName") runtime_id: str = Field(alias="runtimeId") @@ -135,14 +140,32 @@ class GovernRequest(BaseModel): class GovernancePolicyProvider(Protocol): """Contract for fetching the governance policy pack. - Any object exposing a ``get_policy(context) -> PolicyResponse`` - method satisfies this protocol. + Implementations expose both a sync and an async fetch. The async + variant is the preferred entry point for hosts running on an event + loop (the host can overlap policy fetch with the rest of agent + setup via ``asyncio.create_task`` and ``await`` the resolved + :class:`PolicyResponse` before constructing the governance + wrapper). The sync variant is kept for callers outside an event + loop (CLI tools, integration tests). + + Any object exposing both ``get_policy(context) -> PolicyResponse`` + and ``async def get_policy_async(context) -> PolicyResponse`` + satisfies this protocol. """ def get_policy(self, context: PolicyContext) -> PolicyResponse: """Fetch the policy pack for the active org/tenant.""" ... + async def get_policy_async(self, context: PolicyContext) -> PolicyResponse: + """Async variant of :meth:`get_policy`. + + Hosts running on an event loop should use this so the fetch + doesn't block the loop and can overlap with other startup + work. + """ + ... + @runtime_checkable class GovernanceCompensationProvider(Protocol): diff --git a/packages/uipath-core/tests/adapters/test_base.py b/packages/uipath-core/tests/adapters/test_base.py deleted file mode 100644 index 9be6346ed..000000000 --- a/packages/uipath-core/tests/adapters/test_base.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Tests for BaseAdapter defaults and GovernedAgentBase proxy behavior.""" - -from __future__ import annotations - -from typing import Any - -import pytest - -from uipath.core.adapters import BaseAdapter, EvaluatorProtocol -from uipath.core.adapters.base import GovernedAgentBase - - -class _StubEvaluator: - """No-op evaluator that structurally matches EvaluatorProtocol.""" - - def evaluate_before_agent(self, *args: Any, **kwargs: Any) -> Any: - return None - - def evaluate_after_agent(self, *args: Any, **kwargs: Any) -> Any: - return None - - def evaluate_before_model(self, *args: Any, **kwargs: Any) -> Any: - return None - - def evaluate_after_model(self, *args: Any, **kwargs: Any) -> Any: - return None - - def evaluate_tool_call(self, *args: Any, **kwargs: Any) -> Any: - return None - - def evaluate_after_tool(self, *args: Any, **kwargs: Any) -> Any: - return None - - -class _MinimalAdapter(BaseAdapter): - """Concrete adapter that does NOT override ``name`` — exercises the default.""" - - def can_handle(self, agent: Any) -> bool: - return True - - def attach( - self, - agent: Any, - agent_id: str, - session_id: str, - evaluator: EvaluatorProtocol, - ) -> Any: - return agent - - -class _Agent: - """Simple stand-in for a framework agent with one attribute and one method.""" - - foo = "bar" - - def greet(self) -> str: - return "hello" - - -# --------------------------------------------------------------------------- -# BaseAdapter defaults -# --------------------------------------------------------------------------- - - -def test_default_name_is_class_name(): - """The default ``name`` property returns the class name.""" - assert _MinimalAdapter().name == "_MinimalAdapter" - - -def test_detach_returns_unwrapped_when_present(): - """``detach`` honours the ``unwrapped`` contract on a governed proxy.""" - adapter = _MinimalAdapter() - original = object() - - class _Proxy: - unwrapped = original - - assert adapter.detach(_Proxy()) is original - - -def test_detach_returns_input_when_no_unwrapped_attribute(): - """For non-proxy adapters, ``detach`` returns the input unchanged.""" - adapter = _MinimalAdapter() - raw = object() - assert adapter.detach(raw) is raw - - -def test_generate_trace_id_returns_unique_uuid_string(): - """``_generate_trace_id`` returns a string UUID; consecutive calls differ.""" - adapter = _MinimalAdapter() - a = adapter._generate_trace_id() - b = adapter._generate_trace_id() - assert isinstance(a, str) - assert a != b - assert len(a) == 36 # canonical UUID4 form: 32 hex + 4 dashes - - -# --------------------------------------------------------------------------- -# GovernedAgentBase proxy -# --------------------------------------------------------------------------- - - -def test_governed_agent_base_stores_metadata_and_generates_trace_id(): - """Constructor wires every governance field and pulls a trace id from the adapter.""" - agent = _Agent() - adapter = _MinimalAdapter() - evaluator = _StubEvaluator() - - governed = GovernedAgentBase( - agent=agent, - adapter=adapter, - agent_id="agent-123", - session_id="session-abc", - evaluator=evaluator, - ) - - assert governed._agent is agent - assert governed._adapter is adapter - assert governed._agent_id == "agent-123" - assert governed._session_id == "session-abc" - assert governed._evaluator is evaluator - assert isinstance(governed._trace_id, str) - assert len(governed._trace_id) == 36 - - -def test_governed_agent_base_unwrapped_returns_original_agent(): - agent = _Agent() - governed = GovernedAgentBase( - agent=agent, - adapter=_MinimalAdapter(), - agent_id="a", - session_id="s", - evaluator=_StubEvaluator(), - ) - assert governed.unwrapped is agent - - -def test_governed_agent_base_forwards_attribute_access_to_agent(): - """Unknown attributes fall through to the wrapped agent via __getattr__.""" - governed = GovernedAgentBase( - agent=_Agent(), - adapter=_MinimalAdapter(), - agent_id="a", - session_id="s", - evaluator=_StubEvaluator(), - ) - - assert governed.foo == "bar" - assert governed.greet() == "hello" - - -def test_governed_agent_base_attribute_miss_raises_attribute_error(): - """If the wrapped agent also lacks the attribute, AttributeError surfaces.""" - governed = GovernedAgentBase( - agent=_Agent(), - adapter=_MinimalAdapter(), - agent_id="a", - session_id="s", - evaluator=_StubEvaluator(), - ) - - with pytest.raises(AttributeError): - _ = governed.does_not_exist diff --git a/packages/uipath-core/tests/adapters/test_evaluator.py b/packages/uipath-core/tests/adapters/test_evaluator.py index 5c9e5c9e5..83aa70b5e 100644 --- a/packages/uipath-core/tests/adapters/test_evaluator.py +++ b/packages/uipath-core/tests/adapters/test_evaluator.py @@ -96,9 +96,9 @@ def test_protocol_subclass_methods_execute_stub_bodies(): """Calling each method via ``super()`` executes the stub body and returns None.""" e = _ProtocolSubclass() - assert e.evaluate_before_agent("input", "agent", "rt", "trace") is None - assert e.evaluate_after_agent("output", "agent", "rt", "trace") is None - assert e.evaluate_before_model("input", "agent", "rt", "trace") is None - assert e.evaluate_after_model("output", "agent", "rt", "trace") is None - assert e.evaluate_tool_call("tool", {"arg": 1}, "agent", "rt", "trace") is None - assert e.evaluate_after_tool("tool", "result", "agent", "rt", "trace") is None + assert e.evaluate_before_agent("input", "agent", "rt") is None + assert e.evaluate_after_agent("output", "agent", "rt") is None + assert e.evaluate_before_model("input", "agent", "rt") is None + assert e.evaluate_after_model("output", "agent", "rt") is None + assert e.evaluate_tool_call("tool", {"arg": 1}, "agent", "rt") is None + assert e.evaluate_after_tool("tool", "result", "agent", "rt") is None diff --git a/packages/uipath-core/tests/adapters/test_registry.py b/packages/uipath-core/tests/adapters/test_registry.py deleted file mode 100644 index b16b29b1e..000000000 --- a/packages/uipath-core/tests/adapters/test_registry.py +++ /dev/null @@ -1,492 +0,0 @@ -"""Tests for AdapterRegistry — ordering, resolution, entry-point discovery.""" - -from __future__ import annotations - -from typing import Any -from unittest.mock import MagicMock - -import pytest - -from uipath.core.adapters import BaseAdapter, EvaluatorProtocol -from uipath.core.adapters.registry import ( - AdapterRegistry, - _discover_entry_point_adapters, - get_adapter_registry, - reset_adapter_registry, -) - -# --------------------------------------------------------------------------- -# Test adapters -# --------------------------------------------------------------------------- - - -class _SpecificAdapter(BaseAdapter): - """Matches only objects with a ``__specific__`` marker.""" - - @property - def name(self) -> str: - return "specific" - - def can_handle(self, agent: Any) -> bool: - return hasattr(agent, "__specific__") - - def attach( - self, - agent: Any, - agent_id: str, - session_id: str, - evaluator: EvaluatorProtocol, - ) -> Any: - return agent - - -class _FallbackAdapter(BaseAdapter): - """Matches anything — must always sort last.""" - - is_fallback = True - - @property - def name(self) -> str: - return "fallback" - - def can_handle(self, agent: Any) -> bool: - return True - - def attach( - self, - agent: Any, - agent_id: str, - session_id: str, - evaluator: EvaluatorProtocol, - ) -> Any: - return agent - - -class _SecondaryAdapter(BaseAdapter): - """Another specific adapter, used to test ordering between two specifics.""" - - @property - def name(self) -> str: - return "secondary" - - def can_handle(self, agent: Any) -> bool: - return hasattr(agent, "__secondary__") - - def attach( - self, - agent: Any, - agent_id: str, - session_id: str, - evaluator: EvaluatorProtocol, - ) -> Any: - return agent - - -class _HighPriorityAdapter(BaseAdapter): - """Specific adapter with an elevated priority.""" - - priority = 100 - - @property - def name(self) -> str: - return "high" - - def can_handle(self, agent: Any) -> bool: - return True - - def attach( - self, - agent: Any, - agent_id: str, - session_id: str, - evaluator: EvaluatorProtocol, - ) -> Any: - return agent - - -class _LowPriorityAdapter(BaseAdapter): - """Generic adapter that should yield to higher-priority specifics.""" - - priority = -10 - - @property - def name(self) -> str: - return "low" - - def can_handle(self, agent: Any) -> bool: - return True - - def attach( - self, - agent: Any, - agent_id: str, - session_id: str, - evaluator: EvaluatorProtocol, - ) -> Any: - return agent - - -class _BrokenAdapter(BaseAdapter): - """``can_handle`` raises — must be skipped, not crash resolution.""" - - @property - def name(self) -> str: - return "broken" - - def can_handle(self, agent: Any) -> bool: - raise RuntimeError("can_handle exploded") - - def attach( - self, - agent: Any, - agent_id: str, - session_id: str, - evaluator: EvaluatorProtocol, - ) -> Any: - raise RuntimeError("attach exploded") - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture(autouse=True) -def _isolate_global_registry(): - """Each test starts with no singleton registry.""" - reset_adapter_registry() - yield - reset_adapter_registry() - - -# --------------------------------------------------------------------------- -# register / resolve / get_all / clear -# --------------------------------------------------------------------------- - - -def test_empty_registry_resolves_to_none(): - reg = AdapterRegistry() - assert reg.resolve(object()) is None - assert reg.get_all() == [] - - -def test_register_appends_in_order(): - reg = AdapterRegistry() - a, b = _SpecificAdapter(), _SecondaryAdapter() - reg.register(a) - reg.register(b) - assert reg.get_all() == [a, b] - - -def test_resolve_returns_first_matching_adapter(): - reg = AdapterRegistry() - reg.register(_SpecificAdapter()) - reg.register(_SecondaryAdapter()) - - agent = MagicMock() - agent.__secondary__ = True # only secondary should match - resolved = reg.resolve(agent) - assert resolved is not None - assert resolved.name == "secondary" - - -def test_resolve_skips_broken_can_handle_and_continues(): - """A can_handle() that raises must not break the whole resolve loop.""" - reg = AdapterRegistry() - reg.register(_BrokenAdapter()) - reg.register(_SpecificAdapter()) - - agent = MagicMock() - agent.__specific__ = True - resolved = reg.resolve(agent) - assert resolved is not None - assert resolved.name == "specific" - - -def test_register_position_inserts_at_index(): - reg = AdapterRegistry() - a, b, c = _SpecificAdapter(), _SecondaryAdapter(), _SpecificAdapter() - reg.register(a) - reg.register(b) - reg.register(c, position=0) # c jumps to head - assert reg.get_all()[0] is c - assert reg.get_all()[1:] == [a, b] - - -def test_higher_priority_adapter_inserted_before_lower_priority(): - """A specific (higher-priority) adapter must sort before a generic one - even when the generic one was registered first.""" - reg = AdapterRegistry() - generic = _LowPriorityAdapter() - specific = _HighPriorityAdapter() - reg.register(generic) - reg.register(specific) # registered later, but higher priority - - adapters = reg.get_all() - assert adapters[0] is specific - assert adapters[1] is generic - - -def test_same_priority_preserves_registration_order(): - """Adapters with equal priority should fall back to insertion order.""" - reg = AdapterRegistry() - a, b = _SpecificAdapter(), _SecondaryAdapter() # both priority=0 - reg.register(a) - reg.register(b) - assert reg.get_all() == [a, b] - - -def test_higher_priority_adapter_inserted_before_fallback(): - """High-priority adapter goes in front of an already-registered fallback.""" - reg = AdapterRegistry() - fallback = _FallbackAdapter() - reg.register(fallback) - reg.register(_HighPriorityAdapter()) - - adapters = reg.get_all() - assert adapters[0].name == "high" - assert adapters[-1] is fallback - - -def test_lower_priority_adapter_inserted_before_fallback_after_specifics(): - """Negative-priority adapter sorts after default-priority specifics but - still before the fallback.""" - reg = AdapterRegistry() - reg.register(_SpecificAdapter()) # priority=0 - reg.register(_FallbackAdapter()) - reg.register(_LowPriorityAdapter()) # priority=-10 - - adapters = reg.get_all() - assert adapters[0].name == "specific" - assert adapters[1].name == "low" - assert adapters[-1].name == "fallback" - - -def test_priority_overrides_registration_order_in_resolve(): - """Resolution must follow priority ordering, not registration order.""" - reg = AdapterRegistry() - reg.register(_LowPriorityAdapter()) # both adapters match every agent, - reg.register(_HighPriorityAdapter()) # so priority decides which wins. - - resolved = reg.resolve(object()) - assert resolved is not None - assert resolved.name == "high" - - -def test_fallback_stays_last_when_new_adapter_registered(): - """When the last entry has ``is_fallback`` set, new adapters insert before it.""" - reg = AdapterRegistry() - fallback = _FallbackAdapter() - reg.register(fallback) - reg.register(_SpecificAdapter()) # this should insert BEFORE fallback - - adapters = reg.get_all() - assert adapters[-1] is fallback - assert adapters[0].name == "specific" - - -def test_fallback_resolves_only_when_no_specific_matches(): - reg = AdapterRegistry() - reg.register(_SpecificAdapter()) - reg.register(_FallbackAdapter()) - - # Agent without the __specific__ marker → fallback wins. - resolved = reg.resolve(object()) - assert resolved is not None - assert resolved.name == "fallback" - - -def test_clear_removes_all_adapters(): - reg = AdapterRegistry() - reg.register(_SpecificAdapter()) - reg.register(_SecondaryAdapter()) - reg.clear() - assert reg.get_all() == [] - assert reg.resolve(object()) is None - - -def test_get_all_returns_copy_not_internal_list(): - """Callers must not be able to mutate the registry through get_all().""" - reg = AdapterRegistry() - reg.register(_SpecificAdapter()) - snapshot = reg.get_all() - snapshot.clear() - assert len(reg.get_all()) == 1 # unaffected - - -# --------------------------------------------------------------------------- -# Singleton + entry-point discovery -# --------------------------------------------------------------------------- - - -def test_get_adapter_registry_returns_singleton(): - reg1 = get_adapter_registry() - reg2 = get_adapter_registry() - assert reg1 is reg2 - - -def test_reset_adapter_registry_drops_singleton(): - first = get_adapter_registry() - reset_adapter_registry() - second = get_adapter_registry() - assert first is not second - - -def test_entry_point_discovery_invokes_registrars(monkeypatch): - """Each entry-point's zero-arg callable must be loaded and called.""" - called: list[str] = [] - - def make_registrar(name: str): - def _register() -> None: - called.append(name) - - return _register - - ep_a = MagicMock() - ep_a.name = "a" - ep_a.value = "pkg_a:register" - ep_a.load.return_value = make_registrar("a") - - ep_b = MagicMock() - ep_b.name = "b" - ep_b.value = "pkg_b:register" - ep_b.load.return_value = make_registrar("b") - - monkeypatch.setattr( - "uipath.core.adapters.registry.entry_points", - lambda group: [ep_a, ep_b] if group == "uipath.governance.adapters" else [], - raising=False, - ) - - # entry_points lives in importlib.metadata; the registry imports it - # lazily inside the function. Patch the import target directly. - import importlib.metadata as importlib_metadata - - monkeypatch.setattr( - importlib_metadata, - "entry_points", - lambda group=None: ( - [ep_a, ep_b] if group == "uipath.governance.adapters" else [] - ), - ) - - _discover_entry_point_adapters() - assert sorted(called) == ["a", "b"] - - -def test_entry_point_discovery_skips_broken_loader(monkeypatch): - """One broken entry-point must not stop the others from registering.""" - called: list[str] = [] - - ep_broken = MagicMock() - ep_broken.name = "broken" - ep_broken.value = "pkg_broken:register" - ep_broken.load.side_effect = ImportError("cannot import") - - ep_ok = MagicMock() - ep_ok.name = "ok" - ep_ok.value = "pkg_ok:register" - ep_ok.load.return_value = lambda: called.append("ok") - - import importlib.metadata as importlib_metadata - - monkeypatch.setattr( - importlib_metadata, - "entry_points", - lambda group=None: ( - [ep_broken, ep_ok] if group == "uipath.governance.adapters" else [] - ), - ) - - _discover_entry_point_adapters() # must not raise - assert called == ["ok"] - - -def test_entry_point_discovery_skips_non_callable(monkeypatch): - """An entry-point that resolves to a non-callable must be logged and skipped.""" - called: list[str] = [] - - ep_bad = MagicMock() - ep_bad.name = "bad" - ep_bad.value = "pkg_bad:NOT_A_FUNCTION" - ep_bad.load.return_value = "not callable" - - ep_ok = MagicMock() - ep_ok.name = "ok" - ep_ok.value = "pkg_ok:register" - ep_ok.load.return_value = lambda: called.append("ok") - - import importlib.metadata as importlib_metadata - - monkeypatch.setattr( - importlib_metadata, - "entry_points", - lambda group=None: ( - [ep_bad, ep_ok] if group == "uipath.governance.adapters" else [] - ), - ) - - _discover_entry_point_adapters() - assert called == ["ok"] - - -def test_entry_point_discovery_swallows_registrar_exception(monkeypatch): - """A registrar that raises mid-call must not stop subsequent registrars.""" - called: list[str] = [] - - def _raises() -> None: - raise RuntimeError("registrar exploded") - - ep_raising = MagicMock() - ep_raising.name = "raises" - ep_raising.value = "pkg:register" - ep_raising.load.return_value = _raises - - ep_ok = MagicMock() - ep_ok.name = "ok" - ep_ok.value = "pkg:register2" - ep_ok.load.return_value = lambda: called.append("ok") - - import importlib.metadata as importlib_metadata - - monkeypatch.setattr( - importlib_metadata, - "entry_points", - lambda group=None: ( - [ep_raising, ep_ok] if group == "uipath.governance.adapters" else [] - ), - ) - - _discover_entry_point_adapters() - assert called == ["ok"] - - -def test_entry_point_discovery_swallows_entry_points_failure(monkeypatch): - """If ``entry_points()`` itself raises, discovery must log and return cleanly.""" - import importlib.metadata as importlib_metadata - - def _boom(group=None): - raise RuntimeError("entry_points API exploded") - - monkeypatch.setattr(importlib_metadata, "entry_points", _boom) - - # Must not raise — and must not register anything. - _discover_entry_point_adapters() - reg = get_adapter_registry() - assert reg.get_all() == [] - - -# --------------------------------------------------------------------------- -# Protocol conformance smoke tests -# --------------------------------------------------------------------------- - - -def test_baseadapter_is_abc(): - """BaseAdapter must be abstract — direct instantiation must fail.""" - with pytest.raises(TypeError): - BaseAdapter() # type: ignore[abstract] - - -def test_concrete_adapter_is_baseadapter(): - """A concrete subclass must be recognized as a BaseAdapter.""" - assert isinstance(_SpecificAdapter(), BaseAdapter) diff --git a/packages/uipath-core/tests/governance/test_exceptions.py b/packages/uipath-core/tests/governance/test_exceptions.py index 257feb3d4..5e8738a15 100644 --- a/packages/uipath-core/tests/governance/test_exceptions.py +++ b/packages/uipath-core/tests/governance/test_exceptions.py @@ -109,7 +109,6 @@ def _audit_record_with(*evaluations: RuleEvaluation) -> AuditRecord: timestamp=datetime.now(timezone.utc), agent_name="agent", runtime_id="run-1", - trace_id="trace-1", hook=LifecycleHook.BEFORE_AGENT, evaluations=list(evaluations), final_action=Action.DENY, diff --git a/packages/uipath-core/tests/governance/test_providers.py b/packages/uipath-core/tests/governance/test_providers.py index 083b62663..21e5f1703 100644 --- a/packages/uipath-core/tests/governance/test_providers.py +++ b/packages/uipath-core/tests/governance/test_providers.py @@ -18,11 +18,16 @@ class _FakePolicyProvider: def __init__(self) -> None: self.calls: list[PolicyContext] = [] + self.async_calls: list[PolicyContext] = [] def get_policy(self, context: PolicyContext) -> PolicyResponse: self.calls.append(context) return PolicyResponse(mode=EnforcementMode.ENFORCE, policies="rules: []") + async def get_policy_async(self, context: PolicyContext) -> PolicyResponse: + self.async_calls.append(context) + return PolicyResponse(mode=EnforcementMode.ENFORCE, policies="rules: []") + class _FakeCompensationProvider: def __init__(self) -> None: @@ -141,6 +146,24 @@ def test_policy_round_trip(self) -> None: assert response.mode is EnforcementMode.ENFORCE assert provider.calls == [PolicyContext(is_conversational=True)] + @pytest.mark.asyncio + async def test_policy_round_trip_async(self) -> None: + """The async variant is the preferred entry point for event-loop hosts. + + Hosts running ``await provider.get_policy_async(ctx)`` overlap + the fetch with the rest of agent setup; the sync ``get_policy`` + path remains for callers outside an event loop. + """ + provider = _FakePolicyProvider() + response = await provider.get_policy_async( + PolicyContext(is_conversational=False) + ) + + assert response.mode is EnforcementMode.ENFORCE + assert provider.async_calls == [PolicyContext(is_conversational=False)] + # Sync slot stays untouched — the two entrypoints are independent. + assert provider.calls == [] + def test_compensation_round_trip(self) -> None: provider = _FakeCompensationProvider() request = _make_request() diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index 67d937f3a..6969d38ab 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -1011,7 +1011,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.23" +version = "0.5.25" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index d46a6de4b..32e85b47e 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.79" +version = "0.1.80" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" @@ -8,7 +8,7 @@ dependencies = [ "httpx>=0.28.1", "tenacity>=9.0.0", "truststore>=0.10.1", - "uipath-core>=0.5.21, <0.6.0", + "uipath-core>=0.5.25, <0.6.0", "pydantic-function-models>=0.1.11", "sqlparse>=0.5.5", ] diff --git a/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py b/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py index 5ceabf479..afd5701b7 100644 --- a/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py +++ b/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py @@ -22,7 +22,7 @@ PolicyResponse, ) -from ..common._base_service import BaseService +from ..common._base_service import BaseService, resolve_trace_id from ..common._config import UiPathConfig from ..common._service_url_overrides import ( inject_routing_headers, @@ -139,10 +139,10 @@ def compensate( validators: list[str], rules: list[FiredRule], data: dict[str, Any], - trace_id: str, src_timestamp: str, agent_name: str, runtime_id: str, + trace_id: str | None = None, folder_key: str | None = None, job_key: str | None = None, process_key: str | None = None, @@ -154,8 +154,8 @@ def compensate( Fired when a ``guardrail_fallback`` rule matches: the centralized guardrail is disabled, so the server is asked to run the guardrail check server-side and write the per-rule LLMOps audit - records bound to ``trace_id``. The agent does not inspect the - response body. + records bound to the agent's trace. The agent does not inspect + the response body. Job-context fields (``folder_key`` / ``job_key`` / ``process_key`` / ``reference_id`` / ``agent_version``) are @@ -171,9 +171,12 @@ def compensate( written per entry. data: Hook payload the server replays through the centralized guardrail. - trace_id: Canonical 32-char hex trace id. Capture via - :func:`resolve_trace_id` on the hook thread before - hopping to a background pool. + trace_id: Canonical 32-char hex trace id. Optional — when + ``None`` (default) the service resolves the value + itself at call time via :func:`resolve_trace_id`. + Callers that already hold a resolved id (typically + captured on the hook thread before a background-pool + hop) pass it in to win over the auto-resolve. src_timestamp: ISO-8601 timestamp on the source side. agent_name: Agent identifier as known to the platform. runtime_id: Runtime instance identifier. @@ -189,10 +192,11 @@ def compensate( EnrichedException: If the backend returns a non-2xx response. Threading: - ``trace_id`` must be the agent's canonical trace id, and - OpenTelemetry context is thread-local; capture it on the - hook thread (via :func:`resolve_trace_id`) before hopping - to a background pool. + OpenTelemetry context is thread-local; callers that + background-pool the compensation call must capture the + canonical trace id (via :func:`resolve_trace_id`) on the + hook thread and pass it in explicitly — the auto-resolve + on the worker thread will see a detached context. """ self._compensate( GovernRequest( @@ -219,10 +223,10 @@ async def compensate_async( validators: list[str], rules: list[FiredRule], data: dict[str, Any], - trace_id: str, src_timestamp: str, agent_name: str, runtime_id: str, + trace_id: str | None = None, folder_key: str | None = None, job_key: str | None = None, process_key: str | None = None, @@ -262,18 +266,46 @@ def _compensate(self, request: GovernRequest) -> None: to satisfy :class:`uipath.core.governance.GovernanceCompensationProvider` without unpacking the request. The public ergonomic counterpart is :meth:`compensate`. + + When ``request.trace_id`` is ``None`` the service resolves the + canonical trace id itself via :func:`resolve_trace_id` — same + fallback ``track_event`` uses. Callers that have a resolved + value still pass it in; callers that don't (e.g. the runtime + layer, which intentionally stays env-free) leave it ``None`` + and let the service do the work. """ + request = self._resolve_request_trace_id(request) url, headers = self._build_org_scoped_request(GOVERN_API_PATH) payload = self._build_govern_payload(request) self.request("POST", url=url, headers=headers, json=payload) @traced(name="governance_compensate", run_type="uipath") async def _compensate_async(self, request: GovernRequest) -> None: - """Async variant of :meth:`_compensate`.""" + """Async variant of :meth:`_compensate`. + + Same ``trace_id`` self-resolution behavior as the sync variant. + """ + request = self._resolve_request_trace_id(request) url, headers = self._build_org_scoped_request(GOVERN_API_PATH) payload = self._build_govern_payload(request) await self.request_async("POST", url=url, headers=headers, json=payload) + @staticmethod + def _resolve_request_trace_id(request: GovernRequest) -> GovernRequest: + """Fill ``request.trace_id`` from :func:`resolve_trace_id` when absent. + + Caller-supplied values (including ``""``) win — the runtime + captures on the hook thread (via ``contextvars.copy_context`` + for the background pool) and the resolver here only fires when + the field was left ``None``. + """ + if request.trace_id is not None: + return request + resolved = resolve_trace_id() + if not resolved: + return request + return request.model_copy(update={"trace_id": resolved}) + # ── Internals ──────────────────────────────────────────────────── def _build_org_scoped_request(self, path: str) -> tuple[str, dict[str, str]]: diff --git a/packages/uipath-platform/tests/services/test_governance_service.py b/packages/uipath-platform/tests/services/test_governance_service.py index e437fdda0..2c808b943 100644 --- a/packages/uipath-platform/tests/services/test_governance_service.py +++ b/packages/uipath-platform/tests/services/test_governance_service.py @@ -433,6 +433,99 @@ def test_raises_when_org_id_missing( with pytest.raises(ValueError, match="UIPATH_ORGANIZATION_ID"): service.compensate(**_compensate_kwargs()) + def test_self_resolves_trace_id_when_caller_leaves_none( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """``trace_id=None`` from the caller is filled via resolve_trace_id(). + + The runtime layer intentionally stays env-free; the platform + service fills the canonical trace id at HTTP-call time from + the OTel/env source. ``UIPATH_TRACE_ID`` covers the + resolver-finds-a-value branch of ``_resolve_request_trace_id``. + """ + monkeypatch.setenv("UIPATH_TRACE_ID", TENANT_ID_HEX) + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={}) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/govern", + ) + + service.compensate(**_compensate_kwargs(trace_id=None)) + + body = json.loads(captured["request"].content) + assert body["traceId"] == TENANT_ID_HEX + + def test_omits_trace_id_when_no_source_resolves( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Resolver returns nothing → traceId is omitted from the body. + + Covers the resolver-finds-nothing branch of + ``_resolve_request_trace_id``: no ``UIPATH_TRACE_ID``, no + active OTel context → ``trace_id`` stays ``None`` on the + request and ``model_dump(exclude_none=True)`` drops it from + the wire JSON. + """ + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={}) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/govern", + ) + + service.compensate(**_compensate_kwargs(trace_id=None)) + + body = json.loads(captured["request"].content) + assert "traceId" not in body + + def test_caller_empty_string_wins_over_resolver( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """An explicit ``trace_id=""`` from the caller is not overridden. + + With the absence-via-``None`` contract, the empty string is + a legitimate caller-supplied value — it must not trigger + the auto-resolve. + """ + monkeypatch.setenv("UIPATH_TRACE_ID", TENANT_ID_HEX) + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={}) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/govern", + ) + + service.compensate(**_compensate_kwargs(trace_id="")) + + body = json.loads(captured["request"].content) + assert body["traceId"] == "" + class TestCompensateAsync: """Test compensate_async.""" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index da9ec91d6..d74fe6e6b 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1063,7 +1063,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.23" +version = "0.5.25" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.79" +version = "0.1.80" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 1a3771d8b..83e05445e 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2659,7 +2659,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.23" +version = "0.5.25" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.79" +version = "0.1.80" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 3b5753fd7f80718e2e422853bfd31fe3d52ccce3 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Mon, 29 Jun 2026 18:42:44 +0300 Subject: [PATCH 127/191] feat: add timer resume time field (#1767) --- packages/uipath-core/pyproject.toml | 2 +- packages/uipath-core/src/uipath/core/triggers/trigger.py | 2 ++ packages/uipath-core/uv.lock | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 22d665720..739540a5b 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.25" +version = "0.5.26" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/triggers/trigger.py b/packages/uipath-core/src/uipath/core/triggers/trigger.py index c897acd28..52a7da0ba 100644 --- a/packages/uipath-core/src/uipath/core/triggers/trigger.py +++ b/packages/uipath-core/src/uipath/core/triggers/trigger.py @@ -1,5 +1,6 @@ """Module defining resume trigger types and data models.""" +from datetime import datetime from enum import Enum from typing import Any @@ -87,6 +88,7 @@ class UiPathResumeTrigger(BaseModel): integration_resume: UiPathIntegrationTrigger | None = Field( default=None, alias="integrationResume" ) + resume_time: datetime | None = Field(default=None, alias="resumeTime") folder_path: str | None = Field(default=None, alias="folderPath") folder_key: str | None = Field(default=None, alias="folderKey") payload: Any | None = Field(default=None, alias="interruptObject", exclude=True) diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index 6969d38ab..fb7050873 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -1011,7 +1011,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.25" +version = "0.5.26" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index d74fe6e6b..378bd1204 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1063,7 +1063,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.25" +version = "0.5.26" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 83e05445e..ee14e052a 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2659,7 +2659,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.25" +version = "0.5.26" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, From 68e4e8a6f26941e38babf7af7803fd41ae39e79c Mon Sep 17 00:00:00 2001 From: saksharthakkar <162214066+saksharthakkar@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:33:44 -0700 Subject: [PATCH 128/191] feat(tracing): migrate span ingestion to v3 API with string enums (#1684) Co-authored-by: Claude Sonnet 4.6 --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/common/__init__.py | 13 +- .../src/uipath/platform/common/_span_utils.py | 174 +++++++-- .../tests/services/test_span_utils.py | 349 ++++++++++++++++-- packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 4 +- .../uipath/src/uipath/tracing/__init__.py | 2 +- .../tracing/_live_tracking_processor.py | 7 +- .../src/uipath/tracing/_otel_exporters.py | 36 +- .../tests/tracing/test_otel_exporters.py | 128 ++++++- packages/uipath/uv.lock | 4 +- 11 files changed, 626 insertions(+), 95 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 32e85b47e..d5e3f9b46 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.80" +version = "0.1.81" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/common/__init__.py b/packages/uipath-platform/src/uipath/platform/common/__init__.py index 2407263ee..1a08d7869 100644 --- a/packages/uipath-platform/src/uipath/platform/common/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/common/__init__.py @@ -22,7 +22,14 @@ from ._http_config import get_ca_bundle_path, get_httpx_client_kwargs from ._models import Endpoint, RequestSpec from ._service_url_overrides import inject_routing_headers, resolve_service_url -from ._span_utils import UiPathSpan, _SpanUtils +from ._span_utils import ( + ExecutionType, + SpanSource, + SpanStatus, + UiPathSpan, + VerbosityLevel, + _SpanUtils, +) from ._url import UiPathUrl from ._user_agent import user_agent_value from .auth import TokenData @@ -105,11 +112,15 @@ "jsonschema_to_pydantic", "ConnectionResourceOverwrite", "EntityResourceOverwrite", + "ExecutionType", "GenericResourceOverwrite", "ResourceOverwrite", "ResourceOverwriteParser", "ResourceOverwritesContext", + "SpanSource", + "SpanStatus", "UiPathSpan", + "VerbosityLevel", "_SpanUtils", "resolve_service_url", "inject_routing_headers", diff --git a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py index 954fbc038..3db31faed 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -5,10 +5,10 @@ import uuid from dataclasses import dataclass, field from datetime import datetime -from enum import IntEnum +from enum import IntEnum, StrEnum from functools import lru_cache from os import environ as env -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, TypeVar from opentelemetry.sdk.trace import ReadableSpan from opentelemetry.trace import StatusCode @@ -17,8 +17,105 @@ logger = logging.getLogger(__name__) -# SourceEnum.CodedAgents = 10 (default for Python SDK / coded agents) -DEFAULT_SOURCE = 10 + +class SpanStatus(StrEnum): + UNSET = "Unset" + OK = "Ok" + ERROR = "Error" + RUNNING = "Running" + RESTRICTED = "Restricted" + CANCELLED = "Cancelled" + + +class SpanSource(StrEnum): + # Mirrors the server's SourceEnum + # (llm-observability: UiPath.LLMOps.DataAccess/Models/SourceEnum.cs). + # Member name = exact wire string (no naming policy on the v3 API). + # Keep complete: an unknown int is relabeled CodedAgents (see + # otel_span_to_uipath_span), and v3 rejects raw integers. + TESTING = "Testing" + AGENTS = "Agents" + PROCESS_ORCHESTRATION = "ProcessOrchestration" + API_WORKFLOWS = "ApiWorkflows" + ROBOTS = "Robots" + CONVERSATIONAL_AGENTS_SERVICE = "ConversationalAgentsService" + INTEGRATION_SERVICE_TRIGGER = "IntegrationServiceTrigger" + PLAYGROUND = "Playground" + GOVERNANCE = "Governance" + IXP_UNSTRUCTURED_AND_COMPLEX_DOCUMENTS = "IXPUnstructuredAndComplexDocuments" + CODED_AGENTS = "CodedAgents" + IXP_COMMUNICATIONS_MINING = "IXPCommunicationsMining" + ENTERPRISE_CONTEXT_SERVICE = "EnterpriseContextService" + MCP = "MCP" + A2A = "A2A" + SERVERLESS = "Serverless" + DOCUMENT_UNDERSTANDING = "DocumentUnderstanding" + + +class VerbosityLevel(StrEnum): + VERBOSE = "Verbose" + TRACE = "Trace" + INFORMATION = "Information" + WARNING = "Warning" + ERROR = "Error" + CRITICAL = "Critical" + OFF = "Off" + + +class ExecutionType(StrEnum): + DEBUG = "Debug" + RUNTIME = "Runtime" + + +# Int→StrEnum lookup tables for converting raw OTEL attribute integers +_EXECUTION_TYPE_BY_INT: dict[int, ExecutionType] = { + 0: ExecutionType.DEBUG, + 1: ExecutionType.RUNTIME, +} + +_VERBOSITY_LEVEL_BY_INT: dict[int, VerbosityLevel] = { + 0: VerbosityLevel.VERBOSE, + 1: VerbosityLevel.TRACE, + 2: VerbosityLevel.INFORMATION, + 3: VerbosityLevel.WARNING, + 4: VerbosityLevel.ERROR, + 5: VerbosityLevel.CRITICAL, + 6: VerbosityLevel.OFF, +} + +_SOURCE_BY_INT: dict[int, SpanSource] = { + 0: SpanSource.TESTING, + 1: SpanSource.AGENTS, + 2: SpanSource.PROCESS_ORCHESTRATION, + 3: SpanSource.API_WORKFLOWS, + 4: SpanSource.ROBOTS, + 5: SpanSource.CONVERSATIONAL_AGENTS_SERVICE, + 6: SpanSource.INTEGRATION_SERVICE_TRIGGER, + 7: SpanSource.PLAYGROUND, + 8: SpanSource.GOVERNANCE, + 9: SpanSource.IXP_UNSTRUCTURED_AND_COMPLEX_DOCUMENTS, + 10: SpanSource.CODED_AGENTS, + 11: SpanSource.IXP_COMMUNICATIONS_MINING, + 12: SpanSource.ENTERPRISE_CONTEXT_SERVICE, + 13: SpanSource.MCP, + 14: SpanSource.A2A, + 15: SpanSource.SERVERLESS, + 16: SpanSource.DOCUMENT_UNDERSTANDING, +} + +_IntEnumT = TypeVar("_IntEnumT") + + +def _enum_from_int(table: dict[int, _IntEnumT], raw: Any) -> Optional[_IntEnumT]: + """Map a raw OTEL int attribute to its enum member, or None. + + Returns None for missing / non-int input — and explicitly for ``bool``, + since ``bool`` is an ``int`` subclass (``True == 1``) and would otherwise + be coerced to the value-1 member. + """ + if isinstance(raw, bool) or not isinstance(raw, int): + return None + return table.get(raw) @lru_cache(maxsize=1) @@ -65,16 +162,6 @@ class AttachmentDirection(IntEnum): OUT = 2 -class VerbosityLevel(IntEnum): - VERBOSE = 0 - TRACE = 1 - INFORMATION = 2 - WARNING = 3 - ERROR = 4 - CRITICAL = 5 - OFF = 6 - - class SpanAttachment(BaseModel): """Represents an attachment in the UiPath tracing system.""" @@ -106,20 +193,24 @@ class UiPathSpan: parent_id: Optional[str] = None # 16-char hex (OTEL span ID format) start_time: str = field(default_factory=lambda: datetime.now().isoformat()) end_time: str = field(default_factory=lambda: datetime.now().isoformat()) - status: int = 1 + status: SpanStatus = SpanStatus.OK created_at: str = field(default_factory=lambda: datetime.now().isoformat() + "Z") updated_at: str = field(default_factory=lambda: datetime.now().isoformat() + "Z") + # Default to None (not "") when unset; to_dict() then omits these keys + # entirely. The v3 ingest endpoint binds them to Guid fields: "" crashes the + # serializer, and even null fails for the required OrganizationId/FolderKey. + # In the platform runtime these are always set to real GUIDs. organization_id: Optional[str] = field( - default_factory=lambda: env.get("UIPATH_ORGANIZATION_ID", "") + default_factory=lambda: env.get("UIPATH_ORGANIZATION_ID") or None ) tenant_id: Optional[str] = field( - default_factory=lambda: env.get("UIPATH_TENANT_ID", "") + default_factory=lambda: env.get("UIPATH_TENANT_ID") or None ) expiry_time_utc: Optional[str] = None folder_key: Optional[str] = field( - default_factory=lambda: env.get("UIPATH_FOLDER_KEY", "") + default_factory=lambda: env.get("UIPATH_FOLDER_KEY") or None ) - source: int = DEFAULT_SOURCE + source: SpanSource = SpanSource.CODED_AGENTS span_type: str = "Coded Agents" process_key: Optional[str] = field( default_factory=lambda: env.get("UIPATH_PROCESS_UUID") @@ -131,9 +222,9 @@ class UiPathSpan: job_key: Optional[str] = field(default_factory=lambda: env.get("UIPATH_JOB_KEY")) # Top-level fields for internal tracing schema - execution_type: Optional[int] = None + execution_type: Optional[ExecutionType] = None agent_version: Optional[str] = None - verbosity_level: Optional[int] = None + verbosity_level: Optional[VerbosityLevel] = None attachments: Optional[List[SpanAttachment]] = None def to_dict(self, serialize_attributes: bool = True) -> Dict[str, Any]: @@ -182,9 +273,16 @@ def to_dict(self, serialize_attributes: bool = True) -> Dict[str, Any]: "JobKey": self.job_key, "ReferenceId": self.reference_id, "ExecutionType": self.execution_type, - "AgentVersion": self.agent_version, + # v3 ingest (SpanV3Req) has no AgentVersion field; the agent version + # is carried by ReferenceVersion (pairs with ReferenceId above). + "ReferenceVersion": self.agent_version, "Attachments": attachments_out, } + # Omit Guid-typed keys when unset — v3 binds them to Guid columns and + # rejects null/"". When present (platform runtime) they hold real GUIDs. + for guid_key in ("OrganizationId", "TenantId", "FolderKey"): + if result[guid_key] is None: + del result[guid_key] if self.verbosity_level is not None: result["VerbosityLevel"] = self.verbosity_level return result @@ -264,9 +362,9 @@ def otel_span_to_uipath_span( attributes_dict: dict[str, Any] = dict(otel_attrs) if otel_attrs else {} # Map status - status = 1 # Default to OK + status = SpanStatus.OK if otel_span.status.status_code == StatusCode.ERROR: - status = 2 # Error + status = SpanStatus.ERROR attributes_dict["error"] = otel_span.status.description # Process inputs - avoid redundant parsing if already parsed @@ -332,17 +430,33 @@ def otel_span_to_uipath_span( span_type_value = attributes_dict.get("span_type", "OpenTelemetry") span_type = str(span_type_value) - # Top-level fields for internal tracing schema - execution_type = attributes_dict.get("executionType") + # Top-level fields for internal tracing schema. The int->enum lookups go + # through _enum_from_int so they all ignore bools/non-ints identically. + execution_type = _enum_from_int( + _EXECUTION_TYPE_BY_INT, attributes_dict.get("executionType") + ) agent_version = attributes_dict.get("agentVersion") reference_id = attributes_dict.get("agentId") or attributes_dict.get( "referenceId" ) - verbosity_level = attributes_dict.get("verbosityLevel") + verbosity_level = _enum_from_int( + _VERBOSITY_LEVEL_BY_INT, attributes_dict.get("verbosityLevel") + ) - # Source: override via uipath.source attribute, else DEFAULT_SOURCE - uipath_source = attributes_dict.get("uipath.source") - source = uipath_source if isinstance(uipath_source, int) else DEFAULT_SOURCE + # Source: override via uipath.source attribute, else CodedAgents. + # A real int that isn't a known source is relabeled CodedAgents but + # logged — v3 ingest rejects raw integers, so it can't be forwarded. + uipath_source_raw = attributes_dict.get("uipath.source") + source = _enum_from_int(_SOURCE_BY_INT, uipath_source_raw) + if source is None: + if isinstance(uipath_source_raw, int) and not isinstance( + uipath_source_raw, bool + ): + logger.warning( + "Unknown uipath.source int %s; defaulting to CodedAgents", + uipath_source_raw, + ) + source = SpanSource.CODED_AGENTS attachments = None attachments_data = attributes_dict.get("attachments") diff --git a/packages/uipath-platform/tests/services/test_span_utils.py b/packages/uipath-platform/tests/services/test_span_utils.py index 35c2b78e7..9bd53b43b 100644 --- a/packages/uipath-platform/tests/services/test_span_utils.py +++ b/packages/uipath-platform/tests/services/test_span_utils.py @@ -1,4 +1,5 @@ import json +import logging import os from datetime import datetime from unittest.mock import Mock, patch @@ -8,6 +9,13 @@ from opentelemetry.trace import SpanContext, StatusCode from uipath.platform.common import UiPathSpan, _SpanUtils +from uipath.platform.common._span_utils import ( + _SOURCE_BY_INT, + ExecutionType, + SpanSource, + SpanStatus, + VerbosityLevel, +) from uipath.platform.common.constants import ( ENV_PROJECT_KEY, ENV_UIPATH_AGENT_ID, @@ -25,6 +33,102 @@ def _clear_id_cache(): _read_config_id.cache_clear() +class TestStrEnums: + def test_span_status_string_values(self): + assert SpanStatus.UNSET == "Unset" + assert SpanStatus.OK == "Ok" + assert SpanStatus.ERROR == "Error" + assert SpanStatus.RUNNING == "Running" + assert SpanStatus.RESTRICTED == "Restricted" + assert SpanStatus.CANCELLED == "Cancelled" + + def test_span_source_string_values(self): + assert SpanSource.CODED_AGENTS == "CodedAgents" + assert SpanSource.AGENTS == "Agents" + assert SpanSource.PROCESS_ORCHESTRATION == "ProcessOrchestration" + assert SpanSource.API_WORKFLOWS == "ApiWorkflows" + assert SpanSource.ROBOTS == "Robots" + + def test_verbosity_level_string_values(self): + assert VerbosityLevel.VERBOSE == "Verbose" + assert VerbosityLevel.TRACE == "Trace" + assert VerbosityLevel.INFORMATION == "Information" + assert VerbosityLevel.WARNING == "Warning" + assert VerbosityLevel.ERROR == "Error" + assert VerbosityLevel.CRITICAL == "Critical" + assert VerbosityLevel.OFF == "Off" + + def test_execution_type_string_values(self): + assert ExecutionType.DEBUG == "Debug" + assert ExecutionType.RUNTIME == "Runtime" + + def test_enums_are_strings(self): + assert isinstance(SpanStatus.OK, str) + assert isinstance(SpanSource.CODED_AGENTS, str) + assert isinstance(VerbosityLevel.INFORMATION, str) + assert isinstance(ExecutionType.RUNTIME, str) + + +class TestGuidFieldDefaults: + """Guid-typed env-derived fields must default to None, not "". + + v3 ingest (SpanV3Req) binds OrganizationId/FolderKey/TenantId to Guid + fields; an empty string crashes the serializer (400). When the env vars + are unset the span must omit these (None) rather than send "". + """ + + def test_guid_fields_none_when_env_unset( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + for var in ("UIPATH_ORGANIZATION_ID", "UIPATH_TENANT_ID", "UIPATH_FOLDER_KEY"): + monkeypatch.delenv(var, raising=False) + + span = UiPathSpan(id="s", trace_id="t", name="n", attributes={}) + + assert span.organization_id is None + assert span.tenant_id is None + assert span.folder_key is None + + def test_empty_string_env_coerced_to_none( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + for var in ("UIPATH_ORGANIZATION_ID", "UIPATH_TENANT_ID", "UIPATH_FOLDER_KEY"): + monkeypatch.setenv(var, "") + + span = UiPathSpan(id="s", trace_id="t", name="n", attributes={}) + + assert span.organization_id is None + assert span.tenant_id is None + assert span.folder_key is None + + def test_none_guid_fields_omitted_from_to_dict( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Unset Guid fields are omitted from the wire payload, not sent as null.""" + for var in ("UIPATH_ORGANIZATION_ID", "UIPATH_TENANT_ID", "UIPATH_FOLDER_KEY"): + monkeypatch.delenv(var, raising=False) + + d = UiPathSpan(id="s", trace_id="t", name="n", attributes={}).to_dict() + + assert "OrganizationId" not in d + assert "TenantId" not in d + assert "FolderKey" not in d + + def test_set_guid_fields_present_in_to_dict( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Guid fields that are set are still emitted.""" + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-1") + monkeypatch.setenv("UIPATH_FOLDER_KEY", "folder-1") + monkeypatch.delenv("UIPATH_TENANT_ID", raising=False) + + d = UiPathSpan(id="s", trace_id="t", name="n", attributes={}).to_dict() + + assert d["OrganizationId"] == "org-1" + assert d["FolderKey"] == "folder-1" + assert "TenantId" not in d + + class TestOTelToUiPathSpan: """OTEL attribute -> top-level UiPathSpan field mapping. @@ -36,16 +140,18 @@ class TestOTelToUiPathSpan: """ ATTRIBUTE_FIELD_MAP = [ - ("executionType", "execution_type", "ExecutionType", 1), - ("agentVersion", "agent_version", "AgentVersion", "1.2.3"), - ("agentId", "reference_id", "ReferenceId", "ref-abc"), - ("verbosityLevel", "verbosity_level", "VerbosityLevel", 6), + # (otel_attr, span_field, top_level_key, otel_input_int, expected_output) + ("executionType", "execution_type", "ExecutionType", 1, ExecutionType.RUNTIME), + ("agentVersion", "agent_version", "ReferenceVersion", "1.2.3", "1.2.3"), + ("agentId", "reference_id", "ReferenceId", "ref-abc", "ref-abc"), + ("verbosityLevel", "verbosity_level", "VerbosityLevel", 6, VerbosityLevel.OFF), ] @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) def test_attributes_map_to_top_level_fields(self) -> None: attrs = { - otel_attr: value for otel_attr, _, _, value in self.ATTRIBUTE_FIELD_MAP + otel_attr: otel_input + for otel_attr, _, _, otel_input, _ in self.ATTRIBUTE_FIELD_MAP } mock_span = Mock(spec=OTelSpan) @@ -68,9 +174,15 @@ def test_attributes_map_to_top_level_fields(self) -> None: uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) span_dict = uipath_span.to_dict() - for _, span_field, top_level_key, value in self.ATTRIBUTE_FIELD_MAP: - assert getattr(uipath_span, span_field) == value, span_field - assert span_dict[top_level_key] == value, top_level_key + for ( + _, + span_field, + top_level_key, + _, + expected_output, + ) in self.ATTRIBUTE_FIELD_MAP: + assert getattr(uipath_span, span_field) == expected_output, span_field + assert span_dict[top_level_key] == expected_output, top_level_key @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) def test_verbosity_level_omitted_when_unset(self) -> None: @@ -390,7 +502,7 @@ def test_otel_span_to_uipath_span(self): # Verify the conversion assert isinstance(uipath_span, UiPathSpan) assert uipath_span.name == "test-span" - assert uipath_span.status == 1 # OK + assert uipath_span.status == SpanStatus.OK assert uipath_span.span_type == "CustomSpanType" # Verify IDs are in OTEL hex format @@ -412,7 +524,7 @@ def test_otel_span_to_uipath_span(self): mock_span.status.description = "Test error description" mock_span.status.status_code = StatusCode.ERROR uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) - assert uipath_span.status == 2 # Error + assert uipath_span.status == SpanStatus.ERROR @patch.dict( os.environ, @@ -564,12 +676,12 @@ def test_uipath_span_includes_execution_type(self): uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) span_dict = uipath_span.to_dict() - assert span_dict["ExecutionType"] == 0 - assert uipath_span.execution_type == 0 + assert span_dict["ExecutionType"] == ExecutionType.DEBUG + assert uipath_span.execution_type == ExecutionType.DEBUG @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) def test_uipath_span_includes_agent_version(self): - """Test that agentVersion from attributes becomes top-level AgentVersion.""" + """Test that agentVersion from attributes becomes top-level ReferenceVersion.""" mock_span = Mock(spec=OTelSpan) trace_id = 0x123456789ABCDEF0123456789ABCDEF0 @@ -591,7 +703,7 @@ def test_uipath_span_includes_agent_version(self): uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) span_dict = uipath_span.to_dict() - assert span_dict["AgentVersion"] == "2.0.0" + assert span_dict["ReferenceVersion"] == "2.0.0" assert uipath_span.agent_version == "2.0.0" @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) @@ -618,8 +730,8 @@ def test_uipath_span_execution_type_and_agent_version_both(self): uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) span_dict = uipath_span.to_dict() - assert span_dict["ExecutionType"] == 1 - assert span_dict["AgentVersion"] == "1.0.0" + assert span_dict["ExecutionType"] == ExecutionType.RUNTIME + assert span_dict["ReferenceVersion"] == "1.0.0" @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) def test_uipath_span_missing_execution_type_and_agent_version(self): @@ -646,11 +758,11 @@ def test_uipath_span_missing_execution_type_and_agent_version(self): span_dict = uipath_span.to_dict() assert span_dict["ExecutionType"] is None - assert span_dict["AgentVersion"] is None + assert span_dict["ReferenceVersion"] is None @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) def test_uipath_span_source_defaults_to_coded_agents(self): - """Test that Source defaults to 10 (CodedAgents) and ignores attributes.source.""" + """Test that Source defaults to CodedAgents and ignores attributes.source.""" mock_span = Mock(spec=OTelSpan) trace_id = 0x123456789ABCDEF0123456789ABCDEF0 @@ -673,9 +785,9 @@ def test_uipath_span_source_defaults_to_coded_agents(self): uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) span_dict = uipath_span.to_dict() - # Top-level Source should be 10 (CodedAgents), string "runtime" is ignored - assert uipath_span.source == 10 - assert span_dict["Source"] == 10 + # Top-level Source should be CodedAgents, string "runtime" is ignored + assert uipath_span.source == SpanSource.CODED_AGENTS + assert span_dict["Source"] == "CodedAgents" # attributes.source string should still be in Attributes JSON attrs = json.loads(span_dict["Attributes"]) @@ -707,9 +819,200 @@ def test_uipath_span_source_override_with_uipath_source(self): span_dict = uipath_span.to_dict() # uipath.source overrides - low-code agents use 1 (Agents) - assert uipath_span.source == 1 - assert span_dict["Source"] == 1 + assert uipath_span.source == SpanSource.AGENTS + assert span_dict["Source"] == "Agents" # String source still in Attributes JSON attrs = json.loads(span_dict["Attributes"]) assert attrs["source"] == "runtime" + + @pytest.mark.parametrize(("source_int", "expected"), list(_SOURCE_BY_INT.items())) + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_uipath_source_int_maps_to_full_source_enum( + self, source_int: int, expected: SpanSource + ) -> None: + """Every server-known SourceEnum int round-trips (no silent relabeling).""" + mock_span = Mock(spec=OTelSpan) + mock_context = SpanContext( + trace_id=0x123456789ABCDEF0123456789ABCDEF0, + span_id=0x0123456789ABCDEF, + is_remote=False, + ) + mock_span.get_span_context.return_value = mock_context + mock_span.name = "test-span" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + mock_span.attributes = {"uipath.source": source_int} + mock_span.events = [] + mock_span.links = [] + now_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = now_ns + mock_span.end_time = now_ns + 1_000_000 + + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + + assert uipath_span.source == expected + assert uipath_span.to_dict()["Source"] == expected.value + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_unknown_uipath_source_int_warns_and_defaults( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """An unmapped uipath.source int is relabeled CodedAgents and logged.""" + mock_span = Mock(spec=OTelSpan) + mock_context = SpanContext( + trace_id=0x123456789ABCDEF0123456789ABCDEF0, + span_id=0x0123456789ABCDEF, + is_remote=False, + ) + mock_span.get_span_context.return_value = mock_context + mock_span.name = "test-span" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + mock_span.attributes = {"uipath.source": 999} + mock_span.events = [] + mock_span.links = [] + now_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = now_ns + mock_span.end_time = now_ns + 1_000_000 + + with caplog.at_level(logging.WARNING): + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + + assert uipath_span.source == SpanSource.CODED_AGENTS + assert any("999" in record.message for record in caplog.records) + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_bool_attributes_do_not_map_as_ints(self) -> None: + """bool is an int subclass; True must not map to the value-1 enum member.""" + mock_span = Mock(spec=OTelSpan) + mock_context = SpanContext( + trace_id=0x123456789ABCDEF0123456789ABCDEF0, + span_id=0x0123456789ABCDEF, + is_remote=False, + ) + mock_span.get_span_context.return_value = mock_context + mock_span.name = "test-span" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + mock_span.attributes = { + "executionType": True, + "verbosityLevel": True, + "uipath.source": True, + } + mock_span.events = [] + mock_span.links = [] + now_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = now_ns + mock_span.end_time = now_ns + 1_000_000 + + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + + assert uipath_span.execution_type is None + assert uipath_span.verbosity_level is None + assert uipath_span.source == SpanSource.CODED_AGENTS + + +class TestUiPathSpanDictUsesStrings: + def test_default_status_is_ok_string(self): + span = UiPathSpan( + id="a" * 16, + trace_id="b" * 32, + name="test", + attributes={}, + ) + d = span.to_dict() + assert d["Status"] == "Ok" + + def test_default_source_is_coded_agents_string(self): + span = UiPathSpan( + id="a" * 16, + trace_id="b" * 32, + name="test", + attributes={}, + ) + d = span.to_dict() + assert d["Source"] == "CodedAgents" + + def test_verbosity_level_serializes_as_string(self): + span = UiPathSpan( + id="a" * 16, + trace_id="b" * 32, + name="test", + attributes={}, + verbosity_level=VerbosityLevel.OFF, + ) + d = span.to_dict() + assert d["VerbosityLevel"] == "Off" + + def test_execution_type_serializes_as_string(self): + span = UiPathSpan( + id="a" * 16, + trace_id="b" * 32, + name="test", + attributes={}, + execution_type=ExecutionType.RUNTIME, + ) + d = span.to_dict() + assert d["ExecutionType"] == "Runtime" + + +class TestOtelSpanConversionUsesStrEnums: + def _make_mock_span(self, status_code=StatusCode.OK, attributes=None): + from datetime import datetime + from unittest.mock import Mock + + from opentelemetry.trace import SpanContext + + mock_span = Mock() + mock_context = SpanContext( + trace_id=0x123456789ABCDEF0123456789ABCDEF0, + span_id=0x0123456789ABCDEF, + is_remote=False, + ) + mock_span.get_span_context.return_value = mock_context + mock_span.name = "test-span" + mock_span.parent = None + mock_span.status.status_code = status_code + mock_span.status.description = None + mock_span.attributes = attributes or {} + mock_span.events = [] + mock_span.links = [] + now_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = now_ns + mock_span.end_time = now_ns + 1_000_000 + return mock_span + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_ok_status_maps_to_str_enum(self): + span = _SpanUtils.otel_span_to_uipath_span(self._make_mock_span()) + assert span.status == SpanStatus.OK + assert span.to_dict()["Status"] == "Ok" + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_error_status_maps_to_str_enum(self): + mock_span = self._make_mock_span(status_code=StatusCode.ERROR) + mock_span.status.description = "something went wrong" + span = _SpanUtils.otel_span_to_uipath_span(mock_span) + assert span.status == SpanStatus.ERROR + assert span.to_dict()["Status"] == "Error" + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_default_source_is_coded_agents(self): + span = _SpanUtils.otel_span_to_uipath_span(self._make_mock_span()) + assert span.source == SpanSource.CODED_AGENTS + assert span.to_dict()["Source"] == "CodedAgents" + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_execution_type_int_maps_to_str_enum(self): + mock_span = self._make_mock_span(attributes={"executionType": 1}) + span = _SpanUtils.otel_span_to_uipath_span(mock_span) + assert span.execution_type == ExecutionType.RUNTIME + assert span.to_dict()["ExecutionType"] == "Runtime" + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_verbosity_level_int_maps_to_str_enum(self): + mock_span = self._make_mock_span(attributes={"verbosityLevel": 6}) + span = _SpanUtils.otel_span_to_uipath_span(mock_span) + assert span.verbosity_level == VerbosityLevel.OFF + assert span.to_dict()["VerbosityLevel"] == "Off" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 378bd1204..b96c97c7d 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.80" +version = "0.1.81" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 37b64b956..24450fcec 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.11.14" +version = "2.11.15" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.21, <0.6.0", "uipath-runtime>=0.11.4, <0.12.0", - "uipath-platform>=0.1.79, <0.2.0", + "uipath-platform>=0.1.81, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/src/uipath/tracing/__init__.py b/packages/uipath/src/uipath/tracing/__init__.py index e6c37bc99..4fcf2b2db 100644 --- a/packages/uipath/src/uipath/tracing/__init__.py +++ b/packages/uipath/src/uipath/tracing/__init__.py @@ -5,6 +5,7 @@ AttachmentDirection, AttachmentProvider, SpanAttachment, + SpanStatus, VerbosityLevel, ) @@ -12,7 +13,6 @@ from ._otel_exporters import ( # noqa: D104 JsonLinesFileExporter, LlmOpsHttpExporter, - SpanStatus, ) __all__ = [ diff --git a/packages/uipath/src/uipath/tracing/_live_tracking_processor.py b/packages/uipath/src/uipath/tracing/_live_tracking_processor.py index 85bcca1ba..4b0aa0b92 100644 --- a/packages/uipath/src/uipath/tracing/_live_tracking_processor.py +++ b/packages/uipath/src/uipath/tracing/_live_tracking_processor.py @@ -5,7 +5,8 @@ from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor from uipath.core.tracing import UiPathTraceSettings -from uipath.tracing._otel_exporters import LlmOpsHttpExporter, SpanStatus +from uipath.platform.common._span_utils import SpanStatus +from uipath.tracing._otel_exporters import LlmOpsHttpExporter logger = logging.getLogger(__name__) @@ -46,7 +47,7 @@ def __init__( ) def _upsert_span_async( - self, span: Span | ReadableSpan, status_override: int | None = None + self, span: Span | ReadableSpan, status_override: SpanStatus | None = None ) -> None: """Run upsert_span in a background thread without blocking. @@ -57,7 +58,7 @@ def _upsert_span_async( def _upsert(): try: - if status_override: + if status_override is not None: self.exporter.upsert_span(span, status_override=status_override) else: self.exporter.upsert_span(span) diff --git a/packages/uipath/src/uipath/tracing/_otel_exporters.py b/packages/uipath/src/uipath/tracing/_otel_exporters.py index d2bf3a7c1..e76177cc5 100644 --- a/packages/uipath/src/uipath/tracing/_otel_exporters.py +++ b/packages/uipath/src/uipath/tracing/_otel_exporters.py @@ -13,6 +13,7 @@ from uipath._utils._ssl_context import get_httpx_client_kwargs from uipath.platform.common import _SpanUtils +from uipath.platform.common._span_utils import SpanStatus from uipath.platform.common.retry import NON_RETRYABLE_STATUS_CODES logger = logging.getLogger(__name__) @@ -24,17 +25,6 @@ def _normalize_process_key(value: Optional[str]) -> Optional[str]: return None if not value or value == _NIL_UUID else value -class SpanStatus: - """Span status values matching LLMOps StatusEnum.""" - - UNSET = 0 - OK = 1 - ERROR = 2 - RUNNING = 3 - RESTRICTED = 4 - CANCELLED = 5 - - def _safe_parse_json(s: Any) -> Any: """Safely parse a JSON string, returning the original if not a string or on error.""" if not isinstance(s, str): @@ -106,11 +96,6 @@ class LlmOpsHttpExporter(SpanExporter): # Add more mappings as needed } - class Status: - SUCCESS = 1 - ERROR = 2 - INTERRUPTED = 3 - def __init__( self, trace_id: Optional[str] = None, @@ -148,7 +133,7 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: return SpanExportResult.SUCCESS logger.debug( - f"Exporting {len(spans)} spans to {self.base_url}/api/Traces/spans" + f"Exporting {len(spans)} spans to {self.base_url}/api/Traces/v3/spans" ) # Use optimized path: keep attributes as dict for processing @@ -188,7 +173,7 @@ def force_flush(self, timeout_millis: int = 30000) -> bool: def upsert_span( self, span: ReadableSpan, - status_override: Optional[int] = None, + status_override: Optional[SpanStatus] = None, ) -> SpanExportResult: """Upsert a single span to LLMOps for real-time state updates. @@ -312,12 +297,15 @@ def _map_tool_call_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any return result - def _determine_status(self, error: Optional[Any]) -> int: + def _determine_status(self, error: Optional[Any]) -> SpanStatus: if error: if isinstance(error, str) and error.startswith("GraphInterrupt("): - return self.Status.INTERRUPTED - return self.Status.ERROR - return self.Status.SUCCESS + # GraphInterrupt = HITL pause, not a failure. Server StatusEnum has + # no Interrupted member; preserves prior wire behavior (old int + # 3 == Running). + return SpanStatus.RUNNING + return SpanStatus.ERROR + return SpanStatus.OK def _process_span_attributes(self, span_data: Dict[str, Any]) -> None: """Extracts, transforms, and maps attributes for a span in-place. @@ -389,7 +377,9 @@ def _process_span_attributes(self, span_data: Dict[str, Any]) -> None: def _build_url(self, span_list: list[Dict[str, Any]]) -> str: """Construct the URL for the API request.""" trace_id = str(span_list[0]["TraceId"]) - return f"{self.base_url}/api/Traces/spans?traceId={trace_id}&source=CodedAgents" + return ( + f"{self.base_url}/api/Traces/v3/spans?traceId={trace_id}&source=CodedAgents" + ) def _send_with_retries( self, url: str, payload: list[Dict[str, Any]], max_retries: int = 4 diff --git a/packages/uipath/tests/tracing/test_otel_exporters.py b/packages/uipath/tests/tracing/test_otel_exporters.py index fc5a370c0..2ecf930fc 100644 --- a/packages/uipath/tests/tracing/test_otel_exporters.py +++ b/packages/uipath/tests/tracing/test_otel_exporters.py @@ -7,10 +7,8 @@ from opentelemetry.sdk.trace import ReadableSpan from opentelemetry.sdk.trace.export import SpanExportResult -from uipath.tracing._otel_exporters import ( - LlmOpsHttpExporter, - SpanStatus, -) +from uipath.platform.common._span_utils import SpanStatus +from uipath.tracing._otel_exporters import LlmOpsHttpExporter @pytest.fixture @@ -54,7 +52,7 @@ def exporter(mock_env_vars): exporter = LlmOpsHttpExporter() # Mock _build_url to include query parameters as in the actual implementation exporter._build_url = MagicMock( # type: ignore - return_value="https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/spans?traceId=test-trace-id&source=CodedAgents" + return_value="https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/v3/spans?traceId=test-trace-id&source=CodedAgents" ) yield exporter @@ -107,7 +105,7 @@ def test_export_success(exporter, mock_span): [{"span": "data", "TraceId": "test-trace-id"}] ) exporter.http_client.post.assert_called_once_with( - "https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/spans?traceId=test-trace-id&source=CodedAgents", + "https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/v3/spans?traceId=test-trace-id&source=CodedAgents", json=[{"span": "data", "TraceId": "test-trace-id"}], ) @@ -277,6 +275,39 @@ def test_send_with_retries_success(): ) +def test_build_url_uses_v3_endpoint(mock_env_vars): + """_build_url must point to /api/Traces/v3/spans, not /api/Traces/spans.""" + with patch("uipath.tracing._otel_exporters.httpx.Client"): + exporter = LlmOpsHttpExporter() + span_list = [{"TraceId": "ab" * 16}] + url = exporter._build_url(span_list) + assert "/api/Traces/v3/spans" in url + # Ensure the v2 path (without /v3/) is not present + assert "/api/Traces/spans" not in url.replace("/api/Traces/v3/spans", "") + + +def test_determine_status_ok_returns_string(mock_env_vars): + with patch("uipath.tracing._otel_exporters.httpx.Client"): + exporter = LlmOpsHttpExporter() + assert exporter._determine_status(None) == "Ok" + assert exporter._determine_status(None) == SpanStatus.OK + + +def test_determine_status_error_returns_string(mock_env_vars): + with patch("uipath.tracing._otel_exporters.httpx.Client"): + exporter = LlmOpsHttpExporter() + assert exporter._determine_status("some error") == "Error" + assert exporter._determine_status("some error") == SpanStatus.ERROR + + +def test_determine_status_graph_interrupt_returns_running(mock_env_vars): + with patch("uipath.tracing._otel_exporters.httpx.Client"): + exporter = LlmOpsHttpExporter() + # GraphInterrupt is a HITL pause (still in-progress), not a terminal abort. + assert exporter._determine_status("GraphInterrupt()") == "Running" + assert exporter._determine_status("GraphInterrupt()") == SpanStatus.RUNNING + + class TestLangchainExporter(unittest.TestCase): def setUp(self): self.exporter = LlmOpsHttpExporter() @@ -685,7 +716,7 @@ def exporter_with_mocks(self, mock_env_vars): with patch("uipath.tracing._otel_exporters.httpx.Client"): exporter = LlmOpsHttpExporter() exporter._build_url = MagicMock( # type: ignore - return_value="https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/spans?traceId=test-trace-id&source=CodedAgents" + return_value="https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/v3/spans?traceId=test-trace-id&source=CodedAgents" ) yield exporter @@ -820,7 +851,88 @@ def test_uipath_tracing_reexports_verbosity_level(self) -> None: from uipath.tracing import VerbosityLevel as _TracingVerbosity assert _TracingVerbosity is _CommonVerbosity - assert _TracingVerbosity.OFF == 6 + assert _TracingVerbosity.OFF == "Off" + + +class TestV3EndToEnd: + """Integration-style tests verifying string enum values reach the v3 URL end-to-end.""" + + def _make_real_otel_span(self, status_code=None): + """Build a minimal mock OTel ReadableSpan with a real SpanContext.""" + from datetime import datetime + from unittest.mock import Mock + + from opentelemetry.trace import SpanContext, StatusCode + + if status_code is None: + status_code = StatusCode.OK + + mock_span = Mock(spec=ReadableSpan) + mock_context = SpanContext( + trace_id=0xABCDEF1234567890ABCDEF1234567890, + span_id=0x1234567890ABCDEF, + is_remote=False, + ) + mock_span.get_span_context.return_value = mock_context + mock_span.name = "test-v3-span" + mock_span.parent = None + mock_span.status.status_code = status_code + mock_span.status.description = None + mock_span.attributes = {"uipath.custom_instrumentation": True} + mock_span.events = [] + mock_span.links = [] + now_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = now_ns + mock_span.end_time = now_ns + 1_000_000 + return mock_span + + def test_export_posts_to_v3_url_with_string_enums(self, mock_env_vars): + """Exporting a span must POST to /api/Traces/v3/spans with string Status and Source.""" + from opentelemetry.trace import StatusCode + + otel_span = self._make_real_otel_span(status_code=StatusCode.OK) + + with patch("uipath.tracing._otel_exporters.httpx.Client") as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_client.post.return_value = mock_response + + exporter = LlmOpsHttpExporter() + result = exporter.export([otel_span]) + + assert result == SpanExportResult.SUCCESS + + # Verify the POST was made + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + + # URL must contain v3/spans + posted_url = ( + call_args.args[0] if call_args.args else call_args.kwargs.get("url", "") + ) + assert "v3/spans" in posted_url, f"Expected v3/spans in URL, got: {posted_url}" + + # Body must contain string enum values, not integers + payload: list[dict[str, object]] = ( + call_args.kwargs.get("json") or call_args.args[1] + ) + assert len(payload) == 1 + span_payload = payload[0] + + # Status should be the string "Ok", not integer 1 + assert span_payload["Status"] == "Ok", ( + f"Expected Status='Ok' (string), got {span_payload['Status']!r}" + ) + assert span_payload["Status"] != 1, "Status must not be integer 1 (v2 format)" + + # Source should be the string "CodedAgents", not integer 10 + assert span_payload["Source"] == "CodedAgents", ( + f"Expected Source='CodedAgents' (string), got {span_payload['Source']!r}" + ) + assert span_payload["Source"] != 10, "Source must not be integer 10 (v2 format)" if __name__ == "__main__": diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index ee14e052a..02be41e30 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.14" +version = "2.11.15" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.80" +version = "0.1.81" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From bda3cabf973bdcdba2b2d0eb9d34619633dee0ed Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Tue, 30 Jun 2026 12:28:03 +0300 Subject: [PATCH 129/191] feat: add wait until interrupt model (#1768) --- packages/uipath-platform/pyproject.toml | 4 +- .../src/uipath/platform/common/__init__.py | 2 + .../platform/common/interrupt_models.py | 19 ++++- .../platform/resume_triggers/_protocol.py | 73 ++++++++++++++----- .../tests/services/test_hitl.py | 46 ++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 8 +- packages/uipath/uv.lock | 12 +-- 8 files changed, 133 insertions(+), 33 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index d5e3f9b46..b04ecf4e1 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.81" +version = "0.1.82" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" @@ -8,7 +8,7 @@ dependencies = [ "httpx>=0.28.1", "tenacity>=9.0.0", "truststore>=0.10.1", - "uipath-core>=0.5.25, <0.6.0", + "uipath-core>=0.5.26, <0.6.0", "pydantic-function-models>=0.1.11", "sqlparse>=0.5.5", ] diff --git a/packages/uipath-platform/src/uipath/platform/common/__init__.py b/packages/uipath-platform/src/uipath/platform/common/__init__.py index 1a08d7869..5f8fbbf75 100644 --- a/packages/uipath-platform/src/uipath/platform/common/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/common/__init__.py @@ -60,6 +60,7 @@ WaitJobRaw, WaitSystemAgent, WaitTask, + WaitUntil, ) from .paging import PagedResult @@ -99,6 +100,7 @@ "DocumentExtractionValidation", "WaitDocumentExtractionValidation", "WaitIntegrationEvent", + "WaitUntil", "RequestSpec", "Endpoint", "UiPathUrl", diff --git a/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py b/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py index 3b2468551..ee9104c54 100644 --- a/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py +++ b/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py @@ -1,8 +1,9 @@ """Models for interrupt operations in UiPath platform.""" +from datetime import datetime, timezone from typing import Annotated, Any -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from uipath.platform.context_grounding.context_grounding_index import ( ContextGroundingIndex, @@ -279,3 +280,19 @@ class WaitIntegrationEvent(BaseModel): object_name: str filter_expression: str | None = None parameters: dict[str, str] | None = None + + +class WaitUntil(BaseModel): + """Model representing a wait until an absolute point in time.""" + + resume_time: datetime = Field(alias="resumeTime") + + model_config = ConfigDict(validate_by_name=True) + + @field_validator("resume_time") + @classmethod + def validate_resume_time(cls, value: datetime) -> datetime: + """Validate and normalize resume_time to a UTC instant.""" + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("resume_time must include timezone information") + return value.astimezone(timezone.utc) diff --git a/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py b/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py index b2dbae787..e27f18025 100644 --- a/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py +++ b/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py @@ -3,6 +3,7 @@ import json import os import uuid +from functools import cache from typing import Any from uipath.core.errors import ( @@ -49,6 +50,7 @@ WaitJobRaw, WaitSystemAgent, WaitTask, + WaitUntil, ) from uipath.platform.connections import EventArguments from uipath.platform.context_grounding import DeepRagStatus, IndexStatus @@ -129,12 +131,18 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: UiPathRuntimeError: If reading fails, job failed, API connection failed, trigger type is unknown, or HITL feedback retrieval failed. """ - uipath = UiPath() + + @cache + def get_uipath() -> UiPath: + return UiPath() match trigger.trigger_type: + case UiPathResumeTriggerType.TIMER: + return {"resumeTime": serialize_object(trigger.resume_time)} + case UiPathResumeTriggerType.TASK: if trigger.item_key: - task: Task = await uipath.tasks.retrieve_async( + task: Task = await get_uipath().tasks.retrieve_async( trigger.item_key, app_folder_key=trigger.folder_key, app_folder_path=trigger.folder_path, @@ -182,7 +190,7 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: case UiPathResumeTriggerType.JOB: if trigger.item_key: - job = await uipath.jobs.retrieve_async( + job = await get_uipath().jobs.retrieve_async( trigger.item_key, folder_key=trigger.folder_key, folder_path=trigger.folder_path, @@ -223,7 +231,7 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: f"Process did not finish successfully. Error: {job_error}", ) - output_data = await uipath.jobs.extract_output_async(job) + output_data = await get_uipath().jobs.extract_output_async(job) trigger_response = _try_convert_to_json_format(output_data) # if response is an empty dictionary, use job state as placeholder value @@ -239,9 +247,13 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: return trigger_response case UiPathResumeTriggerType.DEEP_RAG: if trigger.item_key: - deep_rag = await uipath.context_grounding.retrieve_deep_rag_async( - trigger.item_key, - index_name=self._extract_field("index_name", trigger.payload), + deep_rag = ( + await get_uipath().context_grounding.retrieve_deep_rag_async( + trigger.item_key, + index_name=self._extract_field( + "index_name", trigger.payload + ), + ) ) deep_rag_status = deep_rag.last_deep_rag_status @@ -279,7 +291,7 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: case UiPathResumeTriggerType.INDEX_INGESTION: if trigger.item_key: - index = await uipath.context_grounding.retrieve_by_id_async( + index = await get_uipath().context_grounding.retrieve_by_id_async( trigger.item_key ) @@ -319,7 +331,7 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: ) assert destination_path is not None try: - await uipath.context_grounding.download_batch_transform_result_async( + await get_uipath().context_grounding.download_batch_transform_result_async( trigger.item_key, destination_path, validate_status=True, @@ -349,10 +361,8 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: assert tag is not None try: - extraction_response = ( - await uipath.documents.retrieve_ixp_extraction_result_async( - project_id, tag, trigger.item_key - ) + extraction_response = await get_uipath().documents.retrieve_ixp_extraction_result_async( + project_id, tag, trigger.item_key ) except OperationNotCompleteException as e: raise UiPathPendingTriggerError( @@ -370,7 +380,7 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: assert project_id is not None assert tag is not None try: - escalation_response = await uipath.documents.retrieve_ixp_extraction_validation_result_async( + escalation_response = await get_uipath().documents.retrieve_ixp_extraction_validation_result_async( project_id, tag, trigger.item_key ) except OperationNotCompleteException as e: @@ -394,7 +404,7 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: case UiPathResumeTriggerType.API: if trigger.api_resume and trigger.api_resume.inbox_id: try: - return await uipath.jobs.retrieve_api_payload_async( + return await get_uipath().jobs.retrieve_api_payload_async( trigger.api_resume.inbox_id ) except Exception as e: @@ -407,12 +417,16 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: case UiPathResumeTriggerType.INBOX: if trigger.integration_resume and trigger.integration_resume.inbox_id: try: - inbox_payload = await uipath.jobs.retrieve_inbox_payload_async( - trigger.integration_resume.inbox_id + inbox_payload = ( + await get_uipath().jobs.retrieve_inbox_payload_async( + trigger.integration_resume.inbox_id + ) ) event_args = EventArguments.model_validate(inbox_payload) - return await uipath.connections.retrieve_event_payload_async( - event_args + return ( + await get_uipath().connections.retrieve_event_payload_async( + event_args + ) ) except Exception as e: raise UiPathFaultedTriggerError( @@ -484,6 +498,9 @@ async def create_trigger(self, suspend_value: Any) -> UiPathResumeTrigger: case UiPathResumeTriggerType.INBOX: await self._handle_inbox_trigger(suspend_value, resume_trigger) + case UiPathResumeTriggerType.TIMER: + self._handle_time_trigger(suspend_value, resume_trigger) + case UiPathResumeTriggerType.DEEP_RAG: await self._handle_deep_rag_job_trigger( suspend_value, resume_trigger @@ -570,6 +587,8 @@ def _determine_trigger_type(self, value: Any) -> UiPathResumeTriggerType: return UiPathResumeTriggerType.IXP_VS_ESCALATION if isinstance(value, WaitIntegrationEvent): return UiPathResumeTriggerType.INBOX + if isinstance(value, WaitUntil): + return UiPathResumeTriggerType.TIMER # default to API trigger return UiPathResumeTriggerType.API @@ -606,6 +625,8 @@ def _determine_trigger_name(self, value: Any) -> UiPathResumeTriggerName: return UiPathResumeTriggerName.EXTRACTION if isinstance(value, WaitIntegrationEvent): return UiPathResumeTriggerName.INBOX + if isinstance(value, WaitUntil): + return UiPathResumeTriggerName.TIMER # default to API trigger return UiPathResumeTriggerName.API @@ -979,6 +1000,20 @@ async def _handle_inbox_trigger( inbox_id=str(uuid.uuid4()), ) + def _handle_time_trigger( + self, value: WaitUntil, resume_trigger: UiPathResumeTrigger + ) -> None: + """Handle Timer-type resume triggers. + + Orchestrator expects timer resume triggers as a top-level + `resumeTime` value on the resume trigger DTO. + + Args: + value: The suspend value (WaitUntil) + resume_trigger: The resume trigger to populate + """ + resume_trigger.resume_time = value.resume_time + class UiPathResumeTriggerHandler: """Combined handler for creating and reading resume triggers. diff --git a/packages/uipath-platform/tests/services/test_hitl.py b/packages/uipath-platform/tests/services/test_hitl.py index 7395908a9..5151d7a7a 100644 --- a/packages/uipath-platform/tests/services/test_hitl.py +++ b/packages/uipath-platform/tests/services/test_hitl.py @@ -1,11 +1,13 @@ import json import uuid +from datetime import datetime, timedelta, timezone from typing import Any from unittest.mock import AsyncMock, patch import pytest from pytest_httpx import HTTPXMock from uipath.core.errors import ErrorCategory, UiPathFaultedTriggerError +from uipath.core.serialization import serialize_object from uipath.core.triggers import ( UiPathApiTrigger, UiPathIntegrationTrigger, @@ -39,6 +41,7 @@ WaitJobRaw, WaitSystemAgent, WaitTask, + WaitUntil, ) from uipath.platform.connections import Connection from uipath.platform.context_grounding import ( @@ -1214,6 +1217,21 @@ async def test_read_ixp_vs_escalation_trigger_unassigned( reader = UiPathResumeTriggerReader() await reader.read_trigger(resume_trigger) + @pytest.mark.anyio + async def test_read_timer_trigger_serializes_resume_time(self) -> None: + """Test reading a timer trigger returns JSON-safe resume time data.""" + resume_time = datetime(2026, 6, 27, 20, 14, 49, tzinfo=timezone.utc) + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.TIMER, + trigger_name=UiPathResumeTriggerName.TIMER, + resume_time=resume_time, + ) + + reader = UiPathResumeTriggerReader() + result = await reader.read_trigger(resume_trigger) + + assert result == {"resumeTime": serialize_object(resume_time)} + class TestHitlProcessor: """Tests for the HitlProcessor class.""" @@ -2063,6 +2081,34 @@ async def test_create_resume_trigger_wait_document_extraction_validation( assert resume_trigger.trigger_type == UiPathResumeTriggerType.IXP_VS_ESCALATION assert resume_trigger.item_key == operation_id + @pytest.mark.anyio + async def test_create_resume_trigger_wait_until_normalizes_to_utc(self) -> None: + """Test creating a timer resume trigger for WaitUntil.""" + local_resume_time = datetime( + 2026, + 6, + 27, + 23, + 14, + 49, + tzinfo=timezone(timedelta(hours=3)), + ) + wait_until = WaitUntil(resume_time=local_resume_time) + + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(wait_until) + + assert resume_trigger.trigger_type == UiPathResumeTriggerType.TIMER + assert resume_trigger.trigger_name == UiPathResumeTriggerName.TIMER + assert resume_trigger.resume_time == datetime( + 2026, 6, 27, 20, 14, 49, tzinfo=timezone.utc + ) + + def test_wait_until_requires_timezone_aware_resume_time(self) -> None: + """Test WaitUntil rejects timezone-naive resume times.""" + with pytest.raises(ValueError, match="resume_time must include timezone"): + WaitUntil(resume_time=datetime(2026, 6, 27, 20, 14, 49)) + class TestDocumentExtractionModels: """Tests for document extraction models.""" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index b96c97c7d..28f37c369 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.81" +version = "0.1.82" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 24450fcec..16d98a731 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.11.15" +version = "2.11.16" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath-core>=0.5.21, <0.6.0", - "uipath-runtime>=0.11.4, <0.12.0", - "uipath-platform>=0.1.81, <0.2.0", + "uipath-core>=0.5.26, <0.6.0", + "uipath-runtime>=0.11.5, <0.12.0", + "uipath-platform>=0.1.82, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 02be41e30..f0b04752b 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.15" +version = "2.11.16" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2625,7 +2625,7 @@ requires-dist = [ { name = "truststore", specifier = ">=0.10.1" }, { name = "uipath-core", editable = "../uipath-core" }, { name = "uipath-platform", editable = "../uipath-platform" }, - { name = "uipath-runtime", specifier = ">=0.11.4,<0.12.0" }, + { name = "uipath-runtime", specifier = ">=0.11.5,<0.12.0" }, ] [package.metadata.requires-dev] @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.81" +version = "0.1.82" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, @@ -2729,14 +2729,14 @@ dev = [ [[package]] name = "uipath-runtime" -version = "0.11.4" +version = "0.11.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/a4/944a7d6ef63aedea3592cdd73f2477b156562f3fb094ecf613117decbec5/uipath_runtime-0.11.4.tar.gz", hash = "sha256:7094b63f259249c763774d971ce0f3e611ca5abc160f2e4157e888b1690d9aa8", size = 152254, upload-time = "2026-06-24T14:26:07.11Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/f1/28ca53eee0176a5f2a4985f82a1d184fe01c21d84043557723e2c22e242f/uipath_runtime-0.11.5.tar.gz", hash = "sha256:a1f04c4199875ab72055082edd30c3546fd3ed80d1d70ed2c042b4b4047f8935", size = 152819, upload-time = "2026-06-29T16:08:00.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/73/730469eb53fcb1bc6b05251323830bc6012993f8cc12b2bb302deeea5679/uipath_runtime-0.11.4-py3-none-any.whl", hash = "sha256:072a620a45584d745da7c7b0ab43d590768acfed846c1b860b15aaa8fb93071a", size = 49826, upload-time = "2026-06-24T14:26:05.831Z" }, + { url = "https://files.pythonhosted.org/packages/8b/58/bd6c42a11b4eb7fd98f8a5b1b7e19e3b15d301c6894154320312086ff5c9/uipath_runtime-0.11.5-py3-none-any.whl", hash = "sha256:e665e10e3beaeba3dae48e354927604fba460568f73694c74e76d018a18d44af", size = 49864, upload-time = "2026-06-29T16:07:58.773Z" }, ] [[package]] From a22cdb1195e6718cd8e94eaab727e227e23ae9c4 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Tue, 30 Jun 2026 16:15:57 +0300 Subject: [PATCH 130/191] feat(core): add shared uipath metadata key (#1773) --- packages/uipath-core/pyproject.toml | 2 +- packages/uipath-core/src/uipath/core/triggers/__init__.py | 2 ++ packages/uipath-core/src/uipath/core/triggers/trigger.py | 3 +++ packages/uipath-core/uv.lock | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 6 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 739540a5b..6fb1831f6 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.26" +version = "0.5.27" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/triggers/__init__.py b/packages/uipath-core/src/uipath/core/triggers/__init__.py index fb4ee6ae5..d57929379 100644 --- a/packages/uipath-core/src/uipath/core/triggers/__init__.py +++ b/packages/uipath-core/src/uipath/core/triggers/__init__.py @@ -6,9 +6,11 @@ "UiPathApiTrigger", "UiPathIntegrationTrigger", "UiPathResumeTriggerName", + "UIPATH_METADATA_KEY", ] from uipath.core.triggers.trigger import ( + UIPATH_METADATA_KEY, UiPathApiTrigger, UiPathIntegrationTrigger, UiPathResumeTrigger, diff --git a/packages/uipath-core/src/uipath/core/triggers/trigger.py b/packages/uipath-core/src/uipath/core/triggers/trigger.py index 52a7da0ba..27680e6bb 100644 --- a/packages/uipath-core/src/uipath/core/triggers/trigger.py +++ b/packages/uipath-core/src/uipath/core/triggers/trigger.py @@ -6,6 +6,9 @@ from pydantic import BaseModel, ConfigDict, Field +# Reserved key for UiPath-owned metadata embedded in user-visible payloads. +UIPATH_METADATA_KEY = "__uipath" + class UiPathResumeTriggerType(str, Enum): """Constants representing different types of resume job triggers in the system.""" diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index fb7050873..6bcdfc306 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -1011,7 +1011,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.26" +version = "0.5.27" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 28f37c369..2170bfe4c 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1063,7 +1063,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.26" +version = "0.5.27" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index f0b04752b..c673c5e1d 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2659,7 +2659,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.26" +version = "0.5.27" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, From a2e8fe4f2826d6cb3f0c82593e24d4985a65cac8 Mon Sep 17 00:00:00 2001 From: Vlad Cimpeanu Date: Tue, 30 Jun 2026 16:54:47 +0300 Subject: [PATCH 131/191] refactor: reference platform constants instead of hardcoded strings (#1747) Co-authored-by: Claude Opus 4.8 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../platform/chat/_llm_gateway_service.py | 3 +- .../platform/common/_service_url_overrides.py | 5 ++-- .../src/uipath/platform/common/_span_utils.py | 27 ++++++++++++------ .../services/test_service_url_overrides.py | 14 ++++++---- packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 4 +-- .../src/uipath/_cli/_auth/_auth_service.py | 28 +++++++++++-------- .../src/uipath/_cli/_auth/_auth_session.py | 3 +- .../src/uipath/_cli/_auth/_url_utils.py | 3 +- .../uipath/src/uipath/_cli/_chat/_bridge.py | 20 +++++++++---- .../src/uipath/_cli/_chat/_voice_bridge.py | 20 +++++++++---- .../uipath/src/uipath/_cli/_debug/_bridge.py | 17 +++++++---- .../uipath/_cli/_evals/_progress_reporter.py | 11 ++++++-- .../src/uipath/_cli/_evals/_telemetry.py | 3 +- .../src/uipath/_cli/_push/sw_file_handler.py | 18 ++++++++---- packages/uipath/src/uipath/_cli/_telemetry.py | 3 +- .../uipath/src/uipath/_cli/_utils/_common.py | 5 ++-- .../src/uipath/_cli/_utils/_project_files.py | 15 ++++++---- .../src/uipath/_cli/_utils/_service_base.py | 5 ++-- packages/uipath/src/uipath/_cli/cli_eval.py | 3 +- packages/uipath/src/uipath/_cli/cli_init.py | 16 ++++++++--- packages/uipath/src/uipath/_cli/cli_invoke.py | 3 +- packages/uipath/src/uipath/_cli/cli_new.py | 9 +++--- packages/uipath/src/uipath/_cli/cli_pack.py | 20 +++++++++---- .../src/uipath/_cli/models/runtime_schema.py | 4 ++- .../uipath/_cli/models/uipath_json_schema.py | 4 ++- packages/uipath/src/uipath/_utils/_auth.py | 4 ++- .../eval/_helpers/evaluators_helpers.py | 2 -- .../eval/evaluators/llm_as_judge_evaluator.py | 2 +- .../eval/mocks/_simulate_component_service.py | 8 ++++-- .../uipath/src/uipath/functions/__init__.py | 5 ++-- .../uipath/src/uipath/functions/factory.py | 5 +++- .../src/uipath/tracing/_otel_exporters.py | 20 ++++++++----- packages/uipath/tests/cli/chat/test_bridge.py | 12 +++++--- .../tests/cli/chat/test_voice_bridge.py | 8 ++++-- .../tests/tracing/test_otel_exporters.py | 12 +++++--- packages/uipath/uv.lock | 4 +-- 38 files changed, 233 insertions(+), 116 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index b04ecf4e1..0678658a9 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.82" +version = "0.1.83" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py b/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py index cb02d8af3..12fe94bb0 100644 --- a/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py +++ b/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py @@ -27,6 +27,7 @@ from ..common._endpoints_manager import EndpointManager from ..common._execution_context import UiPathExecutionContext from ..common._models import Endpoint +from ..common.constants import HEADER_AGENTHUB_CONFIG from .llm_gateway import ( ChatCompletion, SpecificToolChoice, @@ -59,7 +60,7 @@ def _build_llm_headers( "X-UiPath-LlmGateway-RequestingFeature": requesting_feature, } if agenthub_config: - headers["X-UiPath-AgentHub-Config"] = agenthub_config + headers[HEADER_AGENTHUB_CONFIG] = agenthub_config if action_id: headers["X-UiPath-LlmGateway-ActionId"] = action_id return headers diff --git a/packages/uipath-platform/src/uipath/platform/common/_service_url_overrides.py b/packages/uipath-platform/src/uipath/platform/common/_service_url_overrides.py index f0a56318d..6b0a1b529 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_service_url_overrides.py +++ b/packages/uipath-platform/src/uipath/platform/common/_service_url_overrides.py @@ -15,6 +15,7 @@ import os from ._config import UiPathConfig +from .constants import HEADER_INTERNAL_ACCOUNT_ID, HEADER_INTERNAL_TENANT_ID def resolve_service_url(endpoint_path: str) -> str | None: @@ -57,8 +58,8 @@ def inject_routing_headers(headers: dict[str, str]) -> None: """ tenant_id = UiPathConfig.tenant_id if tenant_id: - headers["X-UiPath-Internal-TenantId"] = tenant_id + headers[HEADER_INTERNAL_TENANT_ID] = tenant_id organization_id = UiPathConfig.organization_id if organization_id: - headers["X-UiPath-Internal-AccountId"] = organization_id + headers[HEADER_INTERNAL_ACCOUNT_ID] = organization_id diff --git a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py index 3db31faed..80844f6cc 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -15,6 +15,17 @@ from pydantic import BaseModel, ConfigDict, Field from uipath.core.serialization import serialize_json +from .constants import ( + ENV_FOLDER_KEY, + ENV_JOB_KEY, + ENV_ORGANIZATION_ID, + ENV_PROCESS_KEY, + ENV_TENANT_ID, + ENV_UIPATH_PROCESS_UUID, + ENV_UIPATH_PROCESS_VERSION, + ENV_UIPATH_TRACE_ID, +) + logger = logging.getLogger(__name__) @@ -201,25 +212,25 @@ class UiPathSpan: # serializer, and even null fails for the required OrganizationId/FolderKey. # In the platform runtime these are always set to real GUIDs. organization_id: Optional[str] = field( - default_factory=lambda: env.get("UIPATH_ORGANIZATION_ID") or None + default_factory=lambda: env.get(ENV_ORGANIZATION_ID) or None ) tenant_id: Optional[str] = field( - default_factory=lambda: env.get("UIPATH_TENANT_ID") or None + default_factory=lambda: env.get(ENV_TENANT_ID) or None ) expiry_time_utc: Optional[str] = None folder_key: Optional[str] = field( - default_factory=lambda: env.get("UIPATH_FOLDER_KEY") or None + default_factory=lambda: env.get(ENV_FOLDER_KEY) or None ) source: SpanSource = SpanSource.CODED_AGENTS span_type: str = "Coded Agents" process_key: Optional[str] = field( - default_factory=lambda: env.get("UIPATH_PROCESS_UUID") + default_factory=lambda: env.get(ENV_UIPATH_PROCESS_UUID) ) reference_id: Optional[str] = field( default_factory=lambda: env.get("TRACE_REFERENCE_ID") ) - job_key: Optional[str] = field(default_factory=lambda: env.get("UIPATH_JOB_KEY")) + job_key: Optional[str] = field(default_factory=lambda: env.get(ENV_JOB_KEY)) # Top-level fields for internal tracing schema execution_type: Optional[ExecutionType] = None @@ -341,7 +352,7 @@ def otel_span_to_uipath_span( span_id = format(span_context.span_id, "016x") # Override trace_id if custom or env var provided (supports both UUID and hex format) - trace_id_override = custom_trace_id or os.environ.get("UIPATH_TRACE_ID") + trace_id_override = custom_trace_id or os.environ.get(ENV_UIPATH_TRACE_ID) if trace_id_override: trace_id = _SpanUtils.normalize_trace_id(trace_id_override) @@ -420,8 +431,8 @@ def otel_span_to_uipath_span( # Add process context attributes from environment variables for env_key, attr_key in ( - ("UIPATH_PROCESS_KEY", "agentName"), - ("UIPATH_PROCESS_VERSION", "agentVersion"), + (ENV_PROCESS_KEY, "agentName"), + (ENV_UIPATH_PROCESS_VERSION, "agentVersion"), ): value = env.get(env_key) if value: diff --git a/packages/uipath-platform/tests/services/test_service_url_overrides.py b/packages/uipath-platform/tests/services/test_service_url_overrides.py index cc038a3b9..ef1c6db23 100644 --- a/packages/uipath-platform/tests/services/test_service_url_overrides.py +++ b/packages/uipath-platform/tests/services/test_service_url_overrides.py @@ -4,6 +4,10 @@ inject_routing_headers, resolve_service_url, ) +from uipath.platform.common.constants import ( + HEADER_INTERNAL_ACCOUNT_ID, + HEADER_INTERNAL_TENANT_ID, +) class TestResolveServiceUrl: @@ -68,16 +72,16 @@ def test_injects_tenant_and_org(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-456") headers: dict[str, str] = {} inject_routing_headers(headers) - assert headers["X-UiPath-Internal-TenantId"] == "tenant-123" - assert headers["X-UiPath-Internal-AccountId"] == "org-456" + assert headers[HEADER_INTERNAL_TENANT_ID] == "tenant-123" + assert headers[HEADER_INTERNAL_ACCOUNT_ID] == "org-456" def test_skips_missing_env_vars(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("UIPATH_TENANT_ID", raising=False) monkeypatch.delenv("UIPATH_ORGANIZATION_ID", raising=False) headers: dict[str, str] = {} inject_routing_headers(headers) - assert "X-UiPath-Internal-TenantId" not in headers - assert "X-UiPath-Internal-AccountId" not in headers + assert HEADER_INTERNAL_TENANT_ID not in headers + assert HEADER_INTERNAL_ACCOUNT_ID not in headers def test_does_not_overwrite_existing_headers( self, monkeypatch: pytest.MonkeyPatch @@ -87,4 +91,4 @@ def test_does_not_overwrite_existing_headers( headers: dict[str, str] = {"X-Custom": "keep-me"} inject_routing_headers(headers) assert headers["X-Custom"] == "keep-me" - assert headers["X-UiPath-Internal-TenantId"] == "tenant-123" + assert headers[HEADER_INTERNAL_TENANT_ID] == "tenant-123" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 2170bfe4c..1535e576f 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.82" +version = "0.1.83" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 16d98a731..0025ccb60 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.11.16" +version = "2.11.17" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.26, <0.6.0", "uipath-runtime>=0.11.5, <0.12.0", - "uipath-platform>=0.1.82, <0.2.0", + "uipath-platform>=0.1.83, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/src/uipath/_cli/_auth/_auth_service.py b/packages/uipath/src/uipath/_cli/_auth/_auth_service.py index c89968683..88d94d1fb 100644 --- a/packages/uipath/src/uipath/_cli/_auth/_auth_service.py +++ b/packages/uipath/src/uipath/_cli/_auth/_auth_service.py @@ -9,6 +9,12 @@ from uipath._cli._utils._console import ConsoleLogger from uipath._utils._auth import update_env_file from uipath.platform.common import ExternalApplicationService, TokenData +from uipath.platform.common.constants import ( + ENV_BASE_URL, + ENV_ORGANIZATION_ID, + ENV_TENANT_ID, + ENV_UIPATH_ACCESS_TOKEN, +) from ._utils import update_auth_file @@ -61,9 +67,9 @@ async def _authenticate_client_credentials(self): ) env_vars = { - "UIPATH_ACCESS_TOKEN": token_data.access_token, - "UIPATH_URL": external_app_service._base_url, - "UIPATH_ORGANIZATION_ID": get_parsed_token_data(token_data).get("prt_id"), + ENV_UIPATH_ACCESS_TOKEN: token_data.access_token, + ENV_BASE_URL: external_app_service._base_url, + ENV_ORGANIZATION_ID: get_parsed_token_data(token_data).get("prt_id"), } if tenant_name: @@ -71,7 +77,7 @@ async def _authenticate_client_credentials(self): auth_session = AuthSession(self._domain) auth_session.update_token_data(token_data) tenant_info = await auth_session.resolve_tenant_info(self._tenant) - env_vars["UIPATH_TENANT_ID"] = tenant_info["tenant_id"] + env_vars[ENV_TENANT_ID] = tenant_info["tenant_id"] else: self._console.warning("Could not extract tenant from --base-url.") update_env_file(env_vars) @@ -90,10 +96,10 @@ async def _authenticate_authorization_code(self) -> None: update_env_file( { - "UIPATH_ACCESS_TOKEN": token_data.access_token, - "UIPATH_URL": uipath_url, - "UIPATH_TENANT_ID": tenant_info["tenant_id"], - "UIPATH_ORGANIZATION_ID": tenant_info["organization_id"], + ENV_UIPATH_ACCESS_TOKEN: token_data.access_token, + ENV_BASE_URL: uipath_url, + ENV_TENANT_ID: tenant_info["tenant_id"], + ENV_ORGANIZATION_ID: tenant_info["organization_id"], } ) @@ -110,9 +116,9 @@ async def _authenticate_authorization_code(self) -> None: async def _can_reuse_existing_token(self, auth_session: AuthSession) -> bool: if ( - os.getenv("UIPATH_URL") - and os.getenv("UIPATH_TENANT_ID") - and os.getenv("UIPATH_ORGANIZATION_ID") + os.getenv(ENV_BASE_URL) + and os.getenv(ENV_TENANT_ID) + and os.getenv(ENV_ORGANIZATION_ID) ): try: await auth_session.ensure_valid_token() diff --git a/packages/uipath/src/uipath/_cli/_auth/_auth_session.py b/packages/uipath/src/uipath/_cli/_auth/_auth_session.py index cd51a583b..839bc1415 100644 --- a/packages/uipath/src/uipath/_cli/_auth/_auth_session.py +++ b/packages/uipath/src/uipath/_cli/_auth/_auth_session.py @@ -3,6 +3,7 @@ import click from uipath.platform.common import TokenData +from uipath.platform.common.constants import ENV_UIPATH_ACCESS_TOKEN from uipath.platform.identity import IdentityService from uipath.platform.portal import ( PortalService as PlatformPortalService, @@ -95,7 +96,7 @@ async def ensure_valid_token(self): def finalize(token_data: TokenData): self.update_token_data(token_data) update_auth_file(token_data) - update_env_file({"UIPATH_ACCESS_TOKEN": token_data.access_token}) + update_env_file({ENV_UIPATH_ACCESS_TOKEN: token_data.access_token}) if exp is not None and float(exp) > time.time(): finalize(auth_data) diff --git a/packages/uipath/src/uipath/_cli/_auth/_url_utils.py b/packages/uipath/src/uipath/_cli/_auth/_url_utils.py index 844968762..e232d6dfb 100644 --- a/packages/uipath/src/uipath/_cli/_auth/_url_utils.py +++ b/packages/uipath/src/uipath/_cli/_auth/_url_utils.py @@ -2,6 +2,7 @@ from typing import Tuple from urllib.parse import urlparse +from ...platform.common.constants import ENV_BASE_URL from .._utils._console import ConsoleLogger console = ConsoleLogger() @@ -26,7 +27,7 @@ def resolve_domain(base_url: str | None, environment: str | None) -> str: return domain if environment is None: - uipath_url = os.getenv("UIPATH_URL") + uipath_url = os.getenv(ENV_BASE_URL) if uipath_url: parsed = urlparse(uipath_url) if parsed.scheme and parsed.netloc: diff --git a/packages/uipath/src/uipath/_cli/_chat/_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_bridge.py index 442322e36..70fe03134 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_bridge.py @@ -20,6 +20,14 @@ UiPathConversationToolCallEvent, ) from uipath.core.triggers import UiPathResumeTrigger +from uipath.platform.common.constants import ( + ENV_BASE_URL, + ENV_ORGANIZATION_ID, + ENV_TENANT_ID, + ENV_UIPATH_ACCESS_TOKEN, + HEADER_INTERNAL_ACCOUNT_ID, + HEADER_INTERNAL_TENANT_ID, +) from uipath.runtime.chat import UiPathChatProtocol from uipath.runtime.context import UiPathRuntimeContext @@ -503,7 +511,7 @@ def get_chat_bridge( assert context.exchange_id is not None, "exchange_id must be set in context" # Extract host from UIPATH_URL - base_url = os.environ.get("UIPATH_URL") + base_url = os.environ.get(ENV_BASE_URL) if not base_url: raise RuntimeError( "UIPATH_URL environment variable required for conversational mode" @@ -528,11 +536,11 @@ def get_chat_bridge( # Build headers from context headers = { - "Authorization": f"Bearer {os.environ.get('UIPATH_ACCESS_TOKEN', '')}", - "X-UiPath-Internal-TenantId": context.tenant_id - or os.environ.get("UIPATH_TENANT_ID", ""), - "X-UiPath-Internal-AccountId": context.org_id - or os.environ.get("UIPATH_ORGANIZATION_ID", ""), + "Authorization": f"Bearer {os.environ.get(ENV_UIPATH_ACCESS_TOKEN, '')}", + HEADER_INTERNAL_TENANT_ID: context.tenant_id + or os.environ.get(ENV_TENANT_ID, ""), + HEADER_INTERNAL_ACCOUNT_ID: context.org_id + or os.environ.get(ENV_ORGANIZATION_ID, ""), "X-UiPath-ConversationId": context.conversation_id, } diff --git a/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py index 8473c1574..b575404b3 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py @@ -17,6 +17,14 @@ UiPathVoiceToolCallRequest, UiPathVoiceToolCallResult, ) +from uipath.platform.common.constants import ( + ENV_BASE_URL, + ENV_ORGANIZATION_ID, + ENV_TENANT_ID, + ENV_UIPATH_ACCESS_TOKEN, + HEADER_INTERNAL_ACCOUNT_ID, + HEADER_INTERNAL_TENANT_ID, +) from uipath.runtime.context import UiPathRuntimeContext logger = logging.getLogger(__name__) @@ -229,7 +237,7 @@ def get_voice_bridge( f"CAS_WEBSOCKET_HOST is set. Using websocket_url '{url}{socketio_path}'." ) else: - base_url = os.environ.get("UIPATH_URL") + base_url = os.environ.get(ENV_BASE_URL) if not base_url: raise RuntimeError( "UIPATH_URL environment variable required for conversational mode" @@ -241,11 +249,11 @@ def get_voice_bridge( socketio_path = "autopilotforeveryone_/websocket_/socket.io" headers = { - "Authorization": f"Bearer {os.environ.get('UIPATH_ACCESS_TOKEN', '')}", - "X-UiPath-Internal-TenantId": context.tenant_id - or os.environ.get("UIPATH_TENANT_ID", ""), - "X-UiPath-Internal-AccountId": context.org_id - or os.environ.get("UIPATH_ORGANIZATION_ID", ""), + "Authorization": f"Bearer {os.environ.get(ENV_UIPATH_ACCESS_TOKEN, '')}", + HEADER_INTERNAL_TENANT_ID: context.tenant_id + or os.environ.get(ENV_TENANT_ID, ""), + HEADER_INTERNAL_ACCOUNT_ID: context.org_id + or os.environ.get(ENV_ORGANIZATION_ID, ""), "X-UiPath-ConversationId": context.conversation_id, } diff --git a/packages/uipath/src/uipath/_cli/_debug/_bridge.py b/packages/uipath/src/uipath/_cli/_debug/_bridge.py index cfd286b5c..db152057e 100644 --- a/packages/uipath/src/uipath/_cli/_debug/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_debug/_bridge.py @@ -13,6 +13,13 @@ from uipath.core.serialization import serialize_object from uipath.core.triggers import UiPathResumeTriggerType +from uipath.platform.common.constants import ( + ENV_BASE_URL, + ENV_UIPATH_ACCESS_TOKEN, + HEADER_FOLDER_KEY, + HEADER_INTERNAL_ACCOUNT_ID, + HEADER_INTERNAL_TENANT_ID, +) from uipath.runtime import ( UiPathBreakpointResult, UiPathRuntimeContext, @@ -860,7 +867,7 @@ async def _handle_error(self, error: Any) -> None: def get_remote_debug_bridge(context: UiPathRuntimeContext) -> UiPathDebugProtocol: """Factory to get SignalR debug bridge for remote debugging.""" - uipath_url = os.environ.get("UIPATH_URL") + uipath_url = os.environ.get(ENV_BASE_URL) if not uipath_url or not context.job_id: raise ValueError( "UIPATH_URL and UIPATH_JOB_KEY are required for remote debugging" @@ -870,11 +877,11 @@ def get_remote_debug_bridge(context: UiPathRuntimeContext) -> UiPathDebugProtoco return SignalRDebugBridge( hub_url=signalr_url, - access_token=os.environ.get("UIPATH_ACCESS_TOKEN"), + access_token=os.environ.get(ENV_UIPATH_ACCESS_TOKEN), headers={ - "X-UiPath-Internal-TenantId": context.tenant_id or "", - "X-UiPath-Internal-AccountId": context.org_id or "", - "X-UiPath-FolderKey": context.folder_key or "", + HEADER_INTERNAL_TENANT_ID: context.tenant_id or "", + HEADER_INTERNAL_ACCOUNT_ID: context.org_id or "", + HEADER_FOLDER_KEY: context.folder_key or "", }, ) diff --git a/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py b/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py index fd4849076..d78a3559a 100644 --- a/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py +++ b/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py @@ -38,6 +38,11 @@ ) from uipath.platform import UiPath from uipath.platform.common import UiPathConfig +from uipath.platform.common.constants import ( + ENV_UIPATH_AGENT_ID, + ENV_UIPATH_PROJECT_FILES_SOURCE, + ENV_UIPATH_PROJECT_ID, +) logger = logging.getLogger(__name__) @@ -101,8 +106,8 @@ def __init__(self): self._client = uipath.api_client self._console = console_logger self._rich_console = Console() - self._project_id = os.getenv("UIPATH_PROJECT_ID", None) - self._agent_id = os.getenv("UIPATH_AGENT_ID") or self._project_id + self._project_id = os.getenv(ENV_UIPATH_PROJECT_ID, None) + self._agent_id = os.getenv(ENV_UIPATH_AGENT_ID) or self._project_id if not self._agent_id: logger.warning( "Cannot report data to StudioWeb. Please set UIPATH_PROJECT_ID." @@ -1098,7 +1103,7 @@ def _collect_coded_results( @staticmethod def _resolve_project_files_source() -> int | None: - raw = os.getenv("UIPATH_PROJECT_FILES_SOURCE") + raw = os.getenv(ENV_UIPATH_PROJECT_FILES_SOURCE) if not raw: return None normalized = raw.strip().lower() diff --git a/packages/uipath/src/uipath/_cli/_evals/_telemetry.py b/packages/uipath/src/uipath/_cli/_evals/_telemetry.py index bdbdc67f7..3bedccb5d 100644 --- a/packages/uipath/src/uipath/_cli/_evals/_telemetry.py +++ b/packages/uipath/src/uipath/_cli/_evals/_telemetry.py @@ -19,6 +19,7 @@ EvaluationEvents, ) from uipath.platform.common import UiPathConfig +from uipath.platform.common.constants import ENV_TENANT_ID from uipath.telemetry._track import is_telemetry_enabled, track_event logger = logging.getLogger(__name__) @@ -327,7 +328,7 @@ def _enrich_properties(self, properties: dict[str, Any]) -> None: if cloud_user_id: properties["CloudUserId"] = cloud_user_id - tenant_id = os.getenv("UIPATH_TENANT_ID") + tenant_id = os.getenv(ENV_TENANT_ID) if tenant_id: properties["TenantId"] = tenant_id diff --git a/packages/uipath/src/uipath/_cli/_push/sw_file_handler.py b/packages/uipath/src/uipath/_cli/_push/sw_file_handler.py index 64a0e9b0e..95bafde9f 100644 --- a/packages/uipath/src/uipath/_cli/_push/sw_file_handler.py +++ b/packages/uipath/src/uipath/_cli/_push/sw_file_handler.py @@ -10,6 +10,12 @@ from uipath._cli.models.uipath_json_schema import PackOptions from uipath.platform.common import UiPathConfig +from uipath.platform.common.constants import ( + EVALS_FOLDER, + LEGACY_EVAL_FOLDER, + PYTHON_CONFIGURATION_FILE, + STUDIO_METADATA_FILE, +) from ...platform.errors import EnrichedException from .._utils._common import get_claim_from_token @@ -245,12 +251,12 @@ async def _process_file_uploads( deleted_files = self._collect_deleted_files( remote_files, processed_source_files, - files_to_ignore=["studio_metadata.json"], + files_to_ignore=[STUDIO_METADATA_FILE], directories_to_ignore=[ name for name, condition in [ - ("evals", not UiPathConfig.has_legacy_eval_folder), - ("evaluations", not UiPathConfig.has_eval_folder), + (LEGACY_EVAL_FOLDER, not UiPathConfig.has_legacy_eval_folder), + (EVALS_FOLDER, not UiPathConfig.has_eval_folder), ] if condition ], @@ -420,7 +426,7 @@ def get_author_from_token_or_toml() -> str: pass toml_data = read_toml_project( - os.path.join(self.directory, "pyproject.toml") + os.path.join(self.directory, PYTHON_CONFIGURATION_FILE) ) return toml_data.get("authors", "").strip() @@ -489,7 +495,7 @@ def get_author_from_token_or_toml() -> str: else: structural_migration.added_resources.append( AddedResource( - file_name="studio_metadata.json", + file_name=STUDIO_METADATA_FILE, content_string=json.dumps(metadata), parent_path=".uipath", ) @@ -543,7 +549,7 @@ async def upload_source_files( # Log skipped files from root evals folder if skipped_files: - evals_folder_path = os.path.join(self.directory, "evals") + evals_folder_path = os.path.join(self.directory, LEGACY_EVAL_FOLDER) logger.info( f"Skipping {len(skipped_files)} file(s) in evals folder ({evals_folder_path}): {', '.join(skipped_files)}" ) diff --git a/packages/uipath/src/uipath/_cli/_telemetry.py b/packages/uipath/src/uipath/_cli/_telemetry.py index d245076a4..3668e09ba 100644 --- a/packages/uipath/src/uipath/_cli/_telemetry.py +++ b/packages/uipath/src/uipath/_cli/_telemetry.py @@ -7,6 +7,7 @@ from uipath._cli._utils._common import get_claim_from_token from uipath.platform.common import UiPathConfig +from uipath.platform.common.constants import ENV_UIPATH_AGENT_ID from uipath.telemetry._track import ( _get_project_key, is_telemetry_enabled, @@ -41,7 +42,7 @@ def _enrich_properties(self, properties: Dict[str, Any]) -> None: Args: properties: The properties dictionary to enrich. """ - agent_id = os.getenv("UIPATH_AGENT_ID") or _get_project_key() + agent_id = os.getenv(ENV_UIPATH_AGENT_ID) or _get_project_key() if agent_id: properties["AgentId"] = agent_id diff --git a/packages/uipath/src/uipath/_cli/_utils/_common.py b/packages/uipath/src/uipath/_cli/_utils/_common.py index c24bccff0..da6dec36c 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_common.py +++ b/packages/uipath/src/uipath/_cli/_utils/_common.py @@ -13,6 +13,7 @@ ResourceOverwriteParser, UiPathConfig, ) +from uipath.platform.common.constants import ENV_BASE_URL from ..._utils.constants import ENV_UIPATH_ACCESS_TOKEN from ..models.runtime_schema import EntryPoint @@ -60,8 +61,8 @@ def environment_options(function): def get_env_vars(spinner: Spinner | None = None) -> list[str]: - base_url = os.environ.get("UIPATH_URL") - token = os.environ.get("UIPATH_ACCESS_TOKEN") + base_url = os.environ.get(ENV_BASE_URL) + token = os.environ.get(ENV_UIPATH_ACCESS_TOKEN) if not all([base_url, token]): if spinner: diff --git a/packages/uipath/src/uipath/_cli/_utils/_project_files.py b/packages/uipath/src/uipath/_cli/_utils/_project_files.py index 15f5e53c5..ccd774123 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_project_files.py +++ b/packages/uipath/src/uipath/_cli/_utils/_project_files.py @@ -13,6 +13,11 @@ from uipath._cli.models.uipath_json_schema import PackOptions, UiPathJsonConfig from uipath.platform.common import UiPathConfig +from uipath.platform.common.constants import ( + LEGACY_EVAL_FOLDER, + PYTHON_CONFIGURATION_FILE, + UIPATH_CONFIG_FILE, +) from .._utils._console import ConsoleLogger from ._constants import is_binary_file @@ -118,8 +123,8 @@ def get_project_config(directory: str) -> dict[str, Any]: Raises: SystemExit: If required configuration files are missing or invalid """ - config_path = os.path.join(directory, "uipath.json") - toml_path = os.path.join(directory, "pyproject.toml") + config_path = os.path.join(directory, UIPATH_CONFIG_FILE) + toml_path = os.path.join(directory, PYTHON_CONFIGURATION_FILE) if not os.path.isfile(config_path): console.error("uipath.json not found, please run `uipath init`.") @@ -228,7 +233,7 @@ def ensure_config_file(directory: str) -> None: Raises: SystemExit: If uipath.json is not found in the directory """ - if not os.path.isfile(os.path.join(directory, "uipath.json")): + if not os.path.isfile(os.path.join(directory, UIPATH_CONFIG_FILE)): console.error( "uipath.json not found. Please run `uipath init` in the project directory." ) @@ -418,7 +423,7 @@ def files_to_include( tuple[list[FileInfo], list[str]]: Tuple of (included files, skipped file paths) """ file_extensions_included = [".py", ".mermaid", ".json", ".yaml", ".yml", ".md"] - files_included = ["pyproject.toml"] + files_included = [PYTHON_CONFIGURATION_FILE] files_excluded = [] if directories_to_ignore is None: @@ -458,7 +463,7 @@ def is_venv_dir(d: str) -> bool: # Determine if we're in the root evals folder root_rel_path = os.path.relpath(root, directory) normalized_root_rel_path = root_rel_path.replace(os.sep, "/") - is_root_evals_folder = normalized_root_rel_path == "evals" + is_root_evals_folder = normalized_root_rel_path == LEGACY_EVAL_FOLDER # Skip all directories that start with . or are a venv or are excluded included_dirs = [] diff --git a/packages/uipath/src/uipath/_cli/_utils/_service_base.py b/packages/uipath/src/uipath/_cli/_utils/_service_base.py index 018e2e112..ba622c1e4 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_service_base.py +++ b/packages/uipath/src/uipath/_cli/_utils/_service_base.py @@ -19,6 +19,7 @@ import click from httpx import HTTPError +from ...platform.common.constants import ENV_BASE_URL, ENV_UIPATH_ACCESS_TOKEN from ...platform.errors import ( BaseUrlMissingError, EnrichedException, @@ -321,8 +322,8 @@ def get_client(ctx): if cli_ctx._client is None: from ...platform._uipath import UiPath - base_url = os.environ.get("UIPATH_URL") - secret = os.environ.get("UIPATH_ACCESS_TOKEN") + base_url = os.environ.get(ENV_BASE_URL) + secret = os.environ.get(ENV_UIPATH_ACCESS_TOKEN) if not base_url: raise click.ClickException( diff --git a/packages/uipath/src/uipath/_cli/cli_eval.py b/packages/uipath/src/uipath/_cli/cli_eval.py index 86e028c67..b7dbbba8a 100644 --- a/packages/uipath/src/uipath/_cli/cli_eval.py +++ b/packages/uipath/src/uipath/_cli/cli_eval.py @@ -26,6 +26,7 @@ ResourceOverwritesContext, UiPathConfig, ) +from uipath.platform.common.constants import ENV_FOLDER_KEY from uipath.runtime import ( UiPathRuntimeContext, UiPathRuntimeFactoryRegistry, @@ -64,7 +65,7 @@ def setup_reporting_prereq(no_report: bool) -> bool: if not UiPathConfig.folder_key: folder_key = asyncio.run(get_personal_workspace_key_async()) if folder_key: - os.environ["UIPATH_FOLDER_KEY"] = folder_key + os.environ[ENV_FOLDER_KEY] = folder_key return True diff --git a/packages/uipath/src/uipath/_cli/cli_init.py b/packages/uipath/src/uipath/_cli/cli_init.py index 90a4ca117..3f113349e 100644 --- a/packages/uipath/src/uipath/_cli/cli_init.py +++ b/packages/uipath/src/uipath/_cli/cli_init.py @@ -23,6 +23,12 @@ ) from uipath.platform.common import UiPathConfig +from uipath.platform.common.constants import ( + DOTENV_FILE, + ENTRY_POINTS_FILE, + PYTHON_CONFIGURATION_FILE, + UIPATH_CONFIG_FILE, +) from uipath.runtime import ( UiPathRuntimeContext, UiPathRuntimeFactoryProtocol, @@ -43,7 +49,7 @@ console = ConsoleLogger() logger = logging.getLogger(__name__) -CONFIG_PATH = "uipath.json" +CONFIG_PATH = UIPATH_CONFIG_FILE GRAPH_INDENT = " " @@ -54,7 +60,7 @@ class Action(str, enum.Enum): def generate_env_file(target_directory): - env_path = os.path.join(target_directory, ".env") + env_path = os.path.join(target_directory, DOTENV_FILE) if not os.path.exists(env_path): relative_path = os.path.relpath(env_path, target_directory) @@ -163,7 +169,7 @@ def write_entry_points_file(entry_points: list[UiPathRuntimeSchema]) -> Path: """ json_object = { "$schema": "https://cloud.uipath.com/draft/2024-12/entry-point", - "$id": "entry-points.json", + "$id": ENTRY_POINTS_FILE, "entryPoints": [ ep.model_dump( by_alias=True, @@ -197,7 +203,9 @@ def write_uiproj_file( ] project_type = determine_project_type(entry_point_models).capitalize() - toml_data = read_toml_project(os.path.join(current_directory, "pyproject.toml")) + toml_data = read_toml_project( + os.path.join(current_directory, PYTHON_CONFIGURATION_FILE) + ) project_name = toml_data["name"] project_description = toml_data.get("description") diff --git a/packages/uipath/src/uipath/_cli/cli_invoke.py b/packages/uipath/src/uipath/_cli/cli_invoke.py index 726b623d1..73400b915 100644 --- a/packages/uipath/src/uipath/_cli/cli_invoke.py +++ b/packages/uipath/src/uipath/_cli/cli_invoke.py @@ -7,6 +7,7 @@ import httpx from .._utils._ssl_context import get_httpx_client_kwargs +from ..platform.common.constants import PYTHON_CONFIGURATION_FILE from ._telemetry import track_command from ._utils._common import get_env_vars from ._utils._console import ConsoleLogger @@ -20,7 +21,7 @@ def _read_project_details() -> tuple[str, str]: current_path = os.getcwd() - toml_path = os.path.join(current_path, "pyproject.toml") + toml_path = os.path.join(current_path, PYTHON_CONFIGURATION_FILE) if not os.path.isfile(toml_path): console.error("pyproject.toml not found.") diff --git a/packages/uipath/src/uipath/_cli/cli_new.py b/packages/uipath/src/uipath/_cli/cli_new.py index d273055d5..7cea4dcc8 100644 --- a/packages/uipath/src/uipath/_cli/cli_new.py +++ b/packages/uipath/src/uipath/_cli/cli_new.py @@ -4,6 +4,7 @@ import click +from ..platform.common.constants import PYTHON_CONFIGURATION_FILE, UIPATH_CONFIG_FILE from ._telemetry import track_command from ._utils._console import ConsoleLogger from .middlewares import Middlewares @@ -21,7 +22,7 @@ def generate_script(target_directory): def generate_pyproject(target_directory, project_name): - project_toml_path = os.path.join(target_directory, "pyproject.toml") + project_toml_path = os.path.join(target_directory, PYTHON_CONFIGURATION_FILE) toml_content = f"""[project] name = "{project_name}" version = "0.0.1" @@ -38,7 +39,7 @@ def generate_pyproject(target_directory, project_name): def generate_uipath_json(target_directory): - uipath_json_path = os.path.join(target_directory, "uipath.json") + uipath_json_path = os.path.join(target_directory, UIPATH_CONFIG_FILE) uipath_config = {"functions": {"main": "main.py:main"}} with open(uipath_json_path, "w") as f: @@ -74,9 +75,9 @@ def new(name: str): generate_script(directory) console.success("Created 'main.py' file.") generate_pyproject(directory, name) - console.success("Created 'pyproject.toml' file.") + console.success(f"Created '{PYTHON_CONFIGURATION_FILE}' file.") generate_uipath_json(directory) - console.success("Created 'uipath.json' file.") + console.success(f"Created '{UIPATH_CONFIG_FILE}' file.") init_command = """uipath init""" run_command = """uipath run main '{"message": "Hello World!"}'""" console.hint(f""" Initialize project: {click.style(init_command, fg="cyan")}""") diff --git a/packages/uipath/src/uipath/_cli/cli_pack.py b/packages/uipath/src/uipath/_cli/cli_pack.py index d51eec53c..3a2dbad5a 100644 --- a/packages/uipath/src/uipath/_cli/cli_pack.py +++ b/packages/uipath/src/uipath/_cli/cli_pack.py @@ -11,6 +11,12 @@ from uipath._cli.models.uipath_json_schema import UiPathJsonConfig from uipath.eval.constants import EVALS_FOLDER, LEGACY_EVAL_FOLDER from uipath.platform.common import UiPathConfig +from uipath.platform.common.constants import ( + ENTRY_POINTS_FILE, + PYTHON_CONFIGURATION_FILE, + UIPATH_BINDINGS_FILE, + UIPATH_CONFIG_FILE, +) from ._telemetry import track_command from ._utils._common import determine_project_type @@ -31,7 +37,7 @@ def get_project_version(directory): - toml_path = os.path.join(directory, "pyproject.toml") + toml_path = os.path.join(directory, PYTHON_CONFIGURATION_FILE) if not os.path.exists(toml_path): console.warning("pyproject.toml not found. Using default version 0.0.1") return "0.0.1" @@ -95,7 +101,7 @@ def generate_operate_file( def generate_entrypoints_file(entrypoints: list[EntryPoint]): entrypoint_json_data = { "$schema": schema, - "$id": "entry-points.json", + "$id": ENTRY_POINTS_FILE, "entryPoints": [ ep.model_dump(by_alias=True, exclude_none=True) for ep in entrypoints ], @@ -176,8 +182,8 @@ def generate_psmdcp_content(projectName, version, description, authors): def generate_package_descriptor_content(entrypoints: list[EntryPoint]): files = { "operate.json": "content/operate.json", - "entry-points.json": "content/entry-points.json", - "bindings.json": "content/bindings_v2.json", + ENTRY_POINTS_FILE: "content/entry-points.json", + UIPATH_BINDINGS_FILE: "content/bindings_v2.json", } for entry in entrypoints: @@ -212,14 +218,16 @@ def pack_fn( directory, str(UiPathConfig.entry_points_file_path) ) if not os.path.exists(entry_points_file_path): - raise Exception("'entry-points.json' file not found. Please run 'uipath init'.") + raise Exception( + f"'{ENTRY_POINTS_FILE}' file not found. Please run 'uipath init'." + ) with open(entry_points_file_path, "r") as f: entry_points_data = EntryPoints.model_validate(json.load(f)) entrypoints = entry_points_data.entrypoints - config_path = os.path.join(directory, "uipath.json") + config_path = os.path.join(directory, UIPATH_CONFIG_FILE) if not os.path.exists(config_path): console.error("uipath.json not found, please run `uipath init`.") diff --git a/packages/uipath/src/uipath/_cli/models/runtime_schema.py b/packages/uipath/src/uipath/_cli/models/runtime_schema.py index a32660abf..7b7c39dc1 100644 --- a/packages/uipath/src/uipath/_cli/models/runtime_schema.py +++ b/packages/uipath/src/uipath/_cli/models/runtime_schema.py @@ -2,6 +2,8 @@ from pydantic import BaseModel, ConfigDict, Field +from ...platform.common.constants import ENTRY_POINTS_FILE + class BaseModelWithDefaultConfig(BaseModel): model_config = ConfigDict( @@ -48,7 +50,7 @@ class EntryPoints(BaseModelWithDefaultConfig): default="https://cloud.uipath.com/draft/2024-12/entry-point", alias="$schema", ) - id_: str = Field(default="entry-points.json", alias="$id") + id_: str = Field(default=ENTRY_POINTS_FILE, alias="$id") entrypoints: list[EntryPoint] = Field(..., alias="entryPoints") diff --git a/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py b/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py index 4dd2f6700..572c878c5 100644 --- a/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py +++ b/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py @@ -4,6 +4,8 @@ from pydantic import BaseModel, ConfigDict, Field +from ...platform.common.constants import UIPATH_CONFIG_FILE + class BaseModelWithDefaultConfig(BaseModel): model_config = ConfigDict( @@ -126,7 +128,7 @@ def create_default(cls) -> "UiPathJsonConfig": ) @classmethod - def load_from_file(cls, file_path: str = "uipath.json") -> "UiPathJsonConfig": + def load_from_file(cls, file_path: str = UIPATH_CONFIG_FILE) -> "UiPathJsonConfig": """Load configuration from a JSON file.""" import json from pathlib import Path diff --git a/packages/uipath/src/uipath/_utils/_auth.py b/packages/uipath/src/uipath/_utils/_auth.py index 6f83fd0a2..873c13edc 100644 --- a/packages/uipath/src/uipath/_utils/_auth.py +++ b/packages/uipath/src/uipath/_utils/_auth.py @@ -4,6 +4,8 @@ from pathlib import Path from typing import Optional +from uipath.platform.common.constants import DOTENV_FILE + from .constants import ( ENV_BASE_URL, ENV_UIPATH_ACCESS_TOKEN, @@ -22,7 +24,7 @@ def parse_access_token(access_token: str): def update_env_file(env_contents): - env_path = Path.cwd() / ".env" + env_path = Path.cwd() / DOTENV_FILE if env_path.exists(): with open(env_path, "r") as f: for line in f: diff --git a/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py b/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py index b639e631d..04127b251 100644 --- a/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py +++ b/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py @@ -35,8 +35,6 @@ def _sanitize_tool_name(name: str | None) -> str: "!=": "ne", } -COMMUNITY_agents_SUFFIX = "-community-agents" - def _unsynthesized_tool_attrs(span: ReadableSpan) -> Mapping[str, Any] | None: """Return span.attributes if this is a real tool invocation, else None.""" diff --git a/packages/uipath/src/uipath/eval/evaluators/llm_as_judge_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/llm_as_judge_evaluator.py index c67212548..06b6aabce 100644 --- a/packages/uipath/src/uipath/eval/evaluators/llm_as_judge_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/llm_as_judge_evaluator.py @@ -11,9 +11,9 @@ from uipath.platform import UiPath from uipath.platform.chat import UiPathLlmChatService +from uipath.platform.common.constants import COMMUNITY_agents_SUFFIX from .._execution_context import eval_set_run_id_context -from .._helpers.evaluators_helpers import COMMUNITY_agents_SUFFIX from ..models import ( AgentExecution, EvaluationResult, diff --git a/packages/uipath/src/uipath/eval/mocks/_simulate_component_service.py b/packages/uipath/src/uipath/eval/mocks/_simulate_component_service.py index 87a08bd37..d01426d99 100644 --- a/packages/uipath/src/uipath/eval/mocks/_simulate_component_service.py +++ b/packages/uipath/src/uipath/eval/mocks/_simulate_component_service.py @@ -4,6 +4,10 @@ from uipath._utils import Endpoint from uipath.platform.common import BaseService +from uipath.platform.common.constants import ( + HEADER_INTERNAL_ACCOUNT_ID, + HEADER_INTERNAL_TENANT_ID, +) class SimulateComponentService(BaseService): @@ -12,9 +16,9 @@ async def simulate(self, payload: dict[str, Any]) -> dict[str, Any]: headers: dict[str, str] = {} if UiPathConfig.tenant_id: - headers["X-UiPath-Internal-TenantId"] = UiPathConfig.tenant_id + headers[HEADER_INTERNAL_TENANT_ID] = UiPathConfig.tenant_id if UiPathConfig.organization_id: - headers["X-UiPath-Internal-AccountId"] = UiPathConfig.organization_id + headers[HEADER_INTERNAL_ACCOUNT_ID] = UiPathConfig.organization_id response = await self.request_async( "POST", diff --git a/packages/uipath/src/uipath/functions/__init__.py b/packages/uipath/src/uipath/functions/__init__.py index 74ffac411..a79a0473e 100644 --- a/packages/uipath/src/uipath/functions/__init__.py +++ b/packages/uipath/src/uipath/functions/__init__.py @@ -1,5 +1,6 @@ """UiPath Functions Runtime - factory and runtime for function-based execution.""" +from uipath.platform.common.constants import UIPATH_CONFIG_FILE from uipath.runtime import UiPathRuntimeFactoryRegistry from .debug import UiPathDebugFunctionsRuntime @@ -12,9 +13,9 @@ def register_default_runtime_factory(): UiPathRuntimeFactoryRegistry.register( "uipath", factory_callable=lambda context: UiPathFunctionsRuntimeFactory( - config_path="uipath.json", + config_path=UIPATH_CONFIG_FILE, ), - config_file="uipath.json", + config_file=UIPATH_CONFIG_FILE, ) UiPathRuntimeFactoryRegistry.set_default("uipath") diff --git a/packages/uipath/src/uipath/functions/factory.py b/packages/uipath/src/uipath/functions/factory.py index 2f9888469..4d60dbaa9 100644 --- a/packages/uipath/src/uipath/functions/factory.py +++ b/packages/uipath/src/uipath/functions/factory.py @@ -5,6 +5,7 @@ from pathlib import Path from typing import Any +from uipath.platform.common.constants import UIPATH_CONFIG_FILE from uipath.runtime import ( UiPathRuntimeFactorySettings, UiPathRuntimeProtocol, @@ -20,7 +21,9 @@ class UiPathFunctionsRuntimeFactory: """Factory for discovering and creating function-based runtimes.""" - def __init__(self, config_path: str = "uipath.json", base_dir: str | None = None): + def __init__( + self, config_path: str = UIPATH_CONFIG_FILE, base_dir: str | None = None + ): """Initialize the factory with the path to uipath.json configuration.""" self.config_path = Path(config_path) self.base_dir = Path(base_dir) if base_dir else self.config_path.parent diff --git a/packages/uipath/src/uipath/tracing/_otel_exporters.py b/packages/uipath/src/uipath/tracing/_otel_exporters.py index e76177cc5..6f85a1050 100644 --- a/packages/uipath/src/uipath/tracing/_otel_exporters.py +++ b/packages/uipath/src/uipath/tracing/_otel_exporters.py @@ -14,6 +14,14 @@ from uipath._utils._ssl_context import get_httpx_client_kwargs from uipath.platform.common import _SpanUtils from uipath.platform.common._span_utils import SpanStatus +from uipath.platform.common.constants import ( + ENV_BASE_URL, + ENV_ORGANIZATION_ID, + ENV_TENANT_ID, + ENV_UIPATH_ACCESS_TOKEN, + HEADER_INTERNAL_ACCOUNT_ID, + HEADER_INTERNAL_TENANT_ID, +) from uipath.platform.common.retry import NON_RETRYABLE_STATUS_CODES logger = logging.getLogger(__name__) @@ -107,18 +115,16 @@ def __init__( """ super().__init__() self.base_url = self._get_base_url() - self.auth_token = os.environ.get("UIPATH_ACCESS_TOKEN") + self.auth_token = os.environ.get(ENV_UIPATH_ACCESS_TOKEN) self.headers: dict[str, str] = { "Content-Type": "application/json", "Authorization": f"Bearer {self.auth_token}", } if os.environ.get("UIPATH_TRACE_BASE_URL"): - self.headers["X-UiPath-Internal-TenantId"] = os.environ.get( - "UIPATH_TENANT_ID", "" - ) - self.headers["X-UiPath-Internal-AccountId"] = os.environ.get( - "UIPATH_ORGANIZATION_ID", "" + self.headers[HEADER_INTERNAL_TENANT_ID] = os.environ.get(ENV_TENANT_ID, "") + self.headers[HEADER_INTERNAL_ACCOUNT_ID] = os.environ.get( + ENV_ORGANIZATION_ID, "" ) client_kwargs = get_httpx_client_kwargs(headers=self.headers) @@ -411,7 +417,7 @@ def _get_base_url(self) -> str: return trace_base_url.rstrip("/") uipath_url = ( - os.environ.get("UIPATH_URL") + os.environ.get(ENV_BASE_URL) or "https://cloud.uipath.com/dummyOrg/dummyTennant/" ) diff --git a/packages/uipath/tests/cli/chat/test_bridge.py b/packages/uipath/tests/cli/chat/test_bridge.py index 2c18640f3..8ec17cecc 100644 --- a/packages/uipath/tests/cli/chat/test_bridge.py +++ b/packages/uipath/tests/cli/chat/test_bridge.py @@ -11,6 +11,10 @@ from uipath._cli._chat._bridge import SocketIOChatBridge, get_chat_bridge from uipath._cli._debug._bridge import SignalRDebugBridge from uipath.core.triggers import UiPathApiTrigger, UiPathResumeTrigger +from uipath.platform.common.constants import ( + HEADER_INTERNAL_ACCOUNT_ID, + HEADER_INTERNAL_TENANT_ID, +) class MockRuntimeContext: @@ -203,8 +207,8 @@ def test_get_chat_bridge_constructs_correct_headers( assert "Authorization" in bridge.headers assert "Bearer my-access-token" in bridge.headers["Authorization"] - assert "X-UiPath-Internal-TenantId" in bridge.headers - assert "X-UiPath-Internal-AccountId" in bridge.headers + assert HEADER_INTERNAL_TENANT_ID in bridge.headers + assert HEADER_INTERNAL_ACCOUNT_ID in bridge.headers assert "X-UiPath-ConversationId" in bridge.headers assert bridge.headers["X-UiPath-ConversationId"] == "conv-789" @@ -225,8 +229,8 @@ def test_get_chat_bridge_falls_back_to_env_when_tenant_and_org_absent( bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) - assert bridge.headers["X-UiPath-Internal-TenantId"] == "env-tenant" - assert bridge.headers["X-UiPath-Internal-AccountId"] == "env-org" + assert bridge.headers[HEADER_INTERNAL_TENANT_ID] == "env-tenant" + assert bridge.headers[HEADER_INTERNAL_ACCOUNT_ID] == "env-org" def test_get_chat_bridge_includes_conversational_user_id_header_when_set( self, monkeypatch: pytest.MonkeyPatch diff --git a/packages/uipath/tests/cli/chat/test_voice_bridge.py b/packages/uipath/tests/cli/chat/test_voice_bridge.py index 39e094bfa..a9c8c1baf 100644 --- a/packages/uipath/tests/cli/chat/test_voice_bridge.py +++ b/packages/uipath/tests/cli/chat/test_voice_bridge.py @@ -14,6 +14,10 @@ UiPathVoiceToolCallRequest, UiPathVoiceToolCallResult, ) +from uipath.platform.common.constants import ( + HEADER_INTERNAL_ACCOUNT_ID, + HEADER_INTERNAL_TENANT_ID, +) def _make_session(tool_handler: Any = None) -> VoiceToolCallSession: @@ -133,8 +137,8 @@ def test_headers_fall_back_to_env_when_context_ids_are_none( bridge = get_voice_bridge(ctx, AsyncMock()) - assert bridge._headers["X-UiPath-Internal-TenantId"] == "env-tenant" - assert bridge._headers["X-UiPath-Internal-AccountId"] == "env-org" + assert bridge._headers[HEADER_INTERNAL_TENANT_ID] == "env-tenant" + assert bridge._headers[HEADER_INTERNAL_ACCOUNT_ID] == "env-org" def test_includes_conversational_user_id_header_when_set( self, monkeypatch: pytest.MonkeyPatch diff --git a/packages/uipath/tests/tracing/test_otel_exporters.py b/packages/uipath/tests/tracing/test_otel_exporters.py index 2ecf930fc..8c2dc93a4 100644 --- a/packages/uipath/tests/tracing/test_otel_exporters.py +++ b/packages/uipath/tests/tracing/test_otel_exporters.py @@ -8,6 +8,10 @@ from opentelemetry.sdk.trace.export import SpanExportResult from uipath.platform.common._span_utils import SpanStatus +from uipath.platform.common.constants import ( + HEADER_INTERNAL_ACCOUNT_ID, + HEADER_INTERNAL_TENANT_ID, +) from uipath.tracing._otel_exporters import LlmOpsHttpExporter @@ -230,11 +234,11 @@ def test_internal_headers_set_when_trace_base_url_present(): exporter = LlmOpsHttpExporter() assert ( - exporter.headers["X-UiPath-Internal-TenantId"] + exporter.headers[HEADER_INTERNAL_TENANT_ID] == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" ) assert ( - exporter.headers["X-UiPath-Internal-AccountId"] + exporter.headers[HEADER_INTERNAL_ACCOUNT_ID] == "11111111-2222-3333-4444-555555555555" ) @@ -254,8 +258,8 @@ def test_internal_headers_not_set_without_trace_base_url(): with patch("uipath.tracing._otel_exporters.httpx.Client"): exporter = LlmOpsHttpExporter() - assert "X-UiPath-Internal-TenantId" not in exporter.headers - assert "X-UiPath-Internal-AccountId" not in exporter.headers + assert HEADER_INTERNAL_TENANT_ID not in exporter.headers + assert HEADER_INTERNAL_ACCOUNT_ID not in exporter.headers def test_send_with_retries_success(): diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index c673c5e1d..30a57c9fd 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.16" +version = "2.11.17" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.82" +version = "0.1.83" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From fe490f70b8ac5472a7232c663f48f7ce12beb90e Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan <84002867+viswa-uipath@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:33:59 +0530 Subject: [PATCH 132/191] feat(governance): add track_event for custom telemetry to /runtime/log (#1745) Co-authored-by: Aditi Kumari Co-authored-by: Claude Opus 4.7 (1M context) --- packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/governance/providers.py | 6 + packages/uipath-core/uv.lock | 2 +- packages/uipath-platform/pyproject.toml | 2 +- .../governance/_governance_provider.py | 33 +++ .../governance/_governance_service.py | 101 ++++++- .../services/test_governance_provider.py | 36 +++ .../tests/services/test_governance_service.py | 247 ++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 10 files changed, 427 insertions(+), 6 deletions(-) diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 6fb1831f6..185d84ab6 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.27" +version = "0.5.28" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/governance/providers.py b/packages/uipath-core/src/uipath/core/governance/providers.py index 2a0b7155a..5435ad389 100644 --- a/packages/uipath-core/src/uipath/core/governance/providers.py +++ b/packages/uipath-core/src/uipath/core/governance/providers.py @@ -130,6 +130,12 @@ class GovernRequest(BaseModel): reference_id: str | None = Field(default=None, alias="referenceId") agent_version: str | None = Field(default=None, alias="agentVersion") + # Runtime identity for governance telemetry; the server stamps these on the + # rule-denied events it emits. Optional — omitted from the wire when None. + agent_framework: str | None = Field(default=None, alias="agentFramework") + agent_type: str | None = Field(default=None, alias="agentType") + runtime_version: str | None = Field(default=None, alias="runtimeVersion") + # ---------------------------------------------------------------------- # Provider protocols diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index 6bcdfc306..a1322e25d 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -1011,7 +1011,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.27" +version = "0.5.28" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 0678658a9..b3743cc0b 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "httpx>=0.28.1", "tenacity>=9.0.0", "truststore>=0.10.1", - "uipath-core>=0.5.26, <0.6.0", + "uipath-core>=0.5.28, <0.6.0", "pydantic-function-models>=0.1.11", "sqlparse>=0.5.5", ] diff --git a/packages/uipath-platform/src/uipath/platform/governance/_governance_provider.py b/packages/uipath-platform/src/uipath/platform/governance/_governance_provider.py index 23f1464bb..1b336ff0e 100644 --- a/packages/uipath-platform/src/uipath/platform/governance/_governance_provider.py +++ b/packages/uipath-platform/src/uipath/platform/governance/_governance_provider.py @@ -12,6 +12,8 @@ from __future__ import annotations +from typing import Any + from uipath.core.governance import GovernRequest, PolicyContext, PolicyResponse from ..common._config import UiPathApiConfig @@ -79,3 +81,34 @@ def compensate(self, request: GovernRequest) -> None: async def compensate_async(self, request: GovernRequest) -> None: """Async variant of :meth:`compensate`.""" await self._service._compensate_async(request) + + # ── Custom telemetry events ────────────────────────────────────── + + def track_event( + self, + *, + event_name: str, + data: dict[str, Any] | None = None, + operation_id: str | None = None, + ) -> None: + """Record a custom telemetry event — delegates to ``GovernanceService``. + + See :meth:`GovernanceService._track_event` for parameter + semantics — in particular, the ``operation_id`` → trace-id + fallback. + """ + self._service._track_event( + event_name=event_name, data=data, operation_id=operation_id + ) + + async def track_event_async( + self, + *, + event_name: str, + data: dict[str, Any] | None = None, + operation_id: str | None = None, + ) -> None: + """Async variant of :meth:`track_event`.""" + await self._service._track_event_async( + event_name=event_name, data=data, operation_id=operation_id + ) diff --git a/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py b/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py index afd5701b7..739791a7a 100644 --- a/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py +++ b/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py @@ -1,6 +1,6 @@ """Service for the ``agenticgovernance_`` ingress. -Wraps the two governance backend endpoints UiPath exposes: +Wraps the governance backend endpoints UiPath exposes: - ``GET /{org}/agenticgovernance_/api/v1/runtime/policy`` — fetch the tenant-managed policy pack (see :meth:`GovernanceService.retrieve_policy`). @@ -8,6 +8,13 @@ governance call fired when a ``guardrail_fallback`` rule matches (see :meth:`GovernanceService.compensate`). +A third backend endpoint — +``POST /{org}/agenticgovernance_/api/v1/runtime/log`` — emits custom +telemetry events to App Insights. It's reached only through the +internal ``_track_event`` helper, which the runtime adapter +(:class:`UiPathPlatformGovernanceProvider`) calls; not part of the +client-facing service surface. + Org/tenant scoping is read from :class:`UiPathConfig`; auth, retries, trace context, and error enrichment come from :class:`BaseService`. """ @@ -35,8 +42,14 @@ GOVERNANCE_SERVICE_PREFIX = "agenticgovernance_" POLICY_API_PATH = "api/v1/runtime/policy" GOVERN_API_PATH = "api/v1/runtime/govern" +LOG_API_PATH = "api/v1/runtime/log" AGENT_TYPE_PARAM = "agentType" +# Caller-set correlation id that becomes the App Insights ``operation_Id`` +# stamped on every customEvent produced by the matching ``/runtime/log`` +# request — see the spec on the platform-side ``postLogHandler``. +HEADER_OPERATION_ID = "x-uipath-operation-id" + class GovernanceService(BaseService): """Service for the agenticgovernance_ ingress. @@ -306,6 +319,92 @@ def _resolve_request_trace_id(request: GovernRequest) -> GovernRequest: return request return request.model_copy(update={"trace_id": resolved}) + # ── Custom telemetry events (internal runtime seam) ────────────── + # + # ``_track_event`` / ``_track_event_async`` are intentionally + # underscore-prefixed: they exist for the runtime adapter + # (:class:`UiPathPlatformGovernanceProvider`) to fire telemetry + # events through the platform's HTTP stack, not as a client-facing + # SDK call. Keeping them off the public surface keeps the auto- + # generated docs (``mkdocs`` + ``mkdocstrings``) focused on the + # endpoints customers consume directly (``retrieve_policy`` / + # ``compensate``). + + def _track_event( + self, + *, + event_name: str, + data: dict[str, Any] | None = None, + operation_id: str | None = None, + ) -> None: + """POST a custom telemetry event to ``/runtime/log``. + + Internal seam — the runtime adapter + (:class:`UiPathPlatformGovernanceProvider`) calls this to emit + governance audit events through the platform's HTTP stack. + The server forwards the event to App Insights as a + ``customEvents`` row; account / tenant / organization are + stamped server-side from the gateway headers and JWT. + + Args: + event_name: Non-empty event name — becomes the App Insights + row ``name``. The platform redactor runs over this before + it reaches the sink. + data: Optional properties flattened into the event. Non-dict + values are dropped server-side. + operation_id: Optional correlation id forwarded as the + ``x-uipath-operation-id`` header. When omitted, falls + back to :func:`resolve_trace_id` so events emitted from + the same agent trace share an ``operation_Id`` and are + queryable together in KQL. When neither is available, + the header is omitted and App Insights generates its + own id per event. + + Raises: + ValueError: If ``event_name`` is empty / whitespace-only, or + if ``UiPathConfig.organization_id`` / + ``UiPathConfig.tenant_id`` is not set. + EnrichedException: If the backend returns a non-2xx response. + """ + self._validate_event_name(event_name) + url, headers = self._build_org_scoped_request(LOG_API_PATH) + resolved_op_id = operation_id or resolve_trace_id() + if resolved_op_id: + headers[HEADER_OPERATION_ID] = resolved_op_id + payload: dict[str, Any] = {"eventName": event_name} + if data is not None: + payload["data"] = data + self.request("POST", url=url, headers=headers, json=payload) + + async def _track_event_async( + self, + *, + event_name: str, + data: dict[str, Any] | None = None, + operation_id: str | None = None, + ) -> None: + """Async variant of :meth:`_track_event`. Internal seam.""" + self._validate_event_name(event_name) + url, headers = self._build_org_scoped_request(LOG_API_PATH) + resolved_op_id = operation_id or resolve_trace_id() + if resolved_op_id: + headers[HEADER_OPERATION_ID] = resolved_op_id + payload: dict[str, Any] = {"eventName": event_name} + if data is not None: + payload["data"] = data + await self.request_async("POST", url=url, headers=headers, json=payload) + + @staticmethod + def _validate_event_name(event_name: str) -> None: + """Reject empty/whitespace-only event names client-side. + + The platform's ``/runtime/log`` handler rejects these with a + 4xx; failing fast here gives the caller a clearer error and + avoids the round trip. + """ + if not event_name or not event_name.strip(): + raise ValueError("event_name must be a non-empty string.") + # ── Internals ──────────────────────────────────────────────────── def _build_org_scoped_request(self, path: str) -> tuple[str, dict[str, str]]: diff --git a/packages/uipath-platform/tests/services/test_governance_provider.py b/packages/uipath-platform/tests/services/test_governance_provider.py index 24e489f65..f7211d97a 100644 --- a/packages/uipath-platform/tests/services/test_governance_provider.py +++ b/packages/uipath-platform/tests/services/test_governance_provider.py @@ -164,3 +164,39 @@ async def test_compensate_async_delegates_to_service( requests = httpx_mock.get_requests() assert len(requests) == 1 assert requests[0].method == "POST" + + def test_track_event_delegates_to_service( + self, + httpx_mock: HTTPXMock, + provider: UiPathPlatformGovernanceProvider, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/log", + method="POST", + status_code=204, + ) + + provider.track_event(event_name="ev", data={"k": "v"}, operation_id="op-1") + + sent = httpx_mock.get_requests()[-1] + assert sent.method == "POST" + assert sent.headers["x-uipath-operation-id"] == "op-1" + + async def test_track_event_async_delegates_to_service( + self, + httpx_mock: HTTPXMock, + provider: UiPathPlatformGovernanceProvider, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/log", + method="POST", + status_code=204, + ) + + await provider.track_event_async(event_name="ev", operation_id="op-2") + + sent = httpx_mock.get_requests()[-1] + assert sent.method == "POST" + assert sent.headers["x-uipath-operation-id"] == "op-2" diff --git a/packages/uipath-platform/tests/services/test_governance_service.py b/packages/uipath-platform/tests/services/test_governance_service.py index 2c808b943..eb4941faf 100644 --- a/packages/uipath-platform/tests/services/test_governance_service.py +++ b/packages/uipath-platform/tests/services/test_governance_service.py @@ -646,6 +646,253 @@ def test_redirects_compensate_to_override( assert sent.method == "POST" assert sent.headers["X-UiPath-Internal-AccountId"] == ORG_ID + def test_redirects_track_event_to_override( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv( + "UIPATH_SERVICE_URL_AGENTICGOVERNANCE", "http://localhost:8123" + ) + httpx_mock.add_response( + url="http://localhost:8123/api/v1/runtime/log", + method="POST", + status_code=204, + ) + + service._track_event(event_name="hello", operation_id="op-1") + + sent = httpx_mock.get_requests()[-1] + assert sent.method == "POST" + assert sent.headers["X-UiPath-Internal-AccountId"] == ORG_ID + assert sent.headers["x-uipath-operation-id"] == "op-1" + + class TestTrackEvent: + """Test track_event (sync).""" + + def test_posts_event_name_only_payload( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(204) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/log", + ) + + service._track_event(event_name="agent.started") + + request = captured["request"] + assert request.method == "POST" + assert request.headers["x-uipath-internal-tenantid"] == TENANT_ID + assert json.loads(request.content) == {"eventName": "agent.started"} + + def test_includes_data_when_provided( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(204) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/log", + ) + + service._track_event( + event_name="agent.completed", + data={"duration_ms": 1234, "outcome": "success"}, + ) + + body = json.loads(captured["request"].content) + assert body == { + "eventName": "agent.completed", + "data": {"duration_ms": 1234, "outcome": "success"}, + } + + def test_sends_caller_operation_id_header( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(204) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/log", + ) + + service._track_event(event_name="ev", operation_id="caller-supplied") + + assert captured["request"].headers["x-uipath-operation-id"] == ( + "caller-supplied" + ) + + def test_falls_back_to_resolved_trace_id( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + # When the caller omits operation_id, the header is filled + # from resolve_trace_id() so events join the agent's trace. + monkeypatch.setenv("UIPATH_TRACE_ID", TENANT_ID_HEX) + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(204) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/log", + ) + + service._track_event(event_name="ev") + + assert captured["request"].headers["x-uipath-operation-id"] == TENANT_ID_HEX + + def test_caller_operation_id_overrides_trace_fallback( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_TRACE_ID", TENANT_ID_HEX) + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(204) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/log", + ) + + service._track_event(event_name="ev", operation_id="explicit") + + assert captured["request"].headers["x-uipath-operation-id"] == "explicit" + + def test_omits_operation_id_header_when_no_source( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(204) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/log", + ) + + service._track_event(event_name="ev") + + assert "x-uipath-operation-id" not in captured["request"].headers + + def test_raises_when_org_id_missing( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_ORGANIZATION_ID", raising=False) + monkeypatch.setenv("UIPATH_TENANT_ID", TENANT_ID) + service = GovernanceService( + config=config, execution_context=execution_context + ) + + with pytest.raises(ValueError, match="UIPATH_ORGANIZATION_ID"): + service._track_event(event_name="ev") + + def test_raises_on_http_error( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + from uipath.platform.errors import EnrichedException + + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/log", + status_code=400, + text="bad event", + ) + + with pytest.raises(EnrichedException): + service._track_event(event_name="ev") + + @pytest.mark.parametrize("invalid_name", ["", " ", "\t", "\n"]) + def test_raises_on_empty_event_name( + self, + service: GovernanceService, + invalid_name: str, + ) -> None: + # Fail fast client-side instead of round-tripping a backend + # 400; matches the platform's own non-empty check on + # /runtime/log. + with pytest.raises(ValueError, match="event_name"): + service._track_event(event_name=invalid_name) + + class TestTrackEventAsync: + """Test track_event_async.""" + + async def test_posts_event_and_falls_back_to_trace_id( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_TRACE_ID", TENANT_ID_HEX) + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/log", + status_code=204, + ) + + await service._track_event_async(event_name="ev", data={"foo": "bar"}) + + sent = httpx_mock.get_requests()[-1] + assert sent.method == "POST" + assert sent.headers["x-uipath-operation-id"] == TENANT_ID_HEX + assert json.loads(sent.content) == { + "eventName": "ev", + "data": {"foo": "bar"}, + } + + async def test_raises_on_empty_event_name( + self, service: GovernanceService + ) -> None: + with pytest.raises(ValueError, match="event_name"): + await service._track_event_async(event_name=" ") + class TestResolveTraceId: """Test the resolve_trace_id helper.""" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 1535e576f..1a8ac3ee8 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1063,7 +1063,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.27" +version = "0.5.28" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 30a57c9fd..126c14982 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2659,7 +2659,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.27" +version = "0.5.28" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, From 66dbaa88d386cc7f83184bf9a90007439d2ba96f Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan <84002867+viswa-uipath@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:55:10 +0530 Subject: [PATCH 133/191] =?UTF-8?q?chore(platform):=20bump=20version=200.1?= =?UTF-8?q?.83=20=E2=86=92=200.1.84=20(#1774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.7 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index b3743cc0b..69289ca85 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.83" +version = "0.1.84" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 1a8ac3ee8..da079bd3f 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.83" +version = "0.1.84" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 126c14982..ecd4aed07 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.83" +version = "0.1.84" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From e671be851956d3eccd204862038f6f667f2c89bc Mon Sep 17 00:00:00 2001 From: Norman <82416746+norman-le@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:58:04 -0400 Subject: [PATCH 134/191] fix: conversational events - client side tool event responses should be queued (#1760) --- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/_cli/_chat/_bridge.py | 141 ++++-- packages/uipath/tests/cli/chat/test_bridge.py | 423 ++++++++++++++++-- packages/uipath/uv.lock | 2 +- 4 files changed, 494 insertions(+), 74 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 0025ccb60..63c4777a2 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.11.17" +version = "2.11.18" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/_chat/_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_bridge.py index 70fe03134..ebefd1cff 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_bridge.py @@ -4,6 +4,7 @@ import json import logging import os +from collections import deque from typing import Any from urllib.parse import urlparse @@ -33,6 +34,14 @@ logger = logging.getLogger(__name__) +# Type for tool call resume values (confirmToolCall or endToolCall payloads) +ToolResumeValue = ( + UiPathConversationToolCallConfirmationEvent | UiPathConversationToolCallEndEvent +) + +# Wrapper that pairs a resume value with its tool_call_id for keyed matching +ToolResumeItem = dict[str, Any] # {"tool_call_id": str, "value": ToolResumeValue} + class CASErrorId: """Error IDs for the Conversational Agent Service (CAS), matching the Temporal backend.""" @@ -137,12 +146,29 @@ def __init__( self._client: Any | None = None self._connected_event = asyncio.Event() - self._tool_resume_event = asyncio.Event() - self._tool_resume_value: ( - UiPathConversationToolCallConfirmationEvent - | UiPathConversationToolCallEndEvent - | None - ) = None + # --- Tool call resume state --- + # When the LLM invokes multiple tools in one turn, the client can send + # back confirmToolCall / endToolCall responses concurrently and in any + # order. Three data structures coordinate matching each response to the + # correct wait_for_resume() call: + # + # 1. _expected_tool_call_ids (deque): + # Ordered queue of tool_call_ids populated by emit_interrupt_event() + # (called by the runtime BEFORE each wait_for_resume()). Tells + # wait_for_resume() WHICH tool_call_id it should consume next. + # + # 2. _tool_resume_results (dict): + # Responses that arrived BEFORE wait_for_resume() was called for that + # tool_call_id. When wait_for_resume() runs, it checks here first + # and returns immediately if a match exists — no blocking needed. + # + # 3. _tool_resume_pending (dict of Futures): + # Created by wait_for_resume() when the response hasn't arrived yet. + # When the response later arrives in _handle_conversation_event, the + # Future is resolved and wait_for_resume() unblocks. + self._tool_resume_results: dict[str, ToolResumeItem] = {} + self._tool_resume_pending: dict[str, asyncio.Future[ToolResumeItem]] = {} + self._expected_tool_call_ids: deque[str] = deque() self._current_message_id: str | None = None # Set CAS_WEBSOCKET_DISABLED when using the debugger to prevent websocket errors from @@ -384,14 +410,19 @@ async def emit_exchange_error_event(self, error: Exception) -> None: raise RuntimeError(f"Failed to send exchange error event: {e}") from e async def emit_interrupt_event(self, resume_trigger: UiPathResumeTrigger): - """No-op. + """Register the trigger's tool_call_id for the upcoming wait_for_resume(). - Tool confirmation is handled end-to-end via ``startToolCall`` with - ``requireConfirmation: true`` paired with ``wait_for_resume()``. - executingToolCall is emitted by the MessageMapper (non-confirmed - tools) and the runtime loop post-confirmation (confirmed tools). + Does not emit any websocket event — tool confirmation and execution + events are handled elsewhere. The runtime calls this immediately + before wait_for_resume() for each trigger, so we record the + tool_call_id here so wait_for_resume() knows which response to match. """ - return None + if resume_trigger.api_resume and isinstance( + resume_trigger.api_resume.request, dict + ): + tool_call_id = resume_trigger.api_resume.request.get("tool_call_id") + if isinstance(tool_call_id, str) and tool_call_id: + self._expected_tool_call_ids.append(tool_call_id) async def emit_executing_tool_call_event( self, @@ -418,21 +449,77 @@ async def emit_executing_tool_call_event( await self.emit_message_event(executing_event) async def wait_for_resume(self) -> dict[str, Any]: - """Wait for a tool resume event (confirmToolCall or endToolCall) to be received.""" - if self._tool_resume_value is None: - self._tool_resume_event.clear() - await self._tool_resume_event.wait() + """Wait for a tool resume event (confirmToolCall or endToolCall). + + Pops the next expected tool_call_id (registered by emit_interrupt_event) + and returns the matching response. Two cases: + + 1. Response already arrived (stored in _tool_resume_results) — return + immediately without blocking. + 2. Response hasn't arrived yet — create a Future in _tool_resume_pending, + block until _handle_conversation_event resolves it. + + Returns: + The resume data dict, including ``tool_call_id``. + """ + if not self._expected_tool_call_ids: + raise RuntimeError( + "wait_for_resume() called but no tool_call_id was registered " + "by emit_interrupt_event(). This indicates a caller/protocol mismatch." + ) + + expected_id = self._expected_tool_call_ids.popleft() - value = self._tool_resume_value - self._tool_resume_value = None - self._tool_resume_event.clear() + if expected_id in self._tool_resume_results: + # Response arrived before we got here — return it immediately + item = self._tool_resume_results.pop(expected_id) + else: + # Response hasn't arrived yet — wait for it + future: asyncio.Future[ToolResumeItem] = ( + asyncio.get_running_loop().create_future() + ) + self._tool_resume_pending[expected_id] = future + item = await future + + value = item["value"] + result = value.model_dump(mode="python", by_alias=False) + result["tool_call_id"] = item["tool_call_id"] + return result + + def _resolve_or_store_resume( + self, tool_call_id: str, value: ToolResumeValue + ) -> None: + """Route an incoming confirmToolCall/endToolCall to the correct consumer. - """For the case where there's no tool confirmation and the client side tool sends endToolCall back before wait_for_resume is called. - Unlikely in practice, but possible in theory, since executingToolCall is emitted during the streaming. + Called from _handle_conversation_event when a tool resume response + arrives from the client. Two cases: + + 1. wait_for_resume() is already waiting (Future in _tool_resume_pending) + — resolve the Future so it unblocks immediately. + 2. wait_for_resume() hasn't been called yet for this tool_call_id + — store in _tool_resume_results so it's found instantly when + wait_for_resume() runs later. """ - if value: - return value.model_dump(mode="python", by_alias=False) - return {} + item: ToolResumeItem = {"tool_call_id": tool_call_id, "value": value} + if tool_call_id in self._tool_resume_pending: + future = self._tool_resume_pending.pop(tool_call_id) + if not future.done(): + future.set_result(item) + else: + # Future was cancelled or already resolved — store the payload + # so a subsequent wait_for_resume() can still find it. + logger.warning( + f"Resume for tool_call_id={tool_call_id} — " + "future already done, storing as fallback." + ) + self._tool_resume_results[tool_call_id] = item + else: + if tool_call_id in self._tool_resume_results: + logger.warning( + f"Duplicate resume for tool_call_id={tool_call_id} — " + "overwriting previously stored result." + ) + self._tool_resume_results[tool_call_id] = item @property def is_connected(self) -> bool: @@ -469,11 +556,9 @@ async def _handle_conversation_event( and (tool_call := parsed_event.exchange.message.tool_call) ): if confirm := tool_call.confirm: - self._tool_resume_value = confirm - self._tool_resume_event.set() + self._resolve_or_store_resume(tool_call.tool_call_id, confirm) elif end := tool_call.end: - self._tool_resume_value = end - self._tool_resume_event.set() + self._resolve_or_store_resume(tool_call.tool_call_id, end) except Exception as e: logger.warning(f"Error parsing conversation event: {e}") diff --git a/packages/uipath/tests/cli/chat/test_bridge.py b/packages/uipath/tests/cli/chat/test_bridge.py index 8ec17cecc..0a8f29e86 100644 --- a/packages/uipath/tests/cli/chat/test_bridge.py +++ b/packages/uipath/tests/cli/chat/test_bridge.py @@ -492,7 +492,7 @@ async def test_send_with_datetime_does_not_raise(self) -> None: class TestEmitInterruptEvent: - """Tests for emit_interrupt_event (now a no-op for executingToolCall).""" + """Tests for emit_interrupt_event — registers expected tool_call_ids.""" def _make_bridge(self) -> SocketIOChatBridge: bridge = SocketIOChatBridge( @@ -506,8 +506,8 @@ def _make_bridge(self) -> SocketIOChatBridge: return bridge @pytest.mark.anyio - async def test_emit_interrupt_event_is_noop(self) -> None: - """emit_interrupt_event no longer emits executingToolCall.""" + async def test_emit_interrupt_event_does_not_emit_websocket_event(self) -> None: + """emit_interrupt_event does not emit any websocket event.""" bridge = self._make_bridge() emitted_events: list[Any] = [] @@ -531,6 +531,47 @@ async def capture_emit(event: Any) -> None: assert len(emitted_events) == 0 + @pytest.mark.anyio + async def test_emit_interrupt_event_registers_tool_call_id(self) -> None: + """emit_interrupt_event adds the tool_call_id to the expected queue.""" + bridge = self._make_bridge() + + trigger = UiPathResumeTrigger( + api_resume=UiPathApiTrigger( + request={"tool_call_id": "tc-42", "tool_name": "my_tool"} + ) + ) + + await bridge.emit_interrupt_event(trigger) + + assert list(bridge._expected_tool_call_ids) == ["tc-42"] + + @pytest.mark.anyio + async def test_emit_interrupt_event_skips_without_tool_call_id(self) -> None: + """emit_interrupt_event does not register if tool_call_id is missing.""" + bridge = self._make_bridge() + + trigger = UiPathResumeTrigger( + api_resume=UiPathApiTrigger(request={"tool_name": "my_tool"}) + ) + + await bridge.emit_interrupt_event(trigger) + + assert len(bridge._expected_tool_call_ids) == 0 + + @pytest.mark.anyio + async def test_emit_interrupt_event_registers_multiple_in_order(self) -> None: + """Multiple emit_interrupt_event calls register IDs in FIFO order.""" + bridge = self._make_bridge() + + for tc_id in ["tc-1", "tc-2", "tc-3"]: + trigger = UiPathResumeTrigger( + api_resume=UiPathApiTrigger(request={"tool_call_id": tc_id}) + ) + await bridge.emit_interrupt_event(trigger) + + assert list(bridge._expected_tool_call_ids) == ["tc-1", "tc-2", "tc-3"] + class TestEmitExecutingToolCall: """Tests for emit_executing_tool_call_event (post-confirmation executingToolCall emission).""" @@ -627,10 +668,8 @@ async def capture_emit(event: Any) -> None: class TestWaitForResumeEndToolCall: """Tests for wait_for_resume unblocking on endToolCall events.""" - @pytest.mark.anyio - async def test_end_tool_call_unblocks_wait_for_resume(self) -> None: - """Receiving an endToolCall event unblocks wait_for_resume and returns parsed payload.""" - bridge = SocketIOChatBridge( + def _make_bridge(self) -> SocketIOChatBridge: + return SocketIOChatBridge( websocket_url="wss://test.example.com", websocket_path="/socket.io", conversation_id="conv-123", @@ -638,16 +677,19 @@ async def test_end_tool_call_unblocks_wait_for_resume(self) -> None: headers={}, ) - end_event = { + def _make_end_event(self, tool_call_id: str, output: Any = None) -> dict[str, Any]: + return { "conversationId": "conv-123", "exchange": { "exchangeId": "exch-456", "message": { "messageId": "msg-200", "toolCall": { - "toolCallId": "tc-99", + "toolCallId": tool_call_id, "endToolCall": { - "output": {"result": "ok"}, + "output": output + if output is not None + else {"result": "ok"}, "isError": False, }, }, @@ -655,36 +697,15 @@ async def test_end_tool_call_unblocks_wait_for_resume(self) -> None: }, } - async def simulate_end_event() -> None: - await asyncio.sleep(0.05) - await bridge._handle_conversation_event(end_event, "sid-1") - - task = asyncio.create_task(simulate_end_event()) - result = await bridge.wait_for_resume() - await task - - assert result["output"] == {"result": "ok"} - assert result["is_error"] is False - - @pytest.mark.anyio - async def test_confirm_tool_call_unblocks_wait_for_resume(self) -> None: - """Receiving a confirmToolCall event also unblocks wait_for_resume.""" - bridge = SocketIOChatBridge( - websocket_url="wss://test.example.com", - websocket_path="/socket.io", - conversation_id="conv-123", - exchange_id="exch-456", - headers={}, - ) - - confirm_event = { + def _make_confirm_event(self, tool_call_id: str) -> dict[str, Any]: + return { "conversationId": "conv-123", "exchange": { "exchangeId": "exch-456", "message": { "messageId": "msg-200", "toolCall": { - "toolCallId": "tc-99", + "toolCallId": tool_call_id, "confirmToolCall": { "approved": True, "input": {"edited": "data"}, @@ -694,9 +715,44 @@ async def test_confirm_tool_call_unblocks_wait_for_resume(self) -> None: }, } + async def _register(self, bridge: SocketIOChatBridge, tool_call_id: str) -> None: + """Register an expected tool_call_id via emit_interrupt_event.""" + trigger = UiPathResumeTrigger( + api_resume=UiPathApiTrigger(request={"tool_call_id": tool_call_id}) + ) + await bridge.emit_interrupt_event(trigger) + + @pytest.mark.anyio + async def test_end_tool_call_unblocks_wait_for_resume(self) -> None: + """Receiving an endToolCall event unblocks wait_for_resume and returns parsed payload.""" + bridge = self._make_bridge() + await self._register(bridge, "tc-99") + + async def simulate_end_event() -> None: + await asyncio.sleep(0.05) + await bridge._handle_conversation_event( + self._make_end_event("tc-99"), "sid-1" + ) + + task = asyncio.create_task(simulate_end_event()) + result = await bridge.wait_for_resume() + await task + + assert result["output"] == {"result": "ok"} + assert result["is_error"] is False + assert result["tool_call_id"] == "tc-99" + + @pytest.mark.anyio + async def test_confirm_tool_call_unblocks_wait_for_resume(self) -> None: + """Receiving a confirmToolCall event also unblocks wait_for_resume.""" + bridge = self._make_bridge() + await self._register(bridge, "tc-99") + async def simulate_confirm_event() -> None: await asyncio.sleep(0.05) - await bridge._handle_conversation_event(confirm_event, "sid-1") + await bridge._handle_conversation_event( + self._make_confirm_event("tc-99"), "sid-1" + ) task = asyncio.create_task(simulate_confirm_event()) result = await bridge.wait_for_resume() @@ -704,11 +760,150 @@ async def simulate_confirm_event() -> None: assert result["approved"] is True assert result["input"] == {"edited": "data"} + assert result["tool_call_id"] == "tc-99" @pytest.mark.anyio async def test_early_end_tool_call_is_not_lost(self) -> None: """An endToolCall that arrives before wait_for_resume is called must not be lost.""" - bridge = SocketIOChatBridge( + bridge = self._make_bridge() + await self._register(bridge, "tc-100") + + # Response arrives BEFORE wait_for_resume is called + await bridge._handle_conversation_event( + self._make_end_event("tc-100", output={"early": True}), "sid-1" + ) + + result = await bridge.wait_for_resume() + + assert result["output"] == {"early": True} + assert result["is_error"] is False + assert result["tool_call_id"] == "tc-100" + + @pytest.mark.anyio + async def test_concurrent_tool_calls_all_early(self) -> None: + """Multiple endToolCall responses arriving before any wait_for_resume are all preserved.""" + bridge = self._make_bridge() + + # Runtime registers 3 expected tool calls + for tc_id in ["tc-1", "tc-2", "tc-3"]: + await self._register(bridge, tc_id) + + # All 3 responses arrive before any wait_for_resume call + for tc_id in ["tc-1", "tc-2", "tc-3"]: + await bridge._handle_conversation_event( + self._make_end_event(tc_id, output={"id": tc_id}), "sid-1" + ) + + # Each wait_for_resume returns the correct result matched by tool_call_id + for tc_id in ["tc-1", "tc-2", "tc-3"]: + result = await bridge.wait_for_resume() + assert result["tool_call_id"] == tc_id + assert result["output"] == {"id": tc_id} + + # All storage is empty after consumption + assert len(bridge._tool_resume_results) == 0 + assert len(bridge._tool_resume_pending) == 0 + + @pytest.mark.anyio + async def test_concurrent_tool_calls_out_of_order(self) -> None: + """Responses arriving in reverse order are matched to the correct wait_for_resume call.""" + bridge = self._make_bridge() + + # Runtime registers in order: tc-A, tc-B, tc-C + for tc_id in ["tc-A", "tc-B", "tc-C"]: + await self._register(bridge, tc_id) + + # Responses arrive in reverse order: tc-C, tc-B, tc-A + for tc_id in ["tc-C", "tc-B", "tc-A"]: + await bridge._handle_conversation_event( + self._make_end_event(tc_id, output={"id": tc_id}), "sid-1" + ) + + # wait_for_resume consumes in registration order, each gets correct result + result_a = await bridge.wait_for_resume() + assert result_a["tool_call_id"] == "tc-A" + + result_b = await bridge.wait_for_resume() + assert result_b["tool_call_id"] == "tc-B" + + result_c = await bridge.wait_for_resume() + assert result_c["tool_call_id"] == "tc-C" + + @pytest.mark.anyio + async def test_concurrent_mixed_early_and_late(self) -> None: + """Mix of early arrivals and late arrivals are all matched correctly.""" + bridge = self._make_bridge() + + # Register 3 expected tool calls + for tc_id in ["tc-1", "tc-2", "tc-3"]: + await self._register(bridge, tc_id) + + # tc-1 arrives early (before any wait_for_resume) + await bridge._handle_conversation_event( + self._make_end_event("tc-1", output={"id": "tc-1"}), "sid-1" + ) + + # First wait_for_resume finds tc-1 already in results + result_1 = await bridge.wait_for_resume() + assert result_1["tool_call_id"] == "tc-1" + + # Second wait_for_resume blocks — tc-2 arrives while waiting + async def send_tc2() -> None: + await asyncio.sleep(0.05) + await bridge._handle_conversation_event( + self._make_end_event("tc-2", output={"id": "tc-2"}), "sid-1" + ) + + task = asyncio.create_task(send_tc2()) + result_2 = await bridge.wait_for_resume() + await task + assert result_2["tool_call_id"] == "tc-2" + + # tc-3 arrives early before third wait_for_resume + await bridge._handle_conversation_event( + self._make_end_event("tc-3", output={"id": "tc-3"}), "sid-1" + ) + result_3 = await bridge.wait_for_resume() + assert result_3["tool_call_id"] == "tc-3" + + @pytest.mark.anyio + async def test_confirm_then_end_same_tool_call(self) -> None: + """Tool with requireConversationalConfirmation: confirm and end are handled sequentially.""" + bridge = self._make_bridge() + + # Runtime registers the same tool_call_id twice (once for confirm, once for end) + await self._register(bridge, "tc-42") + await self._register(bridge, "tc-42") + + # Confirm arrives + await bridge._handle_conversation_event( + self._make_confirm_event("tc-42"), "sid-1" + ) + + # First wait_for_resume gets the confirm + result_confirm = await bridge.wait_for_resume() + assert result_confirm["approved"] is True + assert result_confirm["tool_call_id"] == "tc-42" + + # End arrives after confirm was consumed + async def send_end() -> None: + await asyncio.sleep(0.05) + await bridge._handle_conversation_event( + self._make_end_event("tc-42", output={"done": True}), "sid-1" + ) + + task = asyncio.create_task(send_end()) + result_end = await bridge.wait_for_resume() + await task + assert result_end["output"] == {"done": True} + assert result_end["tool_call_id"] == "tc-42" + + +class TestWaitForResumeEdgeCases: + """Edge case tests to ensure the resume mechanism doesn't crash.""" + + def _make_bridge(self) -> SocketIOChatBridge: + return SocketIOChatBridge( websocket_url="wss://test.example.com", websocket_path="/socket.io", conversation_id="conv-123", @@ -716,16 +911,19 @@ async def test_early_end_tool_call_is_not_lost(self) -> None: headers={}, ) - end_event = { + def _make_end_event(self, tool_call_id: str, output: Any = None) -> dict[str, Any]: + return { "conversationId": "conv-123", "exchange": { "exchangeId": "exch-456", "message": { - "messageId": "msg-300", + "messageId": "msg-200", "toolCall": { - "toolCallId": "tc-100", + "toolCallId": tool_call_id, "endToolCall": { - "output": {"early": True}, + "output": output + if output is not None + else {"result": "ok"}, "isError": False, }, }, @@ -733,10 +931,147 @@ async def test_early_end_tool_call_is_not_lost(self) -> None: }, } - # Simulate the event arriving BEFORE wait_for_resume is called - await bridge._handle_conversation_event(end_event, "sid-1") + async def _register(self, bridge: SocketIOChatBridge, tool_call_id: str) -> None: + trigger = UiPathResumeTrigger( + api_resume=UiPathApiTrigger(request={"tool_call_id": tool_call_id}) + ) + await bridge.emit_interrupt_event(trigger) + + @pytest.mark.anyio + async def test_wait_for_resume_without_registration_raises(self) -> None: + """wait_for_resume with empty deque raises RuntimeError, not IndexError.""" + bridge = self._make_bridge() + + with pytest.raises(RuntimeError, match="no tool_call_id was registered"): + await bridge.wait_for_resume() + + @pytest.mark.anyio + async def test_duplicate_response_stored_does_not_crash(self) -> None: + """Two responses for the same tool_call_id before consumption logs warning, doesn't crash.""" + bridge = self._make_bridge() + await self._register(bridge, "tc-dup") + + # First response stored + await bridge._handle_conversation_event( + self._make_end_event("tc-dup", output={"first": True}), "sid-1" + ) + # Second response overwrites (with warning), but no crash + await bridge._handle_conversation_event( + self._make_end_event("tc-dup", output={"second": True}), "sid-1" + ) result = await bridge.wait_for_resume() + # Second overwrote first + assert result["output"] == {"second": True} - assert result["output"] == {"early": True} - assert result["is_error"] is False + @pytest.mark.anyio + async def test_duplicate_response_pending_does_not_crash(self) -> None: + """Two responses while a Future is pending — first resolves it, second is stored as fallback.""" + bridge = self._make_bridge() + await self._register(bridge, "tc-dup") + + # Start waiting (creates a pending Future) + async def wait() -> dict[str, Any]: + return await bridge.wait_for_resume() + + wait_task = asyncio.create_task(wait()) + await asyncio.sleep(0.02) + + # First response resolves the Future + await bridge._handle_conversation_event( + self._make_end_event("tc-dup", output={"first": True}), "sid-1" + ) + # Second response — Future already resolved, should not crash + await bridge._handle_conversation_event( + self._make_end_event("tc-dup", output={"second": True}), "sid-1" + ) + + result = await wait_task + assert result["output"] == {"first": True} + + @pytest.mark.anyio + async def test_malformed_event_does_not_crash(self) -> None: + """Malformed conversation events are caught and don't crash the bridge.""" + bridge = self._make_bridge() + + # Completely wrong structure + await bridge._handle_conversation_event({"garbage": True}, "sid-1") + + # Missing toolCall + await bridge._handle_conversation_event( + { + "conversationId": "conv-123", + "exchange": {"exchangeId": "exch-456", "message": {"messageId": "m-1"}}, + }, + "sid-1", + ) + + # Missing endToolCall and confirmToolCall + await bridge._handle_conversation_event( + { + "conversationId": "conv-123", + "exchange": { + "exchangeId": "exch-456", + "message": { + "messageId": "m-1", + "toolCall": {"toolCallId": "tc-1"}, + }, + }, + }, + "sid-1", + ) + + # No crash — bridge is still functional + assert len(bridge._tool_resume_results) == 0 + assert len(bridge._tool_resume_pending) == 0 + + @pytest.mark.anyio + async def test_emit_interrupt_event_no_api_resume(self) -> None: + """emit_interrupt_event with no api_resume does not crash or register.""" + bridge = self._make_bridge() + + trigger = UiPathResumeTrigger() + await bridge.emit_interrupt_event(trigger) + + assert len(bridge._expected_tool_call_ids) == 0 + + @pytest.mark.anyio + async def test_emit_interrupt_event_non_dict_request(self) -> None: + """emit_interrupt_event with non-dict request does not crash or register.""" + bridge = self._make_bridge() + + trigger = UiPathResumeTrigger(api_resume=UiPathApiTrigger(request="not-a-dict")) + await bridge.emit_interrupt_event(trigger) + + assert len(bridge._expected_tool_call_ids) == 0 + + @pytest.mark.anyio + async def test_unrequested_response_stored_harmlessly(self) -> None: + """A response for an unregistered tool_call_id is stored without crashing.""" + bridge = self._make_bridge() + + # No registration, but a response arrives + await bridge._handle_conversation_event( + self._make_end_event("tc-unknown", output={"surprise": True}), "sid-1" + ) + + # Stored in results — won't be consumed but doesn't crash + assert "tc-unknown" in bridge._tool_resume_results + + @pytest.mark.anyio + async def test_storage_cleaned_up_after_consumption(self) -> None: + """After all wait_for_resume calls complete, no state is left behind.""" + bridge = self._make_bridge() + + for tc_id in ["tc-1", "tc-2", "tc-3"]: + await self._register(bridge, tc_id) + await bridge._handle_conversation_event( + self._make_end_event(tc_id), "sid-1" + ) + + for _ in range(3): + await bridge.wait_for_resume() + + assert len(bridge._tool_resume_results) == 0 + assert len(bridge._tool_resume_pending) == 0 + assert len(bridge._expected_tool_call_ids) == 0 diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index ecd4aed07..0c265e9d0 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.17" +version = "2.11.18" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 8475132dcfdc1aeda6528e0df00de880f48a5c02 Mon Sep 17 00:00:00 2001 From: saksharthakkar <162214066+saksharthakkar@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:08:49 -0700 Subject: [PATCH 135/191] fix(platform): accept v3 string enum values in span converter (#1777) Co-authored-by: Claude Opus 4.8 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/common/_span_utils.py | 33 ++++--- .../tests/services/test_span_utils.py | 92 +++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 5 files changed, 116 insertions(+), 15 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 69289ca85..3b44e14cc 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.84" +version = "0.1.85" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py index 80844f6cc..cbabf8230 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -117,16 +117,24 @@ class ExecutionType(StrEnum): _IntEnumT = TypeVar("_IntEnumT") -def _enum_from_int(table: dict[int, _IntEnumT], raw: Any) -> Optional[_IntEnumT]: - """Map a raw OTEL int attribute to its enum member, or None. +def _enum_from_raw(table: dict[int, _IntEnumT], raw: Any) -> Optional[_IntEnumT]: + """Map a raw OTEL attribute to its StrEnum member, or None. - Returns None for missing / non-int input — and explicitly for ``bool``, - since ``bool`` is an ``int`` subclass (``True == 1``) and would otherwise - be coerced to the value-1 member. + Accepts a legacy int (looked up in ``table``) or the v3 string enum value + like ``"Off"`` (which also matches an already-typed member). ``bool`` is + rejected (``True == 1`` would match the value-1 member); unknown values + return None so callers can apply their own default. """ - if isinstance(raw, bool) or not isinstance(raw, int): + if isinstance(raw, bool): return None - return table.get(raw) + if isinstance(raw, int): + return table.get(raw) + if isinstance(raw, str): + for member in table.values(): + if member == raw: + return member + return None + return None @lru_cache(maxsize=1) @@ -441,16 +449,17 @@ def otel_span_to_uipath_span( span_type_value = attributes_dict.get("span_type", "OpenTelemetry") span_type = str(span_type_value) - # Top-level fields for internal tracing schema. The int->enum lookups go - # through _enum_from_int so they all ignore bools/non-ints identically. - execution_type = _enum_from_int( + # Top-level fields for internal tracing schema. The enum lookups go + # through _enum_from_raw, which accepts both the legacy integer wire form + # and the v3 string-enum value (and rejects bools) identically. + execution_type = _enum_from_raw( _EXECUTION_TYPE_BY_INT, attributes_dict.get("executionType") ) agent_version = attributes_dict.get("agentVersion") reference_id = attributes_dict.get("agentId") or attributes_dict.get( "referenceId" ) - verbosity_level = _enum_from_int( + verbosity_level = _enum_from_raw( _VERBOSITY_LEVEL_BY_INT, attributes_dict.get("verbosityLevel") ) @@ -458,7 +467,7 @@ def otel_span_to_uipath_span( # A real int that isn't a known source is relabeled CodedAgents but # logged — v3 ingest rejects raw integers, so it can't be forwarded. uipath_source_raw = attributes_dict.get("uipath.source") - source = _enum_from_int(_SOURCE_BY_INT, uipath_source_raw) + source = _enum_from_raw(_SOURCE_BY_INT, uipath_source_raw) if source is None: if isinstance(uipath_source_raw, int) and not isinstance( uipath_source_raw, bool diff --git a/packages/uipath-platform/tests/services/test_span_utils.py b/packages/uipath-platform/tests/services/test_span_utils.py index 9bd53b43b..6b5994894 100644 --- a/packages/uipath-platform/tests/services/test_span_utils.py +++ b/packages/uipath-platform/tests/services/test_span_utils.py @@ -129,6 +129,26 @@ def test_set_guid_fields_present_in_to_dict( assert "TenantId" not in d +def _make_otel_span(attributes: dict[str, object]) -> Mock: + """Build a minimal mocked OTEL span carrying the given attributes.""" + mock_span = Mock(spec=OTelSpan) + mock_span.get_span_context.return_value = SpanContext( + trace_id=0x123456789ABCDEF0123456789ABCDEF0, + span_id=0x0123456789ABCDEF, + is_remote=False, + ) + mock_span.name = "test-span" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + mock_span.attributes = attributes + mock_span.events = [] + mock_span.links = [] + now_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = now_ns + mock_span.end_time = now_ns + 1_000_000 + return mock_span + + class TestOTelToUiPathSpan: """OTEL attribute -> top-level UiPathSpan field mapping. @@ -215,6 +235,78 @@ def test_verbosity_level_omitted_when_unset(self) -> None: assert uipath_span.verbosity_level is None assert "VerbosityLevel" not in span_dict + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_verbosity_string_value_maps_to_top_level(self) -> None: + """v3 producers emit verbosityLevel as the StrEnum value "Off" (string). + + The converter must promote it to the top-level VerbosityLevel field so + the LLMOps server can apply its verbosity-Off filter. Before the fix the + string was dropped, the field omitted, and the server defaulted the span + to Information (2) — leaking the AgentDefinition span into the trace. + """ + mock_span = _make_otel_span({"verbosityLevel": "Off"}) + + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + span_dict = uipath_span.to_dict() + + assert uipath_span.verbosity_level == VerbosityLevel.OFF + assert span_dict["VerbosityLevel"] == VerbosityLevel.OFF + + @pytest.mark.parametrize( + "raw, expected", + [ + (6, VerbosityLevel.OFF), # legacy int + ("Off", VerbosityLevel.OFF), # v3 string value + (2, VerbosityLevel.INFORMATION), # legacy int + ("Information", VerbosityLevel.INFORMATION), # v3 string value + ("Nope", None), # unknown string -> None (server default applies) + ], + ) + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_verbosity_accepts_int_and_string(self, raw, expected) -> None: + uipath_span = _SpanUtils.otel_span_to_uipath_span( + _make_otel_span({"verbosityLevel": raw}) + ) + assert uipath_span.verbosity_level == expected + if expected is None: + assert "VerbosityLevel" not in uipath_span.to_dict() + else: + assert uipath_span.to_dict()["VerbosityLevel"] == expected + + @pytest.mark.parametrize( + "raw, expected", + [ + (1, ExecutionType.RUNTIME), # legacy int + ("Runtime", ExecutionType.RUNTIME), # v3 string value + (0, ExecutionType.DEBUG), # legacy int + ("Debug", ExecutionType.DEBUG), # v3 string value + ], + ) + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_execution_type_accepts_int_and_string(self, raw, expected) -> None: + uipath_span = _SpanUtils.otel_span_to_uipath_span( + _make_otel_span({"executionType": raw}) + ) + assert uipath_span.execution_type == expected + assert uipath_span.to_dict()["ExecutionType"] == expected + + @pytest.mark.parametrize( + "raw, expected", + [ + (1, SpanSource.AGENTS), # legacy int + ("Agents", SpanSource.AGENTS), # v3 string value + (10, SpanSource.CODED_AGENTS), # legacy int + ("CodedAgents", SpanSource.CODED_AGENTS), # v3 string value + ], + ) + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_source_accepts_int_and_string(self, raw, expected) -> None: + uipath_span = _SpanUtils.otel_span_to_uipath_span( + _make_otel_span({"uipath.source": raw}) + ) + assert uipath_span.source == expected + assert uipath_span.to_dict()["Source"] == expected + class TestReferenceIdResolution: """`reference_id` resolution chain. diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index da079bd3f..0f6f56edb 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.84" +version = "0.1.85" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 0c265e9d0..dc9d3e918 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.84" +version = "0.1.85" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From cb9f8d2fabbb95aa21c3f6d011bf423d38c6c212 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Tue, 30 Jun 2026 15:50:22 -0700 Subject: [PATCH 136/191] refactor(eval)!: rename AgentExecution to WorkloadExecution (#1772) Co-authored-by: Claude Opus 4.8 (1M context) --- packages/uipath/CLAUDE.md | 2 +- packages/uipath/docs/core/release_notes.md | 32 ++ packages/uipath/docs/eval/contains.md | 36 +- .../uipath/docs/eval/custom_evaluators.md | 64 +-- packages/uipath/docs/eval/exact_match.md | 56 +-- packages/uipath/docs/eval/index.md | 10 +- packages/uipath/docs/eval/json_similarity.md | 60 +-- packages/uipath/docs/eval/llm_judge_output.md | 34 +- .../uipath/docs/eval/llm_judge_trajectory.md | 30 +- packages/uipath/docs/eval/tool_call_args.md | 52 +-- packages/uipath/docs/eval/tool_call_count.md | 58 +-- packages/uipath/docs/eval/tool_call_order.md | 50 +-- packages/uipath/docs/eval/tool_call_output.md | 44 +- packages/uipath/pyproject.toml | 2 +- .../attachment_evaluation_test/README.md | 6 +- .../evaluators/custom/correct_operator.py | 10 +- .../custom/balanced_accuracy_evaluator.py | 6 +- .../custom/attachment_created_evaluator.py | 14 +- .../custom/csv_columns_evaluator.py | 14 +- .../evaluators/custom/csv_shape_evaluator.py | 14 +- .../uipath/_cli/_evals/_progress_reporter.py | 2 +- packages/uipath/src/uipath/_cli/cli_eval.py | 2 +- .../eval/_helpers/evaluators_helpers.py | 16 +- .../src/uipath/eval/_helpers/output_path.py | 2 +- .../uipath/eval/evaluators/base_evaluator.py | 16 +- .../eval/evaluators/base_legacy_evaluator.py | 41 +- .../binary_classification_evaluator.py | 8 +- .../eval/evaluators/contains_evaluator.py | 14 +- .../eval/evaluators/exact_match_evaluator.py | 14 +- .../evaluators/json_similarity_evaluator.py | 8 +- .../legacy_context_precision_evaluator.py | 20 +- .../legacy_csv_exact_match_evaluator.py | 30 +- .../legacy_exact_match_evaluator.py | 10 +- .../legacy_faithfulness_evaluator.py | 64 +-- .../legacy_json_similarity_evaluator.py | 8 +- .../legacy_llm_as_judge_evaluator.py | 12 +- .../evaluators/legacy_trajectory_evaluator.py | 12 +- .../eval/evaluators/line_by_line_utils.py | 22 +- .../eval/evaluators/llm_as_judge_evaluator.py | 18 +- .../evaluators/llm_judge_output_evaluator.py | 14 +- .../llm_judge_trajectory_evaluator.py | 22 +- .../multiclass_classification_evaluator.py | 8 +- .../eval/evaluators/output_evaluator.py | 41 +- .../evaluators/tool_call_args_evaluator.py | 17 +- .../evaluators/tool_call_count_evaluator.py | 12 +- .../evaluators/tool_call_order_evaluator.py | 12 +- .../evaluators/tool_call_output_evaluator.py | 14 +- .../uipath/src/uipath/eval/models/__init__.py | 26 +- .../uipath/src/uipath/eval/models/models.py | 31 +- .../uipath/src/uipath/eval/runtime/_spans.py | 2 +- .../uipath/src/uipath/eval/runtime/runtime.py | 20 +- .../tests/cli/eval/test_eval_span_utils.py | 10 +- .../tests/cli/eval/test_eval_telemetry.py | 2 +- .../test_json_similarity_evaluator.py | 26 +- ...test_legacy_context_precision_evaluator.py | 10 +- .../test_legacy_csv_exact_match_evaluator.py | 170 ++++---- .../test_legacy_exact_match_evaluator.py | 250 +++++------ .../test_legacy_faithfulness_evaluator.py | 48 +-- .../evaluators/test_documentation_examples.py | 398 +++++++++--------- .../test_eval_level_expected_output.py | 44 +- .../evaluators/test_evaluator_methods.py | 322 +++++++------- .../test_legacy_target_output_key_paths.py | 50 ++- .../evaluators/test_line_by_line_utils.py | 28 +- .../test_workload_execution_deprecation.py | 87 ++++ packages/uipath/uv.lock | 2 +- 65 files changed, 1397 insertions(+), 1182 deletions(-) create mode 100644 packages/uipath/tests/evaluators/test_workload_execution_deprecation.py diff --git a/packages/uipath/CLAUDE.md b/packages/uipath/CLAUDE.md index 9b4c372c2..a5b489af5 100644 --- a/packages/uipath/CLAUDE.md +++ b/packages/uipath/CLAUDE.md @@ -76,7 +76,7 @@ Plugin-based evaluator registration system with deterministic and LLM-based eval - **`evaluators/`** — 11 active evaluators: ExactMatch, Contains, JsonSimilarity, BinaryClassification, MulticlassClassification, LLMJudgeOutput, LLMJudgeTrajectory, ToolCallOrder/Args/Count/Output. Plus ~7 legacy evaluators. - **`mocks/`** — LLM mocking, input mocking, cache management, mockito integration. Key exports: `mockable`, `UiPathMockRuntime`, `MockingContext`. - **`runtime/`** — `UiPathEvalRuntime`, `UiPathEvalContext`, `evaluate()` entry point, parallelization, exporters. -- **`models/`** — `AgentExecution`, `EvaluationResult`, `ToolCall`, `LLMResponse`, evaluator type enums. +- **`models/`** — `WorkloadExecution`, `EvaluationResult`, `ToolCall`, `LLMResponse`, evaluator type enums. ## Functions Module (`src/uipath/functions/`) diff --git a/packages/uipath/docs/core/release_notes.md b/packages/uipath/docs/core/release_notes.md index 3bdbfb4a0..5565f3689 100644 --- a/packages/uipath/docs/core/release_notes.md +++ b/packages/uipath/docs/core/release_notes.md @@ -10,6 +10,7 @@ A catalog of the releases most relevant to UiPath Python SDK users (breaking cha | Release | Date | What's relevant | Notes | |---------|------|-----------------|-------| +| [v2.12.0](https://github.com/UiPath/uipath-python/releases/tag/v2.12.0) | 2026-06-30 | Eval framework: `AgentExecution` → `WorkloadExecution`; fields `agent_trace`/`agent_output` → `workload_trace`/`workload_output`; `evaluate()`/`validate_and_evaluate_criteria()` keyword `agent_execution` → `workload_execution` | 🚨 Breaking — see [migration](#migration-v2120-eval-workloadexecution-rename) | | [v2.10.0](https://github.com/UiPath/uipath-python/releases/tag/v2.10.0) | 2026-02-27 | Coded function schema `type` changed from `"agent"` to `"function"` | 🚨 Breaking | | [v2.9.0](https://github.com/UiPath/uipath-python/releases/tag/v2.9.0) | 2026-02-23 | `platform` extracted to `uipath-platform`, context grounding contract changes, `uipath dev` defaults to `web` | 🚨 Breaking | | [v2.2.0](https://github.com/UiPath/uipath-python/releases/tag/v2.2.0) | 2025-11-26 | Python 3.11+ required, `UiPath` import moved to `uipath.platform`, configuration architecture redesign | 🚨 Breaking | @@ -25,3 +26,34 @@ A catalog of the releases most relevant to UiPath Python SDK users (breaking cha | Release | Date | What's relevant | Notes | |---------|------|-----------------|-------| | [v0.3.0](https://github.com/UiPath/uipath-runtime-python/releases/tag/v0.3.0) | 2025-12-18 | `UiPathDebugBridgeProtocol` renamed to `UiPathDebugProtocol` | 🚨 Breaking (protocol implementers only) | + +## Migration: v2.12.0 (eval `WorkloadExecution` rename) + +The eval framework's central execution type and its keyword parameters were renamed to +unified-evals terminology. The value passed to every evaluator represents *a workload +execution* (agent, process/orchestration, case management, …), not specifically an agent. + +These are breaking changes for code that uses the affected public names. Update as follows: + +| Before | After | +|--------|-------| +| `from uipath.eval.models import AgentExecution` | `from uipath.eval.models import WorkloadExecution` | +| `WorkloadExecution(agent_trace=...)` | `WorkloadExecution(workload_trace=...)` | +| `WorkloadExecution(agent_output=...)` | `WorkloadExecution(workload_output=...)` | +| `execution.agent_trace` / `execution.agent_output` | `execution.workload_trace` / `execution.workload_output` | +| `evaluator.evaluate(agent_execution=...)` | `evaluator.evaluate(workload_execution=...)` | +| `evaluator.validate_and_evaluate_criteria(agent_execution=...)` | `evaluator.validate_and_evaluate_criteria(workload_execution=...)` | + +What still works: + +- **`AgentExecution` (the class name)** is soft-deprecated: importing it still resolves to + `WorkloadExecution` and emits a `DeprecationWarning` (removed in **uipath 3.0**). Note the + *fields* are not aliased — constructing `AgentExecution(agent_output=..., agent_trace=...)` + now raises a `ValidationError`; rename the fields as above. +- **Custom evaluators that override `evaluate` / `validate_and_evaluate_criteria` with the old + `agent_execution` parameter name** keep running, because the runtime dispatches positionally. + Only *callers* passing `agent_execution=` by keyword are affected. + +Unchanged (intentionally): the `WorkloadExecution.agent_input` and +`expected_agent_behavior` fields, and the `agent_output` / `agent_execution_time` fields on +`EvalRunUpdatedEvent` and `StudioWebProgressItem` (the latter a Studio Web wire contract). diff --git a/packages/uipath/docs/eval/contains.md b/packages/uipath/docs/eval/contains.md index 73a377742..c8609a0fb 100644 --- a/packages/uipath/docs/eval/contains.md +++ b/packages/uipath/docs/eval/contains.md @@ -18,7 +18,7 @@ The Contains Evaluator checks whether the agent's output contains a specific sea ## Configuration !!! note "Agent Output Structure" - `agent_output` must always be a dictionary. When comparing, the value (or specific field via `target_output_key`) is converted to a string before checking if it contains the search text. + `workload_output` must always be a dictionary. When comparing, the value (or specific field via `target_output_key`) is converted to a string before checking if it contains the search text. ### ContainsEvaluatorConfig @@ -44,13 +44,13 @@ The Contains Evaluator checks whether the agent's output contains a specific sea ```python from uipath.eval.evaluators import ContainsEvaluator -from uipath.eval.models import AgentExecution +from uipath.eval.models import WorkloadExecution # Sample agent execution -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"query": "What is the capital of France?"}, - agent_output={"response": "The capital of France is Paris."}, - agent_trace=[], + workload_output={"response": "The capital of France is Paris."}, + workload_trace=[], ) # Create evaluator - extracts "response" field for comparison @@ -65,7 +65,7 @@ evaluator = ContainsEvaluator( # Evaluate - searches in the "response" field value result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"search_text": "Paris"} ) @@ -76,10 +76,10 @@ print(f"Score: {result.score}") # Output: 1.0 ```python # Sample agent execution -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={}, - agent_output={"message": "Hello World"}, - agent_trace=[], + workload_output={"message": "Hello World"}, + workload_trace=[], ) evaluator = ContainsEvaluator( @@ -93,7 +93,7 @@ evaluator = ContainsEvaluator( # This will fail because of case mismatch result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"search_text": "hello"} ) @@ -106,10 +106,10 @@ Use negation to ensure specific text is NOT present: ```python # Sample agent execution -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={}, - agent_output={"status": "Success: Operation completed"}, - agent_trace=[], + workload_output={"status": "Success: Operation completed"}, + workload_trace=[], ) evaluator = ContainsEvaluator( @@ -123,7 +123,7 @@ evaluator = ContainsEvaluator( # Passes because "error" is NOT found result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"search_text": "error"} ) @@ -134,13 +134,13 @@ print(f"Score: {result.score}") # Output: 1.0 ```python # Sample agent execution -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={}, - agent_output={ + workload_output={ "status": "success", "message": "User profile updated successfully" }, - agent_trace=[], + workload_trace=[], ) evaluator = ContainsEvaluator( @@ -153,7 +153,7 @@ evaluator = ContainsEvaluator( # Only searches within the "message" field result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"search_text": "updated"} ) diff --git a/packages/uipath/docs/eval/custom_evaluators.md b/packages/uipath/docs/eval/custom_evaluators.md index e92fbc2c4..b7b146ba8 100644 --- a/packages/uipath/docs/eval/custom_evaluators.md +++ b/packages/uipath/docs/eval/custom_evaluators.md @@ -94,7 +94,7 @@ Implement the core evaluation logic: ```python from uipath.eval.evaluators import BaseEvaluator from uipath.eval.evaluators.base_evaluator import BaseEvaluatorJustification -from uipath.eval.models import AgentExecution, NumericEvaluationResult +from uipath.eval.models import WorkloadExecution, NumericEvaluationResult class MyCustomEvaluator( BaseEvaluator[MyEvaluationCriteria, MyEvaluatorConfig, BaseEvaluatorJustification] @@ -107,16 +107,16 @@ class MyCustomEvaluator( async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: MyEvaluationCriteria ) -> NumericEvaluationResult: """Evaluate the agent execution against criteria. Args: - agent_execution: The agent execution containing: + workload_execution: The agent execution containing: - agent_input: Input received by the agent - - agent_output: Output produced by the agent - - agent_trace: OpenTelemetry spans with execution trace + - workload_output: Output produced by the agent + - workload_trace: OpenTelemetry spans with execution trace - simulation_instructions: Simulation instructions evaluation_criteria: Criteria to evaluate against @@ -124,7 +124,7 @@ class MyCustomEvaluator( EvaluationResult with score and details """ # Extract data from agent execution - actual_values = self._extract_values(agent_execution) + actual_values = self._extract_values(workload_execution) expected_values = evaluation_criteria.expected_values # Apply case sensitivity from config @@ -146,7 +146,7 @@ class MyCustomEvaluator( }), ) - def _extract_values(self, agent_execution: AgentExecution) -> list[str]: + def _extract_values(self, workload_execution: WorkloadExecution) -> list[str]: """Extract values from agent execution (implement your logic).""" # Your custom extraction logic here return [] @@ -244,9 +244,9 @@ Custom evaluators often need to extract information from tool calls in the agent ```python from uipath.eval._helpers.evaluators_helpers import extract_tool_calls -def _process_tool_calls(self, agent_execution: AgentExecution) -> list[str]: +def _process_tool_calls(self, workload_execution: WorkloadExecution) -> list[str]: """Extract and process tool calls from the execution trace.""" - tool_calls = extract_tool_calls(agent_execution.agent_trace) + tool_calls = extract_tool_calls(workload_execution.workload_trace) results = [] for tool_call in tool_calls: @@ -290,7 +290,7 @@ from uipath.eval.evaluators.base_evaluator import ( BaseEvaluatorJustification, ) from uipath.eval.models import EvaluationResult, NumericEvaluationResult -from uipath.eval.models import AgentExecution +from uipath.eval.models import WorkloadExecution from uipath.eval._helpers.evaluators_helpers import extract_tool_calls @@ -335,13 +335,13 @@ class PatternComparisonEvaluator( async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: PatternEvaluatorCriteria ) -> EvaluationResult: """Evaluate the pattern comparison. Args: - agent_execution: The agent execution containing trace data + workload_execution: The agent execution containing trace data evaluation_criteria: Expected output patterns Returns: @@ -350,7 +350,7 @@ class PatternComparisonEvaluator( expected_output = evaluation_criteria.expected_output # Extract actual output from tool calls - actual_output = self._extract_patterns(agent_execution) + actual_output = self._extract_patterns(workload_execution) # Compute score using intersection over union score = _compute_jaccard_similarity(expected_output, actual_output) @@ -363,17 +363,17 @@ class PatternComparisonEvaluator( }), ) - def _extract_patterns(self, agent_execution: AgentExecution) -> list[str]: + def _extract_patterns(self, workload_execution: WorkloadExecution) -> list[str]: """Extract patterns from tool calls. Args: - agent_execution: The agent execution containing trace data + workload_execution: The agent execution containing trace data Returns: List of pattern strings found """ # Extract tool calls with arguments using the helper function - tool_calls = extract_tool_calls(agent_execution.agent_trace) + tool_calls = extract_tool_calls(workload_execution.workload_trace) for tool_call in tool_calls: if tool_call.name == "DataProcessingTool": @@ -408,13 +408,13 @@ Always include complete type annotations and Google-style docstrings: ```python def _extract_data( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, tool_name: str ) -> list[str]: """Extract data from specific tool calls. Args: - agent_execution: The agent execution to process + workload_execution: The agent execution to process tool_name: The name of the tool to extract data from Returns: @@ -435,13 +435,13 @@ from uipath.eval.models import ErrorEvaluationResult async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: MyCriteria ) -> EvaluationResult: """Evaluate with error handling.""" try: # Your evaluation logic - score = self._compute_score(agent_execution) + score = self._compute_score(workload_execution) return NumericEvaluationResult(score=score) except Exception as e: return ErrorEvaluationResult( @@ -456,12 +456,12 @@ Extract common logic into reusable helper methods: ```python def _extract_from_tool( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, tool_name: str, parameter_name: str ) -> str: """Reusable method to extract parameter from tool calls.""" - tool_calls = extract_tool_calls(agent_execution.agent_trace) + tool_calls = extract_tool_calls(workload_execution.workload_trace) for tool_call in tool_calls: if tool_call.name == tool_name: args = tool_call.args or {} @@ -571,16 +571,16 @@ Test your evaluators locally before registration: ```python import pytest -from uipath.eval.models import AgentExecution +from uipath.eval.models import WorkloadExecution @pytest.mark.asyncio async def test_custom_evaluator() -> None: """Test custom evaluator logic.""" # Create test data - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"query": "test"}, - agent_output={"result": "test output"}, - agent_trace=[], + workload_output={"result": "test output"}, + workload_trace=[], ) # Create evaluator with config @@ -595,7 +595,7 @@ async def test_custom_evaluator() -> None: # Evaluate with criteria criteria = MyEvaluationCriteria(expected_values=["value1"]) - result = await evaluator.evaluate(agent_execution, criteria) + result = await evaluator.evaluate(workload_execution, criteria) # Assert assert result.score >= 0.0 @@ -608,10 +608,10 @@ async def test_custom_evaluator() -> None: ```python def _extract_from_specific_tool( - self, agent_execution: AgentExecution + self, workload_execution: WorkloadExecution ) -> str: """Extract data from a specific tool call.""" - tool_calls = extract_tool_calls(agent_execution.agent_trace) + tool_calls = extract_tool_calls(workload_execution.workload_trace) for tool_call in tool_calls: if tool_call.name == "TargetTool": @@ -643,12 +643,12 @@ def _compute_set_similarity( ```python async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: MyCriteria ) -> EvaluationResult: """Multi-step validation using config settings.""" # Step 1: Validate structure (use strict mode from config) - if not self._validate_structure(agent_execution, self.evaluator_config.strict): + if not self._validate_structure(workload_execution, self.evaluator_config.strict): return NumericEvaluationResult( score=0.0, details=self.validate_justification({ @@ -658,7 +658,7 @@ async def evaluate( ) # Step 2: Extract data - data = self._extract_data(agent_execution) + data = self._extract_data(workload_execution) # Step 3: Compare and score score = self._compare_data(data, evaluation_criteria.expected_data) diff --git a/packages/uipath/docs/eval/exact_match.md b/packages/uipath/docs/eval/exact_match.md index afc11d619..6e5be46b6 100644 --- a/packages/uipath/docs/eval/exact_match.md +++ b/packages/uipath/docs/eval/exact_match.md @@ -18,7 +18,7 @@ The Exact Match Evaluator performs exact string matching between the agent's out ## Configuration !!! note "Agent Output Structure" - `agent_output` must always be a dictionary (e.g., `{"result": "value"}`). To evaluate simple values like strings or numbers, wrap them in a dict and use `target_output_key` to extract the specific field. + `workload_output` must always be a dictionary (e.g., `{"result": "value"}`). To evaluate simple values like strings or numbers, wrap them in a dict and use `target_output_key` to extract the specific field. ### ExactMatchEvaluatorConfig @@ -44,13 +44,13 @@ The Exact Match Evaluator performs exact string matching between the agent's out ```python from uipath.eval.evaluators import ExactMatchEvaluator -from uipath.eval.models import AgentExecution +from uipath.eval.models import WorkloadExecution -# agent_output must be a dict -agent_execution = AgentExecution( +# workload_output must be a dict +workload_execution = WorkloadExecution( agent_input={"query": "What is 2+2?"}, - agent_output={"result": "4"}, - agent_trace=[] + workload_output={"result": "4"}, + workload_trace=[] ) # Create evaluator - extracts "result" field for comparison @@ -65,7 +65,7 @@ evaluator = ExactMatchEvaluator( # Evaluate - compares just the "result" field value result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"expected_output": {"result": "4"}} ) @@ -75,10 +75,10 @@ print(f"Score: {result.score}") # Output: 1.0 ### Case-Sensitive Matching ```python -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={}, - agent_output={"status": "SUCCESS"}, - agent_trace=[] + workload_output={"status": "SUCCESS"}, + workload_trace=[] ) evaluator = ExactMatchEvaluator( @@ -92,7 +92,7 @@ evaluator = ExactMatchEvaluator( # Fails due to case mismatch result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"expected_output": {"status": "success"}} ) @@ -100,7 +100,7 @@ print(f"Score: {result.score}") # Output: 0.0 # This would pass result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"expected_output": {"status": "SUCCESS"}} ) @@ -112,10 +112,10 @@ print(f"Score: {result.score}") # Output: 1.0 When `target_output_key` is `"*"` (default), the entire output dict is compared: ```python -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={}, - agent_output={"status": "success", "code": 200}, - agent_trace=[] + workload_output={"status": "success", "code": 200}, + workload_trace=[] ) evaluator = ExactMatchEvaluator( @@ -128,7 +128,7 @@ evaluator = ExactMatchEvaluator( # Entire dict structure must match result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"expected_output": {"status": "success", "code": 200}} ) @@ -138,13 +138,13 @@ print(f"Score: {result.score}") # Output: 1.0 ### Target Specific Field ```python -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={}, - agent_output={ + workload_output={ "result": "approved", "timestamp": "2024-01-01T12:00:00Z" }, - agent_trace=[] + workload_trace=[] ) evaluator = ExactMatchEvaluator( @@ -157,7 +157,7 @@ evaluator = ExactMatchEvaluator( # Only checks the "result" field result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"expected_output": {"result": "approved"}} ) @@ -167,10 +167,10 @@ print(f"Score: {result.score}") # Output: 1.0 ### Negated Mode ```python -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={}, - agent_output={"result": "error"}, - agent_trace=[] + workload_output={"result": "error"}, + workload_trace=[] ) evaluator = ExactMatchEvaluator( @@ -184,7 +184,7 @@ evaluator = ExactMatchEvaluator( # Passes because outputs do NOT match result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"expected_output": {"result": "success"}} ) @@ -194,10 +194,10 @@ print(f"Score: {result.score}") # Output: 1.0 ### Using Default Criteria ```python -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={}, - agent_output={"status": "OK"}, - agent_trace=[] + workload_output={"status": "OK"}, + workload_trace=[] ) evaluator = ExactMatchEvaluator( @@ -211,7 +211,7 @@ evaluator = ExactMatchEvaluator( # Use default criteria result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, evaluation_criteria=None + workload_execution=workload_execution, evaluation_criteria=None ) print(f"Score: {result.score}") # Output: 1.0 diff --git a/packages/uipath/docs/eval/index.md b/packages/uipath/docs/eval/index.md index 465fcd96a..1848eb51c 100644 --- a/packages/uipath/docs/eval/index.md +++ b/packages/uipath/docs/eval/index.md @@ -72,13 +72,13 @@ To use an evaluator, you typically: ```python from uipath.eval.evaluators import ExactMatchEvaluator -from uipath.eval.models import AgentExecution +from uipath.eval.models import WorkloadExecution # Sample agent execution (this should be replaced with your agent run data) -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"query": "Greet the world"}, - agent_output={"result": "hello, world!"}, - agent_trace=[], + workload_output={"result": "hello, world!"}, + workload_trace=[], ) # Create evaluator @@ -93,7 +93,7 @@ evaluator = ExactMatchEvaluator( # Evaluate result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"expected_output": {"result": "Hello, World!"}} ) diff --git a/packages/uipath/docs/eval/json_similarity.md b/packages/uipath/docs/eval/json_similarity.md index 19bf60739..006b21a0e 100644 --- a/packages/uipath/docs/eval/json_similarity.md +++ b/packages/uipath/docs/eval/json_similarity.md @@ -18,7 +18,7 @@ The JSON Similarity Evaluator performs flexible structural comparison of JSON-li ## Configuration !!! note "Agent Output Structure" - `agent_output` must always be a dictionary. The evaluator compares dictionary structures recursively, making it ideal for complex nested JSON-like outputs. + `workload_output` must always be a dictionary. The evaluator compares dictionary structures recursively, making it ideal for complex nested JSON-like outputs. ### JsonSimilarityEvaluatorConfig @@ -59,12 +59,12 @@ The final score represents the percentage of matching leaf nodes in the tree str ```python from uipath.eval.evaluators import JsonSimilarityEvaluator -from uipath.eval.models import AgentExecution +from uipath.eval.models import WorkloadExecution -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={}, - agent_output={"name": "John Doe", "age": 30, "city": "New York"}, - agent_trace=[] + workload_output={"name": "John Doe", "age": 30, "city": "New York"}, + workload_trace=[] ) evaluator = JsonSimilarityEvaluator( @@ -76,7 +76,7 @@ evaluator = JsonSimilarityEvaluator( ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": {"name": "John Doe", "age": 30, "city": "New York"} } @@ -90,10 +90,10 @@ print(f"Total: {result.details.total_leaves}") # 3.0 ### Numeric Tolerance ```python -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={}, - agent_output={"temperature": 20.5, "humidity": 65}, - agent_trace=[] + workload_output={"temperature": 20.5, "humidity": 65}, + workload_trace=[] ) evaluator = JsonSimilarityEvaluator( @@ -103,7 +103,7 @@ evaluator = JsonSimilarityEvaluator( # Slightly different numbers result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": {"temperature": 20.3, "humidity": 65} } @@ -116,10 +116,10 @@ print(f"Score: {result.score}") # ~0.99 (very high similarity) ### String Similarity ```python -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={}, - agent_output={"status": "completed successfully"}, - agent_trace=[] + workload_output={"status": "completed successfully"}, + workload_trace=[] ) evaluator = JsonSimilarityEvaluator( @@ -129,7 +129,7 @@ evaluator = JsonSimilarityEvaluator( # Similar but not exact string result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": {"status": "completed sucessfully"} # typo } @@ -142,9 +142,9 @@ print(f"Score: {result.score}") # ~0.95 (high similarity despite typo) ### Nested Structures ```python -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={}, - agent_output={ + workload_output={ "user": { "name": "Alice", "profile": { @@ -154,7 +154,7 @@ agent_execution = AgentExecution( }, "status": "active" }, - agent_trace=[] + workload_trace=[] ) evaluator = JsonSimilarityEvaluator( @@ -163,7 +163,7 @@ evaluator = JsonSimilarityEvaluator( ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": { "user": { @@ -184,10 +184,10 @@ print(f"Score: {result.score}") # Output: 1.0 ### Array Comparison ```python -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={}, - agent_output={"items": ["apple", "banana", "orange"]}, - agent_trace=[] + workload_output={"items": ["apple", "banana", "orange"]}, + workload_trace=[] ) evaluator = JsonSimilarityEvaluator( @@ -197,7 +197,7 @@ evaluator = JsonSimilarityEvaluator( # Partial match (2 out of 3 correct) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": {"items": ["apple", "banana", "grape"]} } @@ -209,14 +209,14 @@ print(f"Score: {result.score}") # ~0.67 (2/3 correct) ### Handling Extra Keys in Actual Output ```python -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={}, - agent_output={ + workload_output={ "name": "Bob", "age": 30, "extra_field": "ignored" # Extra field in actual output }, - agent_trace=[] + workload_trace=[] ) evaluator = JsonSimilarityEvaluator( @@ -226,7 +226,7 @@ evaluator = JsonSimilarityEvaluator( # Only expected keys are evaluated result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": { "name": "Bob", @@ -241,13 +241,13 @@ print(f"Score: {result.score}") # Output: 1.0 (extra fields ignored) ### Target Specific Field ```python -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={}, - agent_output={ + workload_output={ "result": {"score": 95, "passed": True}, "metadata": {"timestamp": "2024-01-01"} }, - agent_trace=[] + workload_trace=[] ) evaluator = JsonSimilarityEvaluator( @@ -260,7 +260,7 @@ evaluator = JsonSimilarityEvaluator( # Only compares the "result" field result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": {"result": {"score": 95, "passed": True}} } diff --git a/packages/uipath/docs/eval/llm_judge_output.md b/packages/uipath/docs/eval/llm_judge_output.md index c987a835c..d555e4d1e 100644 --- a/packages/uipath/docs/eval/llm_judge_output.md +++ b/packages/uipath/docs/eval/llm_judge_output.md @@ -109,12 +109,12 @@ The prompt template supports these placeholders: ```python from uipath.eval.evaluators import LLMJudgeOutputEvaluator -from uipath.eval.models import AgentExecution +from uipath.eval.models import WorkloadExecution -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"query": "What is the capital of France?"}, - agent_output={"answer": "Paris is the capital city of France."}, - agent_trace=[] + workload_output={"answer": "Paris is the capital city of France."}, + workload_trace=[] ) evaluator = LLMJudgeOutputEvaluator( @@ -129,7 +129,7 @@ evaluator = LLMJudgeOutputEvaluator( ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": {"answer": "The capital of France is Paris."} } @@ -152,10 +152,10 @@ Expected Output: {{ExpectedOutput}} Provide a score from 0-100 based on semantic similarity. """ -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={}, - agent_output={"message": "The product has been successfully added to your cart."}, - agent_trace=[] + workload_output={"message": "The product has been successfully added to your cart."}, + workload_trace=[] ) evaluator = LLMJudgeOutputEvaluator( @@ -170,7 +170,7 @@ evaluator = LLMJudgeOutputEvaluator( ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": {"message": "Item added to shopping cart."} } @@ -183,9 +183,9 @@ print(f"Justification: {result.details}") #### Evaluating Natural Language Quality ```python -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"task": "Write a professional email"}, - agent_output={"email": """Dear Customer, + workload_output={"email": """Dear Customer, Thank you for your inquiry. We have reviewed your request and are pleased to inform you that we can accommodate your @@ -193,7 +193,7 @@ needs. Please let us know if you have any questions. Best regards, Support Team"""}, - agent_trace=[] + workload_trace=[] ) evaluator = LLMJudgeOutputEvaluator( @@ -207,7 +207,7 @@ evaluator = LLMJudgeOutputEvaluator( ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": {"email": "A professional, courteous response addressing the customer's inquiry"} } @@ -271,19 +271,19 @@ evaluator = LLMJudgeStrictJSONSimilarityOutputEvaluator( } ) -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={}, - agent_output={ + workload_output={ "status": "success", "user_id": 12345, "name": "John Doe", "email": "john@example.com" }, - agent_trace=[] + workload_trace=[] ) result = await evaluator.evaluate( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": { "status": "success", diff --git a/packages/uipath/docs/eval/llm_judge_trajectory.md b/packages/uipath/docs/eval/llm_judge_trajectory.md index 8705f1a29..d5846e387 100644 --- a/packages/uipath/docs/eval/llm_judge_trajectory.md +++ b/packages/uipath/docs/eval/llm_judge_trajectory.md @@ -111,12 +111,12 @@ The prompt template supports these placeholders: ```python from uipath.eval.evaluators import LLMJudgeTrajectoryEvaluator -from uipath.eval.models import AgentExecution +from uipath.eval.models import WorkloadExecution -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"user_query": "Book a flight to Paris"}, - agent_output={"booking_id": "FL123", "status": "confirmed"}, - agent_trace=[ + workload_output={"booking_id": "FL123", "status": "confirmed"}, + workload_trace=[ # Trace contains spans showing the agent's execution path # Each span represents a step in the agent's decision-making ] @@ -133,7 +133,7 @@ evaluator = LLMJudgeTrajectoryEvaluator( ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_agent_behavior": """ The agent should: @@ -152,10 +152,10 @@ print(f"Justification: {result.details}") #### Validating Tool Usage Sequence ```python -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"task": "Update user profile and send notification"}, - agent_output={"status": "completed"}, - agent_trace=[ + workload_output={"status": "completed"}, + workload_trace=[ # Spans showing: validate_user -> update_profile -> send_notification ] ) @@ -170,7 +170,7 @@ evaluator = LLMJudgeTrajectoryEvaluator( ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_agent_behavior": """ The agent must: @@ -259,10 +259,10 @@ Same as `LLMJudgeTrajectoryEvaluatorConfig` but with: ```python from uipath.eval.evaluators import LLMJudgeTrajectorySimulationEvaluator -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"query": "Book a flight to Paris for tomorrow"}, - agent_output={"booking_id": "FL123", "status": "confirmed"}, - agent_trace=[ + workload_output={"booking_id": "FL123", "status": "confirmed"}, + workload_trace=[ # Execution spans showing tool calls and their simulated responses ], simulation_instructions=""" @@ -284,7 +284,7 @@ evaluator = LLMJudgeTrajectorySimulationEvaluator( ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_agent_behavior": """ The agent should: @@ -303,7 +303,7 @@ print(f"Justification: {result.details}") ## Understanding Agent Traces -The `agent_trace` contains execution spans that show: +The `workload_trace` contains execution spans that show: - Tool calls made by the agent - LLM reasoning steps @@ -313,7 +313,7 @@ The `agent_trace` contains execution spans that show: Example trace structure: ```python -agent_trace = [ +workload_trace = [ { "name": "search_flights", "type": "tool", diff --git a/packages/uipath/docs/eval/tool_call_args.md b/packages/uipath/docs/eval/tool_call_args.md index ced1c63fd..94705d25b 100644 --- a/packages/uipath/docs/eval/tool_call_args.md +++ b/packages/uipath/docs/eval/tool_call_args.md @@ -69,7 +69,7 @@ For each tool call: ```python from opentelemetry.sdk.trace import ReadableSpan from uipath.eval.evaluators import ToolCallArgsEvaluator -from uipath.eval.models import AgentExecution +from uipath.eval.models import WorkloadExecution # Sample agent execution with tool calls and arguments mock_spans = [ @@ -84,10 +84,10 @@ mock_spans = [ ), ] -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"user_id": 123, "action": "update"}, - agent_output={"status": "success"}, - agent_trace=mock_spans, + workload_output={"status": "success"}, + workload_trace=mock_spans, ) evaluator = ToolCallArgsEvaluator( @@ -100,7 +100,7 @@ evaluator = ToolCallArgsEvaluator( ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls": [ { @@ -136,10 +136,10 @@ mock_spans = [ ), ] -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"action": "fetch_users"}, - agent_output={"status": "success"}, - agent_trace=mock_spans, + workload_output={"status": "success"}, + workload_trace=mock_spans, ) evaluator = ToolCallArgsEvaluator( @@ -153,7 +153,7 @@ evaluator = ToolCallArgsEvaluator( # Arguments must match exactly result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls": [ { @@ -177,7 +177,7 @@ print(f"Score: {result.score}") # 1.0 ```python from opentelemetry.sdk.trace import ReadableSpan from uipath.eval.evaluators import ToolCallArgsEvaluator -from uipath.eval.models import AgentExecution +from uipath.eval.models import WorkloadExecution # Agent called 3 tools, but only 2 match the expected args mock_spans = [ @@ -210,10 +210,10 @@ mock_spans = [ ), ] -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"task": "Update user profile"}, - agent_output={"status": "updated"}, - agent_trace=mock_spans, + workload_output={"status": "updated"}, + workload_trace=mock_spans, ) evaluator = ToolCallArgsEvaluator( @@ -226,7 +226,7 @@ evaluator = ToolCallArgsEvaluator( ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls": [ { @@ -270,10 +270,10 @@ mock_spans = [ ), ] -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"action": "send_welcome"}, - agent_output={"status": "sent"}, - agent_trace=mock_spans, + workload_output={"status": "sent"}, + workload_trace=mock_spans, ) evaluator = ToolCallArgsEvaluator( @@ -287,7 +287,7 @@ evaluator = ToolCallArgsEvaluator( # Only validate specific arguments, allow extras result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls": [ { @@ -341,10 +341,10 @@ mock_spans = [ ), ] -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"task": "Update user profile"}, - agent_output={"status": "updated"}, - agent_trace=mock_spans, + workload_output={"status": "updated"}, + workload_trace=mock_spans, ) evaluator = ToolCallArgsEvaluator( @@ -357,7 +357,7 @@ evaluator = ToolCallArgsEvaluator( ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls": [ { @@ -399,10 +399,10 @@ mock_spans = [ ), ] -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"task": "Create order"}, - agent_output={"status": "created"}, - agent_trace=mock_spans, + workload_output={"status": "created"}, + workload_trace=mock_spans, ) evaluator = ToolCallArgsEvaluator( @@ -416,7 +416,7 @@ evaluator = ToolCallArgsEvaluator( # Validate complex nested structures result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls": [ { diff --git a/packages/uipath/docs/eval/tool_call_count.md b/packages/uipath/docs/eval/tool_call_count.md index e72e30c2d..a07f15dd6 100644 --- a/packages/uipath/docs/eval/tool_call_count.md +++ b/packages/uipath/docs/eval/tool_call_count.md @@ -72,7 +72,7 @@ Each tool is evaluated independently: ```python from opentelemetry.sdk.trace import ReadableSpan from uipath.eval.evaluators import ToolCallCountEvaluator -from uipath.eval.models import AgentExecution +from uipath.eval.models import WorkloadExecution # Sample agent execution with tool calls mock_spans = [ @@ -92,10 +92,10 @@ mock_spans = [ attributes={"tool.name": "send_notification"}), ] -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"task": "Fetch and process data"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = ToolCallCountEvaluator( @@ -107,7 +107,7 @@ evaluator = ToolCallCountEvaluator( ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls_count": { "fetch_data": ("=", 1), # Called exactly once @@ -154,10 +154,10 @@ mock_spans.append( ) ) -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"task": "Fetch and process data"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = ToolCallCountEvaluator( @@ -169,7 +169,7 @@ evaluator = ToolCallCountEvaluator( ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls_count": { "fetch_data": ("=", 1), # ✓ Matches (1 call) @@ -216,10 +216,10 @@ mock_spans = [ ), ] -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"task": "Database operation"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = ToolCallCountEvaluator( @@ -231,7 +231,7 @@ evaluator = ToolCallCountEvaluator( ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls_count": { "authenticate": ("=", 1), # ✓ Matches (1 call) @@ -278,10 +278,10 @@ mock_spans = [ ), ] -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"task": "Optimize resource usage"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = ToolCallCountEvaluator( @@ -294,7 +294,7 @@ evaluator = ToolCallCountEvaluator( # Ensure expensive operations aren't called too many times result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls_count": { "expensive_api_call": ("<=", 1), # Should not be called more than once @@ -336,10 +336,10 @@ for i in range(10): ), ]) -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"task": "Process 10 items"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = ToolCallCountEvaluator( @@ -352,7 +352,7 @@ evaluator = ToolCallCountEvaluator( # Verify loop processed correct number of items result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls_count": { "process_item": ("=", 10), # Should process 10 items @@ -398,10 +398,10 @@ mock_spans = [ ), ] -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"task": "Retry operation"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = ToolCallCountEvaluator( @@ -414,7 +414,7 @@ evaluator = ToolCallCountEvaluator( # Verify retry logic doesn't exceed limits result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls_count": { "attempt_operation": ("<=", 3), # Max 3 retries @@ -454,10 +454,10 @@ mock_spans = [ ), ] -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"task": "Secure operation"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = ToolCallCountEvaluator( @@ -470,7 +470,7 @@ evaluator = ToolCallCountEvaluator( # Ensure agent calls important tools result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls_count": { "validate_input": (">=", 1), # Must validate at least once diff --git a/packages/uipath/docs/eval/tool_call_order.md b/packages/uipath/docs/eval/tool_call_order.md index 886f8be41..2a703f572 100644 --- a/packages/uipath/docs/eval/tool_call_order.md +++ b/packages/uipath/docs/eval/tool_call_order.md @@ -68,7 +68,7 @@ Where LCS is the Longest Common Subsequence between expected and actual tool cal ```python from opentelemetry.sdk.trace import ReadableSpan from uipath.eval.evaluators import ToolCallOrderEvaluator -from uipath.eval.models import AgentExecution +from uipath.eval.models import WorkloadExecution # Create mock spans representing tool calls in execution trace mock_spans = [ @@ -92,10 +92,10 @@ mock_spans = [ ), ] -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"task": "Process user order"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = ToolCallOrderEvaluator( @@ -107,7 +107,7 @@ evaluator = ToolCallOrderEvaluator( ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls_order": [ "validate_user", @@ -149,10 +149,10 @@ mock_spans = [ ), ] -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"task": "Access secured resource"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = ToolCallOrderEvaluator( @@ -164,7 +164,7 @@ evaluator = ToolCallOrderEvaluator( ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls_order": [ "authenticate_user", @@ -205,10 +205,10 @@ mock_spans = [ ), ] -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"task": "Search and display"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = ToolCallOrderEvaluator( @@ -223,7 +223,7 @@ evaluator = ToolCallOrderEvaluator( expected = ["search", "filter", "sort", "display"] result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls_order": expected } @@ -266,10 +266,10 @@ mock_spans = [ ), ] -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"task": "Update database"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = ToolCallOrderEvaluator( @@ -281,7 +281,7 @@ evaluator = ToolCallOrderEvaluator( ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls_order": [ "begin_transaction", @@ -334,10 +334,10 @@ mock_spans = [ ), ] -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"task": "API integration"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = ToolCallOrderEvaluator( @@ -349,7 +349,7 @@ evaluator = ToolCallOrderEvaluator( ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls_order": [ "get_api_token", @@ -390,10 +390,10 @@ mock_spans = [ ), ] -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"task": "Standard workflow"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = ToolCallOrderEvaluator( @@ -409,7 +409,7 @@ evaluator = ToolCallOrderEvaluator( # Use default criteria result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria=None # Uses default ) diff --git a/packages/uipath/docs/eval/tool_call_output.md b/packages/uipath/docs/eval/tool_call_output.md index 9b3cd29f2..7938dc9db 100644 --- a/packages/uipath/docs/eval/tool_call_output.md +++ b/packages/uipath/docs/eval/tool_call_output.md @@ -64,7 +64,7 @@ For each expected tool output: ```python from opentelemetry.sdk.trace import ReadableSpan from uipath.eval.evaluators import ToolCallOutputEvaluator -from uipath.eval.models import AgentExecution +from uipath.eval.models import WorkloadExecution # Sample agent execution with tool calls and outputs mock_spans = [ @@ -79,10 +79,10 @@ mock_spans = [ ), ] -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"user_id": 123}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = ToolCallOutputEvaluator( @@ -94,7 +94,7 @@ evaluator = ToolCallOutputEvaluator( ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_outputs": [ { @@ -126,10 +126,10 @@ mock_spans = [ ), ] -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"items": ["item1", "item2"]}, - agent_output={"status": "calculated"}, - agent_trace=mock_spans, + workload_output={"status": "calculated"}, + workload_trace=mock_spans, ) evaluator = ToolCallOutputEvaluator( @@ -142,7 +142,7 @@ evaluator = ToolCallOutputEvaluator( # Outputs must match exactly result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_outputs": [ { @@ -162,7 +162,7 @@ print(f"Score: {result.score}") # 1.0 ```python from opentelemetry.sdk.trace import ReadableSpan from uipath.eval.evaluators import ToolCallOutputEvaluator -from uipath.eval.models import AgentExecution +from uipath.eval.models import WorkloadExecution # Agent produced 3 outputs, but only 2 match expected mock_spans = [ @@ -195,10 +195,10 @@ mock_spans = [ ), ] -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"task": "Process data pipeline"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = ToolCallOutputEvaluator( @@ -210,7 +210,7 @@ evaluator = ToolCallOutputEvaluator( ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_outputs": [ { @@ -269,10 +269,10 @@ mock_spans = [ ), ] -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"task": "Process data pipeline"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = ToolCallOutputEvaluator( @@ -284,7 +284,7 @@ evaluator = ToolCallOutputEvaluator( ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_outputs": [ { @@ -323,10 +323,10 @@ mock_spans = [ ), ] -agent_execution = AgentExecution( +workload_execution = WorkloadExecution( agent_input={"task": "Generate report"}, - agent_output={"status": "generated"}, - agent_trace=mock_spans, + workload_output={"status": "generated"}, + workload_trace=mock_spans, ) evaluator = ToolCallOutputEvaluator( @@ -338,7 +338,7 @@ evaluator = ToolCallOutputEvaluator( ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_outputs": [ { diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 63c4777a2..9abd4a08b 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.11.18" +version = "2.12.0" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/samples/attachment_evaluation_test/README.md b/packages/uipath/samples/attachment_evaluation_test/README.md index ab6a35366..d5c30e8d3 100644 --- a/packages/uipath/samples/attachment_evaluation_test/README.md +++ b/packages/uipath/samples/attachment_evaluation_test/README.md @@ -159,7 +159,7 @@ The `targetOutputKey` points to the field containing the attachment URI. ### 3. Automatic Download and Evaluation When the evaluator runs: -1. Extracts value from `agent_output["report"]` +1. Extracts value from `workload_output["report"]` 2. Detects it's an attachment URI (matches pattern) 3. Downloads the attachment content automatically 4. Evaluates the downloaded content against expected criteria @@ -201,14 +201,14 @@ The `targetOutputKey` in evaluator configuration specifies which field contains ```json { - "targetOutputKey": "report" // Looks for agent_output["report"] + "targetOutputKey": "report" // Looks for workload_output["report"] } ``` For nested paths, use dot notation: ```json { - "targetOutputKey": "results.report" // agent_output["results"]["report"] + "targetOutputKey": "results.report" // workload_output["results"]["report"] } ``` diff --git a/packages/uipath/samples/calculator/evaluations/evaluators/custom/correct_operator.py b/packages/uipath/samples/calculator/evaluations/evaluators/custom/correct_operator.py index 0e79f3445..c82797ce1 100644 --- a/packages/uipath/samples/calculator/evaluations/evaluators/custom/correct_operator.py +++ b/packages/uipath/samples/calculator/evaluations/evaluators/custom/correct_operator.py @@ -8,9 +8,9 @@ BaseEvaluatorJustification, ) from uipath.eval.models import ( - AgentExecution, EvaluationResult, NumericEvaluationResult, + WorkloadExecution, ) @@ -41,8 +41,8 @@ class CorrectOperatorEvaluator( ): """A custom evaluator that checks if the correct operator is being used by the agent""" - def extract_operator_from_spans(self, agent_trace: list[ReadableSpan]) -> str: - for span in agent_trace: + def extract_operator_from_spans(self, workload_trace: list[ReadableSpan]) -> str: + for span in workload_trace: if span.name == "track_operator": if span.attributes: input_value_as_str = span.attributes.get("input.value", "{}") @@ -57,10 +57,10 @@ def get_evaluator_id(cls) -> str: async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: CorrectOperatorEvaluationCriteria, ) -> EvaluationResult: - actual_operator = self.extract_operator_from_spans(agent_execution.agent_trace) + actual_operator = self.extract_operator_from_spans(workload_execution.workload_trace) print(actual_operator) is_expected_operator = evaluation_criteria.operator == actual_operator if self.evaluator_config.negated: diff --git a/packages/uipath/samples/classification_agent/evaluations/evaluators/custom/balanced_accuracy_evaluator.py b/packages/uipath/samples/classification_agent/evaluations/evaluators/custom/balanced_accuracy_evaluator.py index 3ef7fd806..043df839c 100644 --- a/packages/uipath/samples/classification_agent/evaluations/evaluators/custom/balanced_accuracy_evaluator.py +++ b/packages/uipath/samples/classification_agent/evaluations/evaluators/custom/balanced_accuracy_evaluator.py @@ -19,9 +19,9 @@ OutputEvaluatorConfig, ) from uipath.eval.models import ( - AgentExecution, EvaluationResult, NumericEvaluationResult, + WorkloadExecution, ) from uipath.eval.models.models import ( EvaluationResultDto, @@ -80,10 +80,10 @@ def reduce_scores(results: list[EvaluationResultDto]) -> float: async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: BalancedAccuracyEvaluationCriteria, ) -> EvaluationResult: - predicted_class = str(self._get_actual_output(agent_execution)).lower() + predicted_class = str(self._get_actual_output(workload_execution)).lower() expected_class = evaluation_criteria.expected_class.lower() classes = [c.lower() for c in self.evaluator_config.classes] class_counts = { diff --git a/packages/uipath/samples/csv-processor/evaluations/evaluators/custom/attachment_created_evaluator.py b/packages/uipath/samples/csv-processor/evaluations/evaluators/custom/attachment_created_evaluator.py index 0b46e77b9..a6627b984 100644 --- a/packages/uipath/samples/csv-processor/evaluations/evaluators/custom/attachment_created_evaluator.py +++ b/packages/uipath/samples/csv-processor/evaluations/evaluators/custom/attachment_created_evaluator.py @@ -4,7 +4,11 @@ BaseEvaluatorConfig, BaseEvaluatorJustification, ) -from uipath.eval.models import AgentExecution, EvaluationResult, NumericEvaluationResult +from uipath.eval.models import ( + EvaluationResult, + NumericEvaluationResult, + WorkloadExecution, +) class AttachmentCreatedEvaluationCriteria(BaseEvaluationCriteria): @@ -39,7 +43,7 @@ def get_evaluator_id(cls) -> str: async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: AttachmentCreatedEvaluationCriteria, ) -> EvaluationResult: # Check if the agent created an attachment by looking for: @@ -49,7 +53,7 @@ async def evaluate( attachment_created = False # Look for attachment creation in traces - for span in agent_execution.agent_trace: + for span in workload_execution.workload_trace: # Check span name for attachment operations if "attachment" in span.name.lower() or "create" in span.name.lower(): attachment_created = True @@ -70,8 +74,8 @@ async def evaluate( break # Also check if output contains attachment information - if not attachment_created and agent_execution.agent_output: - output_str = str(agent_execution.agent_output) + if not attachment_created and workload_execution.workload_output: + output_str = str(workload_execution.workload_output) if ( "attachment" in output_str.lower() or evaluation_criteria.attachment_name in output_str diff --git a/packages/uipath/samples/csv-processor/evaluations/evaluators/custom/csv_columns_evaluator.py b/packages/uipath/samples/csv-processor/evaluations/evaluators/custom/csv_columns_evaluator.py index 29efe1ce7..dd9dc40fb 100644 --- a/packages/uipath/samples/csv-processor/evaluations/evaluators/custom/csv_columns_evaluator.py +++ b/packages/uipath/samples/csv-processor/evaluations/evaluators/custom/csv_columns_evaluator.py @@ -6,7 +6,11 @@ BaseEvaluatorConfig, BaseEvaluatorJustification, ) -from uipath.eval.models import AgentExecution, EvaluationResult, NumericEvaluationResult +from uipath.eval.models import ( + EvaluationResult, + NumericEvaluationResult, + WorkloadExecution, +) class CSVColumnsEvaluationCriteria(BaseEvaluationCriteria): @@ -39,7 +43,7 @@ def get_evaluator_id(cls) -> str: async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: CSVColumnsEvaluationCriteria, ) -> EvaluationResult: # Check if all expected columns are mentioned in the output @@ -55,7 +59,7 @@ async def evaluate( ) # Look for column names in agent traces (where print output is captured) - for span in agent_execution.agent_trace: + for span in workload_execution.workload_trace: # Check span attributes if span.attributes: for attr_value in span.attributes.values(): @@ -75,8 +79,8 @@ async def evaluate( columns_found.add(column) # Also check in the output - if len(columns_found) < total_columns and agent_execution.agent_output: - output_str = str(agent_execution.agent_output) + if len(columns_found) < total_columns and workload_execution.workload_output: + output_str = str(workload_execution.workload_output) for column in evaluation_criteria.expected_columns: if column in output_str: columns_found.add(column) diff --git a/packages/uipath/samples/csv-processor/evaluations/evaluators/custom/csv_shape_evaluator.py b/packages/uipath/samples/csv-processor/evaluations/evaluators/custom/csv_shape_evaluator.py index deb2b83f4..1d50827ee 100644 --- a/packages/uipath/samples/csv-processor/evaluations/evaluators/custom/csv_shape_evaluator.py +++ b/packages/uipath/samples/csv-processor/evaluations/evaluators/custom/csv_shape_evaluator.py @@ -4,7 +4,11 @@ BaseEvaluatorConfig, BaseEvaluatorJustification, ) -from uipath.eval.models import AgentExecution, EvaluationResult, NumericEvaluationResult +from uipath.eval.models import ( + EvaluationResult, + NumericEvaluationResult, + WorkloadExecution, +) class CSVShapeEvaluationCriteria(BaseEvaluationCriteria): @@ -38,7 +42,7 @@ def get_evaluator_id(cls) -> str: async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: CSVShapeEvaluationCriteria, ) -> EvaluationResult: # The agent prints: "CSV shape (rows, columns)\nCSV columns [...]" @@ -48,7 +52,7 @@ async def evaluate( shape_found = False # Check agent traces (where print output is captured) - for span in agent_execution.agent_trace: + for span in workload_execution.workload_trace: # Check span attributes if span.attributes: for attr_value in span.attributes.values(): @@ -72,8 +76,8 @@ async def evaluate( break # Check agent output - if not shape_found and agent_execution.agent_output: - output_str = str(agent_execution.agent_output) + if not shape_found and workload_execution.workload_output: + output_str = str(workload_execution.workload_output) shape_found = expected_shape in output_str return NumericEvaluationResult( diff --git a/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py b/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py index d78a3559a..af7825cab 100644 --- a/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py +++ b/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py @@ -363,7 +363,7 @@ def _extract_usage_from_spans( """Extract token usage and cost from OpenTelemetry spans. Args: - spans: List of ReadableSpan objects from agent execution + spans: List of ReadableSpan objects from workload execution Returns: Dictionary with tokens, completionTokens, promptTokens, and cost diff --git a/packages/uipath/src/uipath/_cli/cli_eval.py b/packages/uipath/src/uipath/_cli/cli_eval.py index b7dbbba8a..2903d45fc 100644 --- a/packages/uipath/src/uipath/_cli/cli_eval.py +++ b/packages/uipath/src/uipath/_cli/cli_eval.py @@ -231,7 +231,7 @@ def _discover_eval_sets() -> list[Path]: "--verbose", is_flag=True, default=False, - help="Include agent execution output (trace, result) in the output file", + help="Include workload execution output (trace, result) in the output file", ) def eval( entrypoint: str | None, diff --git a/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py b/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py index 04127b251..577c61ce7 100644 --- a/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py +++ b/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py @@ -118,7 +118,7 @@ def extract_tool_calls_names(spans: Sequence[ReadableSpan]) -> list[str]: """Extract the tool call names from execution spans IN ORDER. Args: - spans: List of ReadableSpan objects from agent execution. + spans: List of ReadableSpan objects from workload execution. Returns: List of tool names in the order they were called. @@ -139,7 +139,7 @@ def extract_tool_calls( """Extract the tool calls from execution spans. Args: - spans: List of ReadableSpan objects from agent execution. + spans: List of ReadableSpan objects from workload execution. include_args: When False, skip parsing `input.value` and return ToolCall objects with `args={}`. Use for evaluators that only need name/id (count, order) — avoids a parse per span on large @@ -155,7 +155,7 @@ def extract_tool_calls_outputs(spans: Sequence[ReadableSpan]) -> list[ToolOutput """Extract the outputs of the tool calls from execution spans. Args: - spans: List of ReadableSpan objects from agent execution. + spans: List of ReadableSpan objects from workload execution. Returns: List of tool calls outputs. @@ -634,22 +634,22 @@ def tool_calls_output_score( ), justifications -def trace_to_str(agent_trace: Sequence[ReadableSpan]) -> str: - """Convert OTEL spans to a platform-style agent run history string. +def trace_to_str(workload_trace: Sequence[ReadableSpan]) -> str: + """Convert OTEL spans to a platform-style workload run history string. Creates a similar structure to LangChain message processing but using OTEL spans. Only processes tool spans (spans with 'tool.name' attribute). Args: - agent_trace: List of ReadableSpan objects from the agent execution + workload_trace: List of ReadableSpan objects from the workload execution Returns: - String representation of the agent run history in platform format + String representation of the workload run history in platform format """ platform_history = [] seen_tool_calls = set() - for span in agent_trace: + for span in workload_trace: if span.attributes and (tool_name := span.attributes.get(TOOL_NAME_ATTR)): # Get span timing information start_time = span.start_time diff --git a/packages/uipath/src/uipath/eval/_helpers/output_path.py b/packages/uipath/src/uipath/eval/_helpers/output_path.py index 5c50a34df..0278b5153 100644 --- a/packages/uipath/src/uipath/eval/_helpers/output_path.py +++ b/packages/uipath/src/uipath/eval/_helpers/output_path.py @@ -1,4 +1,4 @@ -"""Utility for resolving dot-notation paths from agent output dictionaries. +"""Utility for resolving dot-notation paths from workload output dictionaries. Supports: - "*" → return entire output (default behavior) diff --git a/packages/uipath/src/uipath/eval/evaluators/base_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/base_evaluator.py index 73fac46c6..ec4e0a7e6 100644 --- a/packages/uipath/src/uipath/eval/evaluators/base_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/base_evaluator.py @@ -8,7 +8,7 @@ from pydantic.alias_generators import to_camel from .._helpers.helpers import track_evaluation_metrics -from ..models import AgentExecution, EvaluationResult +from ..models import EvaluationResult, WorkloadExecution from ..models.models import ( EvaluationResultDto, UiPathEvaluationError, @@ -575,22 +575,22 @@ def reduce_scores(self, results: list[EvaluationResultDto]) -> float: @abstractmethod async def validate_and_evaluate_criteria( - self, agent_execution: AgentExecution, evaluation_criteria: Any + self, workload_execution: WorkloadExecution, evaluation_criteria: Any ) -> EvaluationResult: """Evaluate the given data and return a result from a raw evaluation criteria.""" pass @abstractmethod async def evaluate( - self, agent_execution: AgentExecution, evaluation_criteria: T + self, workload_execution: WorkloadExecution, evaluation_criteria: T ) -> EvaluationResult: """Evaluate the given data and return a result. Args: - agent_execution: The execution details containing: + workload_execution: The execution details containing: - agent_input: The input received by the agent - - agent_output: The actual output from the agent - - agent_trace: The execution trace from the agent + - workload_output: The actual output from the agent + - workload_trace: The execution trace from the agent - simulation_instructions: The simulation instructions for the agent evaluation_criteria: The criteria to evaluate @@ -626,7 +626,7 @@ def model_post_init(self, __context: Any) -> None: self.description = self.evaluator_config.description async def validate_and_evaluate_criteria( - self, agent_execution: AgentExecution, evaluation_criteria: Any + self, workload_execution: WorkloadExecution, evaluation_criteria: Any ) -> EvaluationResult: """Evaluate the given data and return a result from a raw evaluation criteria.""" if evaluation_criteria is None: @@ -639,4 +639,4 @@ async def validate_and_evaluate_criteria( category=UiPathEvaluationErrorCategory.SYSTEM, ) criteria = self.validate_evaluation_criteria(evaluation_criteria) - return await self.evaluate(agent_execution, criteria) + return await self.evaluate(workload_execution, criteria) diff --git a/packages/uipath/src/uipath/eval/evaluators/base_legacy_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/base_legacy_evaluator.py index d53dcda20..10b4b8f55 100644 --- a/packages/uipath/src/uipath/eval/evaluators/base_legacy_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/base_legacy_evaluator.py @@ -10,10 +10,10 @@ from ..models import EvaluationResult from ..models.models import ( - AgentExecution, ErrorEvaluationResult, LegacyEvaluatorCategory, LegacyEvaluatorType, + WorkloadExecution, ) from .attachment_utils import ( download_attachment_as_string, @@ -111,7 +111,7 @@ def get_evaluator_id(cls) -> str: async def validate_and_evaluate_criteria( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: LegacyEvaluationCriteria, ) -> EvaluationResult: """Evaluate the given data and return a result from a raw evaluation criteria.""" @@ -119,19 +119,19 @@ async def validate_and_evaluate_criteria( # Check if line-by-line evaluation is enabled if self.line_by_line_evaluation: - return await self._evaluate_line_by_line(agent_execution, criteria) + return await self._evaluate_line_by_line(workload_execution, criteria) - return await self.evaluate(agent_execution, criteria) + return await self.evaluate(workload_execution, criteria) async def _evaluate_line_by_line( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: LegacyEvaluationCriteria, ) -> EvaluationResult: """Evaluate output line-by-line and aggregate results. Args: - agent_execution: The execution details + workload_execution: The execution details evaluation_criteria: The evaluation criteria Returns: @@ -140,7 +140,7 @@ async def _evaluate_line_by_line( from .line_by_line_utils import build_line_by_line_result, evaluate_lines # Extract actual and expected outputs - actual_output = self._get_actual_output(agent_execution) + actual_output = self._get_actual_output(workload_execution) expected_output = evaluation_criteria.expected_output # Split into lines using utility function @@ -168,7 +168,7 @@ def create_line_criteria(expected_line: str) -> LegacyEvaluationCriteria: actual_lines=actual_lines, expected_lines=expected_lines, target_output_key=self.target_output_key, - agent_execution=agent_execution, + workload_execution=workload_execution, evaluate_fn=self.evaluate, create_line_criteria_fn=create_line_criteria, ) @@ -181,29 +181,32 @@ def create_line_criteria(expected_line: str) -> LegacyEvaluationCriteria: expected_lines=expected_lines, ) - def _get_actual_output(self, agent_execution: AgentExecution) -> Any: - """Extract actual output from agent execution. + def _get_actual_output(self, workload_execution: WorkloadExecution) -> Any: + """Extract actual output from workload execution. If the output is a job attachment URI, downloads the attachment and returns its content as a string. Args: - agent_execution: The agent execution + workload_execution: The workload execution Returns: - The actual output (either the full agent_output or a specific key) + The actual output (either the full workload_output or a specific key) """ - agent_output = agent_execution.agent_output + workload_output = workload_execution.workload_output # If target_output_key is "*", return full output if self.target_output_key == "*": - result = agent_output + result = workload_output # Otherwise, extract specific key - elif isinstance(agent_output, dict) and self.target_output_key in agent_output: - result = agent_output[self.target_output_key] + elif ( + isinstance(workload_output, dict) + and self.target_output_key in workload_output + ): + result = workload_output[self.target_output_key] else: # Fallback to full output - result = agent_output + result = workload_output # Check if result is a job attachment URI and download if so if is_job_attachment_uri(result): @@ -217,13 +220,13 @@ def _get_actual_output(self, agent_execution: AgentExecution) -> Any: @abstractmethod async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: LegacyEvaluationCriteria, ) -> EvaluationResult: """Evaluate the given data and return a result. Args: - agent_execution: The execution details containing: + workload_execution: The execution details containing: - agent_input: The input received by the agent - actual_output: The actual output from the agent - spans: The execution spans to use for the evaluation diff --git a/packages/uipath/src/uipath/eval/evaluators/binary_classification_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/binary_classification_evaluator.py index d56509228..051de81a3 100644 --- a/packages/uipath/src/uipath/eval/evaluators/binary_classification_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/binary_classification_evaluator.py @@ -1,4 +1,4 @@ -"""Binary classification evaluator for agent outputs. +"""Binary classification evaluator for workload outputs. Evaluates binary classification by comparing predicted vs expected class. Per-datapoint score is 1.0 (correct) or 0.0 (incorrect). The reduce_scores @@ -9,10 +9,10 @@ from typing import Literal from ..models import ( - AgentExecution, EvaluationResult, EvaluatorType, NumericEvaluationResult, + WorkloadExecution, ) from ..models.models import ( EvaluationResultDto, @@ -63,11 +63,11 @@ def get_evaluator_id(cls) -> str: async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: BinaryClassificationEvaluationCriteria, ) -> EvaluationResult: """Evaluate binary classification by comparing predicted vs expected class.""" - predicted_class = str(self._get_actual_output(agent_execution)).lower() + predicted_class = str(self._get_actual_output(workload_execution)).lower() expected_class = evaluation_criteria.expected_class.lower() positive_class = self.evaluator_config.positive_class.lower() diff --git a/packages/uipath/src/uipath/eval/evaluators/contains_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/contains_evaluator.py index c002b47d6..f4f30f7cc 100644 --- a/packages/uipath/src/uipath/eval/evaluators/contains_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/contains_evaluator.py @@ -1,10 +1,10 @@ -"""Contains evaluator for agent outputs.""" +"""Contains evaluator for workload outputs.""" from ..models import ( - AgentExecution, EvaluationResult, EvaluatorType, NumericEvaluationResult, + WorkloadExecution, ) from .base_evaluator import BaseEvaluationCriteria, BaseEvaluatorJustification from .output_evaluator import ( @@ -45,22 +45,22 @@ def get_evaluator_id(cls) -> str: async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: ContainsEvaluationCriteria, ) -> EvaluationResult: """Evaluate whether actual output contains the expected output. Args: - agent_execution: The execution details containing: + workload_execution: The execution details containing: - agent_input: The input received by the agent - - agent_output: The actual output from the agent - - agent_trace: The execution spans to use for the evaluation + - workload_output: The actual output from the agent + - workload_trace: The execution spans to use for the evaluation evaluation_criteria: The criteria to evaluate Returns: EvaluationResult: Boolean result indicating if output contains expected value (True/False) """ - actual_output = str(self._get_actual_output(agent_execution)) + actual_output = str(self._get_actual_output(workload_execution)) expected_output = str(self._get_expected_output(evaluation_criteria)) if not self.evaluator_config.case_sensitive: diff --git a/packages/uipath/src/uipath/eval/evaluators/exact_match_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/exact_match_evaluator.py index 0f1b3e8e8..e3873b01f 100644 --- a/packages/uipath/src/uipath/eval/evaluators/exact_match_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/exact_match_evaluator.py @@ -1,10 +1,10 @@ -"""Exact match evaluator for agent outputs.""" +"""Exact match evaluator for workload outputs.""" from ..models import ( - AgentExecution, EvaluationResult, EvaluatorType, NumericEvaluationResult, + WorkloadExecution, ) from .base_evaluator import BaseEvaluatorJustification from .output_evaluator import ( @@ -41,22 +41,22 @@ def get_evaluator_id(cls) -> str: async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: OutputEvaluationCriteria, ) -> EvaluationResult: """Evaluate whether actual output exactly matches expected output. Args: - agent_execution: The execution details containing: + workload_execution: The execution details containing: - agent_input: The input received by the agent - - agent_output: The actual output from the agent - - agent_trace: The execution spans to use for the evaluation + - workload_output: The actual output from the agent + - workload_trace: The execution spans to use for the evaluation evaluation_criteria: The criteria to evaluate Returns: EvaluationResult: Boolean result indicating exact match (True/False) """ - actual_output = self._get_actual_output(agent_execution) + actual_output = self._get_actual_output(workload_execution) expected_output = self._get_expected_output(evaluation_criteria) if isinstance(actual_output, str) or isinstance(expected_output, str): diff --git a/packages/uipath/src/uipath/eval/evaluators/json_similarity_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/json_similarity_evaluator.py index 552194f2e..9ab67be22 100644 --- a/packages/uipath/src/uipath/eval/evaluators/json_similarity_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/json_similarity_evaluator.py @@ -4,10 +4,10 @@ from typing import Any, Tuple from ..models import ( - AgentExecution, EvaluationResult, EvaluatorType, NumericEvaluationResult, + WorkloadExecution, ) from .base_evaluator import BaseEvaluatorJustification from .output_evaluator import ( @@ -51,7 +51,7 @@ def get_evaluator_id(cls) -> str: async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: OutputEvaluationCriteria, ) -> EvaluationResult: """Evaluate similarity between expected and actual JSON outputs. @@ -59,7 +59,7 @@ async def evaluate( Uses token-based comparison with tolerance for numeric differences and Levenshtein distance for string similarity. - agent_execution: The execution details containing: + workload_execution: The execution details containing: - agent_input: The input received by the agent - actual_output: The actual output from the agent - spans: The execution spans to use for the evaluation @@ -69,7 +69,7 @@ async def evaluate( EvaluationResult: Numerical score between 0-100 indicating similarity """ expected_output = self._get_expected_output(evaluation_criteria) - actual_output = self._get_actual_output(agent_execution) + actual_output = self._get_actual_output(workload_execution) score, justification = self._compare_json( expected_output, actual_output, diff --git a/packages/uipath/src/uipath/eval/evaluators/legacy_context_precision_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_context_precision_evaluator.py index 3b0468363..f0ea9c3a5 100644 --- a/packages/uipath/src/uipath/eval/evaluators/legacy_context_precision_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/legacy_context_precision_evaluator.py @@ -11,7 +11,7 @@ from .._execution_context import eval_set_run_id_context from ..models import NumericEvaluationResult -from ..models.models import AgentExecution, EvaluationResult +from ..models.models import EvaluationResult, WorkloadExecution from .base_legacy_evaluator import ( BaseLegacyEvaluator, LegacyEvaluationCriteria, @@ -96,7 +96,7 @@ class LegacyContextPrecisionEvaluator( ): """Legacy evaluator that assesses context precision using an LLM. - This evaluator extracts context grounding spans from agent execution traces + This evaluator extracts context grounding spans from workload execution traces and uses an LLM to score the relevance of each chunk to its corresponding query. The final score is the mean of all chunk relevancy scores (normalized to 0-1). """ @@ -125,13 +125,13 @@ def _initialize_llm(self): @track_evaluation_metrics async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: LegacyEvaluationCriteria, ) -> EvaluationResult: - """Evaluate context precision from agent execution traces. + """Evaluate context precision from workload execution traces. Args: - agent_execution: The execution details containing agent_trace with spans + workload_execution: The execution details containing workload_trace with spans evaluation_criteria: Legacy evaluation criteria (unused for context precision) Returns: @@ -143,13 +143,13 @@ async def evaluate( # Extract context grounding spans from the trace context_groundings = self._extract_context_groundings( - agent_execution.agent_trace + workload_execution.workload_trace ) if not context_groundings: return NumericEvaluationResult( score=0.0, - details="No context grounding tool calls found in the agent execution trace.", + details="No context grounding tool calls found in the workload execution trace.", ) # Evaluate each context grounding call @@ -224,16 +224,16 @@ def _parse_span_value(self, value_str: str) -> Any: raise ValueError(f"Cannot parse value: {value_str}") from e def _extract_context_groundings( - self, agent_trace: list[Any] + self, workload_trace: list[Any] ) -> list[dict[str, Any]]: - """Extract context groundings from agent execution trace. + """Extract context groundings from workload execution trace. Looks for spans with input.value and output.value attributes that represent context grounding tool calls. """ context_groundings = [] - for span in agent_trace: + for span in workload_trace: if not hasattr(span, "attributes") or span.attributes is None: continue diff --git a/packages/uipath/src/uipath/eval/evaluators/legacy_csv_exact_match_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_csv_exact_match_evaluator.py index 0b7bac2b4..9441ae65a 100644 --- a/packages/uipath/src/uipath/eval/evaluators/legacy_csv_exact_match_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/legacy_csv_exact_match_evaluator.py @@ -12,7 +12,7 @@ EvaluationResult, ) -from ..models.models import AgentExecution +from ..models.models import WorkloadExecution from .base_legacy_evaluator import LegacyEvaluationCriteria, LegacyEvaluatorConfig from .legacy_deterministic_evaluator_base import BaseLegacyDeterministicEvaluator from .line_by_line_utils import wrap_line_in_structure @@ -59,15 +59,15 @@ def validate_target_sub_output_key(cls, v: str) -> str: async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: LegacyEvaluationCriteria, ) -> EvaluationResult: """Evaluate whether specific CSV columns exactly match between actual and expected. Args: - agent_execution: The execution details containing: + workload_execution: The execution details containing: - agent_input: The input received by the agent - - agent_output: The actual output from the agent (can be CSV string or job attachment) + - workload_output: The actual output from the agent (can be CSV string or job attachment) - spans: The execution spans to use for the evaluation evaluation_criteria: The criteria to evaluate containing expected output @@ -78,7 +78,7 @@ async def evaluate( ValueError: If CSV format is invalid or required columns are missing """ # Get actual output (handles job attachments and target_output_key extraction) - actual_output = self._get_actual_output(agent_execution) + actual_output = self._get_actual_output(workload_execution) # Get expected output from criteria expected_output = evaluation_criteria.expected_output @@ -241,7 +241,7 @@ def _do_values_match( async def _evaluate_line_by_line( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: LegacyEvaluationCriteria, ) -> EvaluationResult: """Override line-by-line evaluation to handle CSV structure properly. @@ -255,7 +255,7 @@ async def _evaluate_line_by_line( 5. Aggregates results Args: - agent_execution: The execution details + workload_execution: The execution details evaluation_criteria: The evaluation criteria Returns: @@ -271,7 +271,7 @@ async def _evaluate_line_by_line( ) # Get actual output (this handles job attachments automatically) - actual_output = self._get_actual_output(agent_execution) + actual_output = self._get_actual_output(workload_execution) # Get expected output from criteria expected_output = evaluation_criteria.expected_output @@ -330,18 +330,18 @@ async def _evaluate_line_by_line( else: expected_mini_csv = expected_header # Just header, will fail validation - # Create a modified agent execution for this line - line_agent_execution = AgentExecution( - agent_input=agent_execution.agent_input, - agent_output=wrap_line_in_structure( + # Create a modified workload execution for this line + line_agent_execution = WorkloadExecution( + agent_input=workload_execution.agent_input, + workload_output=wrap_line_in_structure( actual_mini_csv, self.target_output_key ), - agent_trace=agent_execution.agent_trace, + workload_trace=workload_execution.workload_trace, expected_agent_behavior=getattr( - agent_execution, "expected_agent_behavior", None + workload_execution, "expected_agent_behavior", None ), simulation_instructions=getattr( - agent_execution, "simulation_instructions", "" + workload_execution, "simulation_instructions", "" ), ) diff --git a/packages/uipath/src/uipath/eval/evaluators/legacy_exact_match_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_exact_match_evaluator.py index 42ffae047..f20ea2bdf 100644 --- a/packages/uipath/src/uipath/eval/evaluators/legacy_exact_match_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/legacy_exact_match_evaluator.py @@ -1,9 +1,9 @@ -"""Exact match evaluator for binary pass/fail evaluation of agent outputs.""" +"""Exact match evaluator for binary pass/fail evaluation of workload outputs.""" from uipath.eval.models import BooleanEvaluationResult, EvaluationResult from .._helpers.output_path import resolve_output_path -from ..models.models import AgentExecution +from ..models.models import WorkloadExecution from .base_legacy_evaluator import LegacyEvaluationCriteria, LegacyEvaluatorConfig from .legacy_deterministic_evaluator_base import BaseLegacyDeterministicEvaluator @@ -26,13 +26,13 @@ class LegacyExactMatchEvaluator( async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: LegacyEvaluationCriteria, ) -> EvaluationResult: """Evaluate whether actual output exactly matches expected output. Args: - agent_execution: The execution details containing: + workload_execution: The execution details containing: - agent_input: The input received by the agent - actual_output: The actual output from the agent - spans: The execution spans to use for the evaluation @@ -41,7 +41,7 @@ async def evaluate( Returns: EvaluationResult: Boolean result indicating exact match (True/False) """ - actual_output = agent_execution.agent_output + actual_output = workload_execution.workload_output expected_output = evaluation_criteria.expected_output if self.target_output_key and self.target_output_key != "*": diff --git a/packages/uipath/src/uipath/eval/evaluators/legacy_faithfulness_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_faithfulness_evaluator.py index c4eba1fcf..20d33a87e 100644 --- a/packages/uipath/src/uipath/eval/evaluators/legacy_faithfulness_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/legacy_faithfulness_evaluator.py @@ -1,4 +1,4 @@ -"""Legacy Faithfulness evaluator for assessing whether agent output claims are grounded in context.""" +"""Legacy Faithfulness evaluator for assessing whether workload output claims are grounded in context.""" import json from typing import Any, Optional @@ -10,7 +10,7 @@ from .._execution_context import eval_set_run_id_context from ..models import NumericEvaluationResult -from ..models.models import AgentExecution, EvaluationResult +from ..models.models import EvaluationResult, WorkloadExecution from .base_legacy_evaluator import ( BaseLegacyEvaluator, LegacyEvaluationCriteria, @@ -35,9 +35,9 @@ class LegacyFaithfulnessEvaluator( ): """Legacy evaluator that assesses faithfulness using an LLM. - This evaluator extracts claims from agent output using a 3-stage pipeline + This evaluator extracts claims from workload output using a 3-stage pipeline (selection, disambiguation, decomposition) and evaluates whether each claim - is grounded in the available context sources extracted from agent traces. + is grounded in the available context sources extracted from workload traces. The final score is the percentage of claims that are grounded. """ @@ -63,13 +63,13 @@ def _initialize_llm(self): @track_evaluation_metrics async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: LegacyEvaluationCriteria, ) -> EvaluationResult: - """Evaluate faithfulness of agent output against available context. + """Evaluate faithfulness of workload output against available context. Args: - agent_execution: The execution details containing agent_trace with spans + workload_execution: The execution details containing workload_trace with spans evaluation_criteria: Legacy evaluation criteria containing expected_output Returns: @@ -79,30 +79,32 @@ async def evaluate( if self.llm is None: self._initialize_llm() - # Extract agent output - agent_output = str(evaluation_criteria.expected_output or "") - if not agent_output or not agent_output.strip(): + # Extract workload output + workload_output = str(evaluation_criteria.expected_output or "") + if not workload_output or not workload_output.strip(): return NumericEvaluationResult( score=0.0, - details="No agent output provided for faithfulness evaluation.", + details="No workload output provided for faithfulness evaluation.", ) # Extract context sources from traces - context_sources = self._extract_context_sources(agent_execution.agent_trace) + context_sources = self._extract_context_sources( + workload_execution.workload_trace + ) if not context_sources: return NumericEvaluationResult( score=0.0, - details="No context sources found in the agent execution trace.", + details="No context sources found in the workload execution trace.", ) - # Stage 1: Extract verifiable claims from agent output - claims = await self._extract_claims(agent_output) + # Stage 1: Extract verifiable claims from workload output + claims = await self._extract_claims(workload_output) if not claims: return NumericEvaluationResult( score=100.0, - details="No verifiable claims found in agent output.", + details="No verifiable claims found in workload output.", ) # Stage 2: Evaluate each claim against context sources @@ -127,8 +129,10 @@ async def evaluate( details=justification, ) - def _extract_context_sources(self, agent_trace: list[Any]) -> list[dict[str, str]]: - """Extract context sources from agent execution trace. + def _extract_context_sources( + self, workload_trace: list[Any] + ) -> list[dict[str, str]]: + """Extract context sources from workload execution trace. Looks for tool call outputs and context grounding spans that provide context. @@ -137,7 +141,7 @@ def _extract_context_sources(self, agent_trace: list[Any]) -> list[dict[str, str """ context_sources = [] - for span in agent_trace: + for span in workload_trace: if not hasattr(span, "attributes") or span.attributes is None: continue @@ -180,8 +184,8 @@ def _serialize_content(self, content: Any) -> str: """Serialize content to string format.""" return serialize_object(content, sort_keys=False) - async def _extract_claims(self, agent_output: str) -> list[dict[str, str]]: - """Extract verifiable claims from agent output using 3-stage pipeline. + async def _extract_claims(self, workload_output: str) -> list[dict[str, str]]: + """Extract verifiable claims from workload output using 3-stage pipeline. Stages: 1. Selection: Filter to verifiable sentences @@ -192,23 +196,25 @@ async def _extract_claims(self, agent_output: str) -> list[dict[str, str]]: List of claim dicts with 'text' and 'original_sentence' keys """ # Stage 1: Selection - verifiable_sentences = await self._select_verifiable_sentences(agent_output) + verifiable_sentences = await self._select_verifiable_sentences(workload_output) if not verifiable_sentences: return [] # Stage 2: Disambiguation disambiguated_sentences = await self._disambiguate_sentences( - verifiable_sentences, agent_output + verifiable_sentences, workload_output ) if not disambiguated_sentences: return [] # Stage 3: Decomposition - claims = await self._decompose_to_claims(disambiguated_sentences, agent_output) + claims = await self._decompose_to_claims( + disambiguated_sentences, workload_output + ) return claims - async def _select_verifiable_sentences(self, agent_output: str) -> list[str]: - """Stage 1: Filter agent output to verifiable sentences.""" + async def _select_verifiable_sentences(self, workload_output: str) -> list[str]: + """Stage 1: Filter workload output to verifiable sentences.""" prompt = f"""You are an expert evaluator identifying verifiable claims. TASK: Identify sentences in the agent output that contain verifiable, factual claims. @@ -217,9 +223,9 @@ async def _select_verifiable_sentences(self, agent_output: str) -> list[str]: OUTPUT FORMAT: Return a JSON object with a "sentences" field containing an array of strings. Each string should be a complete sentence from the original output. - -{agent_output} - + +{workload_output} + Identify and return only the verifiable sentences.""" diff --git a/packages/uipath/src/uipath/eval/evaluators/legacy_json_similarity_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_json_similarity_evaluator.py index 70fe28b2e..0fbb99b8e 100644 --- a/packages/uipath/src/uipath/eval/evaluators/legacy_json_similarity_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/legacy_json_similarity_evaluator.py @@ -5,7 +5,7 @@ from .._helpers.output_path import resolve_output_path from ..models import EvaluationResult, NumericEvaluationResult -from ..models.models import AgentExecution +from ..models.models import WorkloadExecution from .base_legacy_evaluator import LegacyEvaluationCriteria, LegacyEvaluatorConfig from .legacy_deterministic_evaluator_base import BaseLegacyDeterministicEvaluator @@ -30,7 +30,7 @@ class LegacyJsonSimilarityEvaluator( async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: LegacyEvaluationCriteria, ) -> EvaluationResult: """Evaluate similarity between expected and actual JSON outputs. @@ -38,7 +38,7 @@ async def evaluate( Uses token-based comparison with tolerance for numeric differences and Levenshtein distance for string similarity. - agent_execution: The execution details containing: + workload_execution: The execution details containing: - agent_input: The input received by the agent - actual_output: The actual output from the agent - spans: The execution spans to use for the evaluation @@ -47,7 +47,7 @@ async def evaluate( Returns: EvaluationResult: Numerical score between 0-100 indicating similarity """ - actual_output = agent_execution.agent_output + actual_output = workload_execution.workload_output expected_output = evaluation_criteria.expected_output if self.target_output_key and self.target_output_key != "*": diff --git a/packages/uipath/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py index 0c676ac9b..ec0662ae8 100644 --- a/packages/uipath/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py @@ -1,4 +1,4 @@ -"""LLM-as-a-judge evaluator for subjective quality assessment of agent outputs.""" +"""LLM-as-a-judge evaluator for subjective quality assessment of workload outputs.""" import logging from typing import Any, Optional @@ -15,11 +15,11 @@ from .._helpers.output_path import resolve_output_path from ..models import NumericEvaluationResult from ..models.models import ( - AgentExecution, EvaluationResult, LLMResponse, UiPathEvaluationError, UiPathEvaluationErrorCategory, + WorkloadExecution, ) from .base_legacy_evaluator import ( BaseLegacyEvaluator, @@ -38,7 +38,7 @@ class LegacyLlmAsAJudgeEvaluatorConfig(LegacyEvaluatorConfig): class LegacyLlmAsAJudgeEvaluator(BaseLegacyEvaluator[LegacyLlmAsAJudgeEvaluatorConfig]): - """Legacy evaluator that uses an LLM to judge the quality of agent output.""" + """Legacy evaluator that uses an LLM to judge the quality of workload output.""" prompt: str model: str = Field(default="same-as-agent") @@ -104,7 +104,7 @@ def _initialize_llm(self): async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: LegacyEvaluationCriteria, ) -> EvaluationResult: """Evaluate using an LLM as a judge. @@ -112,7 +112,7 @@ async def evaluate( Sends the formatted prompt to the configured LLM and expects a JSON response with a numerical score (0-100) and justification. - agent_execution: The execution details containing: + workload_execution: The execution details containing: - agent_input: The input received by the agent - actual_output: The actual output from the agent - spans: The execution spans to use for the evaluation @@ -125,7 +125,7 @@ async def evaluate( if self.llm is None: self._initialize_llm() - actual_output = agent_execution.agent_output + actual_output = workload_execution.workload_output expected_output = evaluation_criteria.expected_output if self.target_output_key and self.target_output_key != "*": diff --git a/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py index cff0e8788..b7cb627e8 100644 --- a/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py @@ -16,11 +16,11 @@ from .._helpers.helpers import is_empty_value from ..models import EvaluationResult from ..models.models import ( - AgentExecution, LLMResponse, NumericEvaluationResult, UiPathEvaluationError, UiPathEvaluationErrorCategory, + WorkloadExecution, ) from .base_legacy_evaluator import ( BaseLegacyEvaluator, @@ -75,7 +75,7 @@ def _initialize_llm(self): async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: LegacyEvaluationCriteria, ) -> EvaluationResult: """Evaluate using trajectory analysis. @@ -83,10 +83,10 @@ async def evaluate( Analyzes the execution path and decision sequence taken by the agent. Args: - agent_execution: The execution details containing: + workload_execution: The execution details containing: - agent_input: The input received by the agent - actual_output: The actual output from the agent - - agent_trace: The execution spans to use for the evaluation + - workload_trace: The execution spans to use for the evaluation - expected_agent_behavior: The expected agent behavior evaluation_criteria: The criteria to evaluate Returns: @@ -100,8 +100,8 @@ async def evaluate( self._initialize_llm() evaluation_prompt = self._create_evaluation_prompt( - expected_agent_behavior=agent_execution.expected_agent_behavior, - agent_run_history=agent_execution.agent_trace, + expected_agent_behavior=workload_execution.expected_agent_behavior, + agent_run_history=workload_execution.workload_trace, ) llm_response = await self._get_llm_response(evaluation_prompt) diff --git a/packages/uipath/src/uipath/eval/evaluators/line_by_line_utils.py b/packages/uipath/src/uipath/eval/evaluators/line_by_line_utils.py index 19182622c..ca1d7d891 100644 --- a/packages/uipath/src/uipath/eval/evaluators/line_by_line_utils.py +++ b/packages/uipath/src/uipath/eval/evaluators/line_by_line_utils.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any, Callable if TYPE_CHECKING: - from ..models import AgentExecution, EvaluationResult + from ..models import EvaluationResult, WorkloadExecution def split_into_lines( @@ -79,7 +79,7 @@ async def evaluate_lines( actual_lines: list[str], expected_lines: list[str], target_output_key: str, - agent_execution: "AgentExecution", + workload_execution: "WorkloadExecution", evaluate_fn: Callable[[Any, Any], Any], create_line_criteria_fn: Callable[[str], Any], ) -> tuple[list[Any], list[tuple[int, "EvaluationResult"]]]: @@ -89,7 +89,7 @@ async def evaluate_lines( actual_lines: List of actual output lines expected_lines: List of expected output lines target_output_key: Key for wrapping lines - agent_execution: Original agent execution + workload_execution: Original workload execution evaluate_fn: Function to evaluate (line_execution, line_criteria) -> result create_line_criteria_fn: Function to create criteria for a line (expected_line) -> criteria @@ -110,18 +110,18 @@ async def evaluate_lines( # Wrap lines in the same structure as original output line_agent_output = wrap_line_in_structure(actual_line, target_output_key) - # Create a modified agent execution for this line - from ..models.models import AgentExecution + # Create a modified workload execution for this line + from ..models.models import WorkloadExecution - line_agent_execution = AgentExecution( - agent_input=agent_execution.agent_input, - agent_output=line_agent_output, - agent_trace=agent_execution.agent_trace, + line_agent_execution = WorkloadExecution( + agent_input=workload_execution.agent_input, + workload_output=line_agent_output, + workload_trace=workload_execution.workload_trace, expected_agent_behavior=getattr( - agent_execution, "expected_agent_behavior", None + workload_execution, "expected_agent_behavior", None ), simulation_instructions=getattr( - agent_execution, "simulation_instructions", "" + workload_execution, "simulation_instructions", "" ), ) diff --git a/packages/uipath/src/uipath/eval/evaluators/llm_as_judge_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/llm_as_judge_evaluator.py index 06b6aabce..ccbace5dc 100644 --- a/packages/uipath/src/uipath/eval/evaluators/llm_as_judge_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/llm_as_judge_evaluator.py @@ -1,4 +1,4 @@ -"""LLM-as-a-judge evaluator for subjective quality assessment of agent outputs.""" +"""LLM-as-a-judge evaluator for subjective quality assessment of workload outputs.""" import copy import json @@ -15,10 +15,10 @@ from .._execution_context import eval_set_run_id_context from ..models import ( - AgentExecution, EvaluationResult, LLMResponse, NumericEvaluationResult, + WorkloadExecution, ) from ..models.llm_judge_types import ( LLMJudgeOutputSchema, @@ -142,8 +142,8 @@ def _get_llm_service(self): ) from e @abstractmethod - def _get_actual_output(self, agent_execution: AgentExecution) -> Any: - """Get the actual output from the agent execution. Must be implemented by concrete evaluator classes.""" + def _get_actual_output(self, workload_execution: WorkloadExecution) -> Any: + """Get the actual output from the workload execution. Must be implemented by concrete evaluator classes.""" pass @abstractmethod @@ -153,12 +153,12 @@ def _get_expected_output(self, evaluation_criteria: T) -> Any: async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: T, ) -> EvaluationResult: """Evaluate using an LLM as a judge.""" evaluation_prompt = self._create_evaluation_prompt( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria=evaluation_criteria, ) @@ -166,7 +166,7 @@ async def evaluate( validated_justification = self.validate_justification( { "expected": str(self._get_expected_output(evaluation_criteria)), - "actual": str(self._get_actual_output(agent_execution)), + "actual": str(self._get_actual_output(workload_execution)), "justification": llm_response.justification, } ) @@ -178,7 +178,7 @@ async def evaluate( def _create_evaluation_prompt( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: T, ) -> str: """Create the evaluation prompt for the LLM.""" @@ -186,7 +186,7 @@ def _create_evaluation_prompt( formatted_prompt = self.evaluator_config.prompt.replace( self.actual_output_placeholder, - str(self._get_actual_output(agent_execution)), + str(self._get_actual_output(workload_execution)), ) formatted_prompt = formatted_prompt.replace( self.expected_output_placeholder, diff --git a/packages/uipath/src/uipath/eval/evaluators/llm_judge_output_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/llm_judge_output_evaluator.py index d533de237..bd86d229b 100644 --- a/packages/uipath/src/uipath/eval/evaluators/llm_judge_output_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/llm_judge_output_evaluator.py @@ -1,10 +1,10 @@ -"""LLM judge output evaluators for evaluating agent outputs.""" +"""LLM judge output evaluators for evaluating workload outputs.""" from typing import TypeVar from pydantic import BaseModel, Field -from ..models import AgentExecution, EvaluationResult, EvaluatorType +from ..models import EvaluationResult, EvaluatorType, WorkloadExecution from ..models.llm_judge_types import ( LLMJudgeOutputSchema, LLMJudgePromptTemplates, @@ -68,16 +68,18 @@ def get_evaluator_id(cls) -> str: async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: OutputEvaluationCriteria, ) -> EvaluationResult: """Evaluate using an LLM as a judge.""" # Explicitly delegate to LLMJudgeMixin's evaluate method to override BaseEvaluator - return await LLMJudgeMixin.evaluate(self, agent_execution, evaluation_criteria) + return await LLMJudgeMixin.evaluate( + self, workload_execution, evaluation_criteria + ) class LLMJudgeOutputEvaluator(BaseLLMOutputEvaluator[LLMJudgeOutputEvaluatorConfig]): - """Evaluator that uses an LLM to judge the quality of agent output. + """Evaluator that uses an LLM to judge the quality of workload output. Inherits all functionality from BaseLLMOutputEvaluator but uses the standard system prompt and output schema for general output evaluation. @@ -95,7 +97,7 @@ def get_evaluator_id(cls) -> str: class LLMJudgeStrictJSONSimilarityOutputEvaluator( BaseLLMOutputEvaluator[LLMJudgeStrictJSONSimilarityOutputEvaluatorConfig] ): - """Evaluator that uses an LLM to judge the quality of agent output with strict JSON similarity. + """Evaluator that uses an LLM to judge the quality of workload output with strict JSON similarity. Inherits all functionality from BaseLLMOutputEvaluator but uses a different system prompt and output schema specific to strict JSON similarity evaluation. diff --git a/packages/uipath/src/uipath/eval/evaluators/llm_judge_trajectory_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/llm_judge_trajectory_evaluator.py index 69b14cd1f..d8b16ea90 100644 --- a/packages/uipath/src/uipath/eval/evaluators/llm_judge_trajectory_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/llm_judge_trajectory_evaluator.py @@ -1,4 +1,4 @@ -"""LLM judge trajectory evaluator for evaluating agent execution trajectories.""" +"""LLM judge trajectory evaluator for evaluating workload execution trajectories.""" from typing import Any, TypeVar @@ -6,9 +6,9 @@ from .._helpers.evaluators_helpers import trace_to_str from ..models import ( - AgentExecution, EvaluationResult, EvaluatorType, + WorkloadExecution, ) from ..models.llm_judge_types import ( LLMJudgePromptTemplates, @@ -72,15 +72,15 @@ def get_evaluator_id(cls) -> str: async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: TrajectoryEvaluationCriteria, ) -> EvaluationResult: """Evaluate using trajectory analysis.""" - return await super().evaluate(agent_execution, evaluation_criteria) + return await super().evaluate(workload_execution, evaluation_criteria) - def _get_actual_output(self, agent_execution: AgentExecution) -> Any: - """Get the actual output from the agent execution.""" - return trace_to_str(agent_execution.agent_trace) + def _get_actual_output(self, workload_execution: WorkloadExecution) -> Any: + """Get the actual output from the workload execution.""" + return trace_to_str(workload_execution.workload_trace) def _get_expected_output( self, evaluation_criteria: TrajectoryEvaluationCriteria @@ -90,20 +90,20 @@ def _get_expected_output( def _create_evaluation_prompt( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: TrajectoryEvaluationCriteria, ) -> str: """Create the evaluation prompt for the LLM.""" formatted_prompt = super()._create_evaluation_prompt( - agent_execution, evaluation_criteria + workload_execution, evaluation_criteria ) formatted_prompt = formatted_prompt.replace( self.user_input_placeholder, - str(agent_execution.agent_input), + str(workload_execution.agent_input), ) formatted_prompt = formatted_prompt.replace( self.simulation_instructions_placeholder, - agent_execution.simulation_instructions, + workload_execution.simulation_instructions, ) return formatted_prompt diff --git a/packages/uipath/src/uipath/eval/evaluators/multiclass_classification_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/multiclass_classification_evaluator.py index 69790c3aa..ef1ffe0b6 100644 --- a/packages/uipath/src/uipath/eval/evaluators/multiclass_classification_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/multiclass_classification_evaluator.py @@ -1,4 +1,4 @@ -"""Multiclass classification evaluator for agent outputs. +"""Multiclass classification evaluator for workload outputs. Evaluates multiclass classification by comparing predicted vs expected class. Per-datapoint score is 1.0 (correct) or 0.0 (incorrect). The reduce_scores @@ -10,10 +10,10 @@ from typing import Literal from ..models import ( - AgentExecution, EvaluationResult, EvaluatorType, NumericEvaluationResult, + WorkloadExecution, ) from ..models.models import ( EvaluationResultDto, @@ -66,11 +66,11 @@ def get_evaluator_id(cls) -> str: async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: MulticlassClassificationEvaluationCriteria, ) -> EvaluationResult: """Evaluate multiclass classification by comparing predicted vs expected class.""" - predicted_class = str(self._get_actual_output(agent_execution)).lower() + predicted_class = str(self._get_actual_output(workload_execution)).lower() expected_class = evaluation_criteria.expected_class.lower() classes = [c.lower() for c in self.evaluator_config.classes] diff --git a/packages/uipath/src/uipath/eval/evaluators/output_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/output_evaluator.py index 5eb54e9e3..d8b99c9c0 100644 --- a/packages/uipath/src/uipath/eval/evaluators/output_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/output_evaluator.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field from .._helpers.output_path import resolve_output_path -from ..models import AgentExecution +from ..models import WorkloadExecution from ..models.models import UiPathEvaluationError, UiPathEvaluationErrorCategory from .attachment_utils import ( download_attachment_as_string, @@ -88,7 +88,7 @@ class OutputEvaluatorConfig(BaseEvaluatorConfig[T]): target_output_key: str | list[str] = Field( default="*", - description="Key or list of keys to extract output from agent execution", + description="Key or list of keys to extract output from workload execution", ) line_by_line_evaluator: bool = Field( default=False, @@ -128,8 +128,8 @@ def _normalize_numbers(self, obj: Any) -> Any: return float(obj) return obj - def _get_actual_output(self, agent_execution: AgentExecution) -> Any: - """Get the actual output from the agent execution. + def _get_actual_output(self, workload_execution: WorkloadExecution) -> Any: + """Get the actual output from the workload execution. If the output is a job attachment URI, downloads the attachment and returns its content as a string. @@ -137,16 +137,17 @@ def _get_actual_output(self, agent_execution: AgentExecution) -> Any: key = self.evaluator_config.target_output_key if isinstance(key, list): - if not isinstance(agent_execution.agent_output, dict): + if not isinstance(workload_execution.workload_output, dict): raise UiPathEvaluationError( code="INVALID_ACTUAL_OUTPUT", title="When target output keys are specified, actual output must be a dictionary", - detail=f"Got {type(agent_execution.agent_output).__name__}", + detail=f"Got {type(workload_execution.workload_output).__name__}", category=UiPathEvaluationErrorCategory.USER, ) try: list_result: dict[str, Any] = { - k: resolve_output_path(agent_execution.agent_output, k) for k in key + k: resolve_output_path(workload_execution.workload_output, k) + for k in key } except (KeyError, IndexError, TypeError) as e: raise UiPathEvaluationError( @@ -162,7 +163,7 @@ def _get_actual_output(self, agent_execution: AgentExecution) -> Any: return self._normalize_numbers(list_result) elif key != "*": try: - result = resolve_output_path(agent_execution.agent_output, key) + result = resolve_output_path(workload_execution.workload_output, key) except (KeyError, IndexError, TypeError) as e: raise UiPathEvaluationError( code="TARGET_OUTPUT_KEY_NOT_FOUND", @@ -171,7 +172,7 @@ def _get_actual_output(self, agent_execution: AgentExecution) -> Any: category=UiPathEvaluationErrorCategory.USER, ) from e else: - result = agent_execution.agent_output + result = workload_execution.workload_output if is_job_attachment_uri(result): attachment_id = extract_attachment_id(result) @@ -253,16 +254,16 @@ def _get_expected_output(self, evaluation_criteria: T) -> Any: async def validate_and_evaluate_criteria( self, - agent_execution: "AgentExecution", + workload_execution: "WorkloadExecution", evaluation_criteria: Any, ) -> "EvaluationResult": - """Validate evaluation criteria and evaluate the agent execution. + """Validate evaluation criteria and evaluate the workload execution. If line_by_line_evaluator is enabled, splits the output by delimiter and evaluates each line separately, then aggregates the scores. Args: - agent_execution: The agent execution to evaluate + workload_execution: The workload execution to evaluate evaluation_criteria: The evaluation criteria (dict or typed object) Returns: @@ -287,20 +288,20 @@ async def validate_and_evaluate_criteria( self.evaluator_config.target_output_key, list ): # Standard evaluation - return await self.evaluate(agent_execution, validated_criteria) + return await self.evaluate(workload_execution, validated_criteria) # Line-by-line evaluation - return await self._evaluate_line_by_line(agent_execution, validated_criteria) + return await self._evaluate_line_by_line(workload_execution, validated_criteria) async def _evaluate_line_by_line( self, - agent_execution: "AgentExecution", + workload_execution: "WorkloadExecution", evaluation_criteria: T, ) -> "EvaluationResult": """Evaluate output line by line and aggregate scores. Args: - agent_execution: The agent execution to evaluate + workload_execution: The workload execution to evaluate evaluation_criteria: Validated evaluation criteria Returns: @@ -314,7 +315,7 @@ async def _evaluate_line_by_line( else "*" ) - actual_output = self._get_actual_output(agent_execution) + actual_output = self._get_actual_output(workload_execution) expected_output = self._get_expected_output(evaluation_criteria) actual_lines = split_into_lines( @@ -324,7 +325,7 @@ async def _evaluate_line_by_line( expected_output, self.evaluator_config.line_delimiter, key_str ) - original_agent_output = agent_execution.agent_output + original_agent_output = workload_execution.workload_output def create_line_criteria(expected_line: str) -> Any: from .line_by_line_utils import wrap_line_in_structure @@ -339,12 +340,12 @@ def create_line_criteria(expected_line: str) -> Any: actual_lines=actual_lines, expected_lines=expected_lines, target_output_key=key_str, - agent_execution=agent_execution, + workload_execution=workload_execution, evaluate_fn=self.evaluate, create_line_criteria_fn=create_line_criteria, ) - agent_execution.agent_output = original_agent_output + workload_execution.workload_output = original_agent_output return build_line_by_line_result( line_details=line_details, diff --git a/packages/uipath/src/uipath/eval/evaluators/tool_call_args_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/tool_call_args_evaluator.py index 2703e3c76..bd10702ff 100644 --- a/packages/uipath/src/uipath/eval/evaluators/tool_call_args_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/tool_call_args_evaluator.py @@ -4,7 +4,12 @@ extract_tool_calls, tool_calls_args_score, ) -from ..models import AgentExecution, EvaluationResult, NumericEvaluationResult, ToolCall +from ..models import ( + EvaluationResult, + NumericEvaluationResult, + ToolCall, + WorkloadExecution, +) from ..models.models import EvaluatorType from .base_evaluator import ( BaseEvaluationCriteria, @@ -54,21 +59,21 @@ def get_evaluator_id(cls) -> str: async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: ToolCallArgsEvaluationCriteria, ) -> EvaluationResult: """Evaluate if the tool calls are in the correct order. Args: - agent_execution: The execution details containing: + workload_execution: The execution details containing: - agent_input: The input received by the agent - - agent_output: The final output of the agent - - agent_trace: The execution spans to use for the evaluation + - workload_output: The final output of the agent + - workload_trace: The execution spans to use for the evaluation evaluation_criteria: The criteria to evaluate Returns: EvaluationResult: Boolean result indicating correct tool call order (True/False) """ - tool_calls_order = extract_tool_calls(agent_execution.agent_trace) + tool_calls_order = extract_tool_calls(workload_execution.workload_trace) score, justification = tool_calls_args_score( tool_calls_order, evaluation_criteria.tool_calls, diff --git a/packages/uipath/src/uipath/eval/evaluators/tool_call_count_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/tool_call_count_evaluator.py index 4df237e7e..4657eec26 100644 --- a/packages/uipath/src/uipath/eval/evaluators/tool_call_count_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/tool_call_count_evaluator.py @@ -5,7 +5,7 @@ extract_tool_calls, tool_calls_count_score, ) -from ..models import AgentExecution, EvaluationResult, NumericEvaluationResult +from ..models import EvaluationResult, NumericEvaluationResult, WorkloadExecution from ..models.models import EvaluatorType from .base_evaluator import ( BaseEvaluationCriteria, @@ -57,22 +57,22 @@ def get_evaluator_id(cls) -> str: async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: ToolCallCountEvaluationCriteria, ) -> EvaluationResult: """Evaluate if the tool calls are in the correct order. Args: - agent_execution: The execution details containing: + workload_execution: The execution details containing: - agent_input: The input received by the agent - - agent_output: The final output of the agent - - agent_trace: The execution spans to use for the evaluation + - workload_output: The final output of the agent + - workload_trace: The execution spans to use for the evaluation evaluation_criteria: The criteria to evaluate Returns: EvaluationResult: Boolean result indicating correct tool call order (True/False) """ tool_calls_count = count_tool_calls_by_name_and_id( - extract_tool_calls(agent_execution.agent_trace, include_args=False) + extract_tool_calls(workload_execution.workload_trace, include_args=False) ) score, justification = tool_calls_count_score( tool_calls_count, diff --git a/packages/uipath/src/uipath/eval/evaluators/tool_call_order_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/tool_call_order_evaluator.py index 4676750d3..17912081c 100644 --- a/packages/uipath/src/uipath/eval/evaluators/tool_call_order_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/tool_call_order_evaluator.py @@ -4,7 +4,7 @@ extract_tool_calls, tool_calls_order_score_with_ids, ) -from ..models import AgentExecution, EvaluationResult, NumericEvaluationResult +from ..models import EvaluationResult, NumericEvaluationResult, WorkloadExecution from ..models.models import EvaluatorType from .base_evaluator import ( BaseEvaluationCriteria, @@ -55,22 +55,22 @@ def get_evaluator_id(cls) -> str: async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: ToolCallOrderEvaluationCriteria, ) -> EvaluationResult: """Evaluate if the tool calls are in the correct order. Args: - agent_execution: The execution details containing: + workload_execution: The execution details containing: - agent_input: The input received by the agent - - agent_output: The final output of the agent - - agent_trace: The execution spans to use for the evaluation + - workload_output: The final output of the agent + - workload_trace: The execution spans to use for the evaluation evaluation_criteria: The criteria to evaluate Returns: EvaluationResult: Boolean result indicating correct tool call order (True/False) """ actual_calls = extract_tool_calls( - agent_execution.agent_trace, include_args=False + workload_execution.workload_trace, include_args=False ) score, justification = tool_calls_order_score_with_ids( actual_calls, diff --git a/packages/uipath/src/uipath/eval/evaluators/tool_call_output_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/tool_call_output_evaluator.py index fff139daf..c098fac3c 100644 --- a/packages/uipath/src/uipath/eval/evaluators/tool_call_output_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/tool_call_output_evaluator.py @@ -5,10 +5,10 @@ tool_calls_output_score, ) from ..models import ( - AgentExecution, EvaluationResult, NumericEvaluationResult, ToolOutput, + WorkloadExecution, ) from ..models.models import EvaluatorType from .base_evaluator import ( @@ -60,21 +60,23 @@ def get_evaluator_id(cls) -> str: async def evaluate( self, - agent_execution: AgentExecution, + workload_execution: WorkloadExecution, evaluation_criteria: ToolCallOutputEvaluationCriteria, ) -> EvaluationResult: """Evaluate if the tool calls are in the correct order. Args: - agent_execution: The execution details containing: + workload_execution: The execution details containing: - agent_input: The input received by the agent - - agent_output: The final output of the agent - - agent_trace: The execution spans to use for the evaluation + - workload_output: The final output of the agent + - workload_trace: The execution spans to use for the evaluation evaluation_criteria: The criteria to evaluate Returns: EvaluationResult: Boolean result indicating correct tool call order (True/False) """ - tool_calls_outputs = extract_tool_calls_outputs(agent_execution.agent_trace) + tool_calls_outputs = extract_tool_calls_outputs( + workload_execution.workload_trace + ) score, justification = tool_calls_output_score( tool_calls_outputs, evaluation_criteria.tool_outputs, diff --git a/packages/uipath/src/uipath/eval/models/__init__.py b/packages/uipath/src/uipath/eval/models/__init__.py index 580ce812a..efc0d1f95 100644 --- a/packages/uipath/src/uipath/eval/models/__init__.py +++ b/packages/uipath/src/uipath/eval/models/__init__.py @@ -1,7 +1,9 @@ """UiPath evaluation module for agent performance assessment.""" +import warnings +from typing import Any + from uipath.eval.models.models import ( - AgentExecution, BooleanEvaluationResult, ErrorEvaluationResult, EvalItemResult, @@ -15,10 +17,11 @@ ScoreType, ToolCall, ToolOutput, + WorkloadExecution, ) __all__ = [ - "AgentExecution", + "WorkloadExecution", "EvaluationResult", "EvaluationResultDto", "LLMResponse", @@ -31,6 +34,23 @@ "NumericEvaluationResult", "ErrorEvaluationResult", "ToolCall", - "EvaluatorType", "ToolOutput", ] + +# Backward-compatibility shim: ``AgentExecution`` was renamed to +# ``WorkloadExecution``. The old name keeps working but emits a +# DeprecationWarning. Remove in uipath 3.0. +_DEPRECATED_NAMES = {"AgentExecution": "WorkloadExecution"} + + +def __getattr__(name: str) -> Any: + new_name = _DEPRECATED_NAMES.get(name) + if new_name is not None: + warnings.warn( + f"{name} is deprecated and will be removed in uipath 3.0; " + f"use {new_name} instead.", + DeprecationWarning, + stacklevel=2, + ) + return globals()[new_name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/packages/uipath/src/uipath/eval/models/models.py b/packages/uipath/src/uipath/eval/models/models.py index 14c130c92..b3c2f1700 100644 --- a/packages/uipath/src/uipath/eval/models/models.py +++ b/packages/uipath/src/uipath/eval/models/models.py @@ -1,6 +1,7 @@ """Models for evaluation framework including execution data and evaluation results.""" import traceback +import warnings from dataclasses import dataclass from enum import Enum, IntEnum from typing import Annotated, Any, Literal, Union @@ -11,14 +12,14 @@ from pydantic_core import core_schema -class AgentExecution(BaseModel): - """Represents the execution data of an agent for evaluation purposes.""" +class WorkloadExecution(BaseModel): + """Represents the execution data of a workload for evaluation purposes.""" model_config = ConfigDict(arbitrary_types_allowed=True) agent_input: dict[str, Any] | None - agent_output: dict[str, Any] | str - agent_trace: list[ReadableSpan] + workload_output: dict[str, Any] | str + workload_trace: list[ReadableSpan] expected_agent_behavior: str | None = None simulation_instructions: str = "" @@ -175,7 +176,7 @@ def from_int(cls, value: int) -> "LegacyEvaluatorType": class TrajectoryEvaluationSpan: """Simplified span representation for trajectory evaluation. - Contains span information needed for evaluating agent execution paths, + Contains span information needed for evaluating workload execution paths, excluding timestamps which are not useful for trajectory analysis. """ @@ -388,3 +389,23 @@ def __init__( def as_dict(self) -> dict[str, Any]: """Get the error information as a dictionary.""" return self.error_info.model_dump() + + +# ── Backward-compatibility shim ────────────────────────────────────────────── +# ``AgentExecution`` was renamed to ``WorkloadExecution`` (unified-evals naming). +# Per the release policy's deprecation requirements, the old name keeps working +# but emits a DeprecationWarning when accessed. Remove this shim in uipath 3.0. +_DEPRECATED_NAMES = {"AgentExecution": "WorkloadExecution"} + + +def __getattr__(name: str) -> Any: + new_name = _DEPRECATED_NAMES.get(name) + if new_name is not None: + warnings.warn( + f"{name} is deprecated and will be removed in uipath 3.0; " + f"use {new_name} instead.", + DeprecationWarning, + stacklevel=2, + ) + return globals()[new_name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/packages/uipath/src/uipath/eval/runtime/_spans.py b/packages/uipath/src/uipath/eval/runtime/_spans.py index 6f492388e..eec742f26 100644 --- a/packages/uipath/src/uipath/eval/runtime/_spans.py +++ b/packages/uipath/src/uipath/eval/runtime/_spans.py @@ -414,7 +414,7 @@ async def configure_evaluation_span( evaluation_run_results: UiPathEvalRunResult object containing evaluation results execution_id: The execution ID for this evaluation input_data: The input data for this evaluation - agent_execution_output: Optional agent execution output for error checking + agent_execution_output: Optional workload execution output for error checking """ # Extract evaluator scores (already normalized to 0-100) evaluator_scores = extract_evaluator_scores(evaluation_run_results) diff --git a/packages/uipath/src/uipath/eval/runtime/runtime.py b/packages/uipath/src/uipath/eval/runtime/runtime.py index 7f7614446..0c4653023 100644 --- a/packages/uipath/src/uipath/eval/runtime/runtime.py +++ b/packages/uipath/src/uipath/eval/runtime/runtime.py @@ -60,7 +60,7 @@ EvaluationItem, EvaluationSet, ) -from ..models.models import AgentExecution, EvalItemResult, EvaluationResultDto +from ..models.models import EvalItemResult, EvaluationResultDto, WorkloadExecution from ._exporters import ( ExecutionLogsExporter, ExecutionSpanExporter, @@ -553,10 +553,10 @@ async def _execute_eval( ) logger.debug( - f"DEBUG: Agent execution result status: {agent_execution_output.result.status}" + f"DEBUG: Workload execution result status: {agent_execution_output.result.status}" ) logger.debug( - f"DEBUG: Agent execution result trigger: {agent_execution_output.result.trigger}" + f"DEBUG: Workload execution result trigger: {agent_execution_output.result.trigger}" ) except Exception as e: @@ -1017,16 +1017,20 @@ async def run_evaluator( else: output_data = execution_output.result.output - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input=eval_item.inputs, - agent_output=output_data, - agent_trace=execution_output.spans, + workload_output=output_data, + workload_trace=execution_output.spans, expected_agent_behavior=eval_item.expected_agent_behavior, ) + # Pass positionally so custom evaluators that still declare the old + # `agent_execution` parameter name keep working (the public keyword + # rename to `workload_execution` is a documented break — see the + # 2.12.0 migration notes). result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, - evaluation_criteria=evaluation_criteria, + workload_execution, + evaluation_criteria, ) # Create "Evaluation output" child span with the result diff --git a/packages/uipath/tests/cli/eval/test_eval_span_utils.py b/packages/uipath/tests/cli/eval/test_eval_span_utils.py index c6b48b0b6..1205a5d82 100644 --- a/packages/uipath/tests/cli/eval/test_eval_span_utils.py +++ b/packages/uipath/tests/cli/eval/test_eval_span_utils.py @@ -460,16 +460,16 @@ async def test_configure_evaluation_span(self): @pytest.mark.asyncio async def test_configure_evaluation_span_with_error(self): - """Test configuring evaluation span when agent execution has error.""" + """Test configuring evaluation span when workload execution has error.""" span = MockSpan() # Mock evaluation run results (empty since agent failed) mock_evaluation_run_results = MagicMock() mock_evaluation_run_results.evaluation_run_results = [] - # Mock agent execution output with error + # Mock workload execution output with error mock_agent_output = MagicMock() - mock_agent_output.result.error = "Agent execution failed" + mock_agent_output.result.error = "Workload execution failed" await configure_evaluation_span( span=span, # type: ignore[arg-type] @@ -487,11 +487,11 @@ async def test_configure_evaluation_span_with_error(self): assert span._status is not None assert span._status.status_code == StatusCode.ERROR assert span._status.description is not None - assert "Agent execution failed" in span._status.description + assert "Workload execution failed" in span._status.description @pytest.mark.asyncio async def test_configure_evaluation_span_without_agent_output(self): - """Test configuring evaluation span without agent execution output.""" + """Test configuring evaluation span without workload execution output.""" span = MockSpan() mock_result = MagicMock() diff --git a/packages/uipath/tests/cli/eval/test_eval_telemetry.py b/packages/uipath/tests/cli/eval/test_eval_telemetry.py index 49a71bc2f..4a9dcf64d 100644 --- a/packages/uipath/tests/cli/eval/test_eval_telemetry.py +++ b/packages/uipath/tests/cli/eval/test_eval_telemetry.py @@ -313,7 +313,7 @@ async def test_on_eval_run_updated_with_scores(self, mock_track_event): async def test_on_eval_run_updated_agent_execution_time_converted_to_ms( self, mock_track_event ): - """Test that agent execution time is converted to milliseconds.""" + """Test that workload execution time is converted to milliseconds.""" subscriber = EvalTelemetrySubscriber() event = self._create_eval_run_updated_event(agent_execution_time=2.5) diff --git a/packages/uipath/tests/cli/evaluators/test_json_similarity_evaluator.py b/packages/uipath/tests/cli/evaluators/test_json_similarity_evaluator.py index 0b74ae07c..1ce823f22 100644 --- a/packages/uipath/tests/cli/evaluators/test_json_similarity_evaluator.py +++ b/packages/uipath/tests/cli/evaluators/test_json_similarity_evaluator.py @@ -11,9 +11,9 @@ from uipath.eval.evaluators import LegacyJsonSimilarityEvaluator from uipath.eval.evaluators.base_legacy_evaluator import LegacyEvaluationCriteria from uipath.eval.models.models import ( - AgentExecution, LegacyEvaluatorCategory, LegacyEvaluatorType, + WorkloadExecution, ) @@ -65,10 +65,10 @@ async def test_json_similarity_exact_score_1(self) -> None: """ result = await evaluator.evaluate( - AgentExecution( + WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output=json.loads(actual_json), + workload_trace=[], + workload_output=json.loads(actual_json), ), evaluation_criteria=LegacyEvaluationCriteria( expected_output=json.loads(expected_json), @@ -101,10 +101,10 @@ async def test_json_similarity_exact_score_2(self) -> None: """ result = await evaluator.evaluate( - AgentExecution( + WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output=json.loads(actual_json), + workload_trace=[], + workload_output=json.loads(actual_json), ), evaluation_criteria=LegacyEvaluationCriteria( expected_output=json.loads(expected_json), @@ -135,10 +135,10 @@ async def test_json_similarity_exact_score_3(self) -> None: """ result = await evaluator.evaluate( - AgentExecution( + WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output=json.loads(actual_json), + workload_trace=[], + workload_output=json.loads(actual_json), ), evaluation_criteria=LegacyEvaluationCriteria( expected_output=json.loads(expected_json), @@ -229,10 +229,10 @@ async def test_json_similarity_exact_score_4(self) -> None: """ result = await evaluator.evaluate( - AgentExecution( + WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output=json.loads(actual_json), + workload_trace=[], + workload_output=json.loads(actual_json), ), evaluation_criteria=LegacyEvaluationCriteria( expected_output=json.loads(expected_json), diff --git a/packages/uipath/tests/cli/evaluators/test_legacy_context_precision_evaluator.py b/packages/uipath/tests/cli/evaluators/test_legacy_context_precision_evaluator.py index 19723eb25..cd1c28738 100644 --- a/packages/uipath/tests/cli/evaluators/test_legacy_context_precision_evaluator.py +++ b/packages/uipath/tests/cli/evaluators/test_legacy_context_precision_evaluator.py @@ -13,9 +13,9 @@ from uipath.eval.evaluators import LegacyContextPrecisionEvaluator from uipath.eval.evaluators.base_legacy_evaluator import LegacyEvaluationCriteria from uipath.eval.models.models import ( - AgentExecution, LegacyEvaluatorCategory, LegacyEvaluatorType, + WorkloadExecution, ) @@ -205,14 +205,14 @@ async def test_evaluation_with_no_context_groundings( evaluator = evaluator_with_mocked_llm # Create empty agent execution (no spans) - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output="", + workload_trace=[], + workload_output="", ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output="", expected_agent_behavior="", diff --git a/packages/uipath/tests/cli/evaluators/test_legacy_csv_exact_match_evaluator.py b/packages/uipath/tests/cli/evaluators/test_legacy_csv_exact_match_evaluator.py index 1fd2f4f9f..45cdfaefe 100644 --- a/packages/uipath/tests/cli/evaluators/test_legacy_csv_exact_match_evaluator.py +++ b/packages/uipath/tests/cli/evaluators/test_legacy_csv_exact_match_evaluator.py @@ -19,9 +19,9 @@ ) from uipath.eval.evaluators.output_evaluator import LineByLineEvaluationDetails from uipath.eval.models.models import ( - AgentExecution, LegacyEvaluatorCategory, LegacyEvaluatorType, + WorkloadExecution, ) @@ -64,14 +64,14 @@ class TestLegacyCSVExactMatchEvaluator: async def test_single_column_match(self, evaluator_single_column) -> None: """Test exact match with single column.""" csv_content = "Name,Age,City\nJohn,25,Paris" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output=csv_content, + workload_trace=[], + workload_output=csv_content, ) result = await evaluator_single_column.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"content": csv_content}, expected_agent_behavior="", @@ -84,14 +84,14 @@ async def test_single_column_match(self, evaluator_single_column) -> None: async def test_multiple_columns_match(self, evaluator_multiple_columns) -> None: """Test exact match with multiple columns.""" csv_content = "Name,Age,City\nJohn,25,Paris" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output=csv_content, + workload_trace=[], + workload_output=csv_content, ) result = await evaluator_multiple_columns.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"content": csv_content}, expected_agent_behavior="", @@ -106,14 +106,14 @@ async def test_column_value_differs(self, evaluator_single_column) -> None: actual_csv = "Name,Age,City\nJohn,25,Paris" expected_csv = "Name,Age,City\nJane,25,Paris" # Different name - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output=actual_csv, + workload_trace=[], + workload_output=actual_csv, ) result = await evaluator_single_column.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"content": expected_csv}, expected_agent_behavior="", @@ -131,14 +131,14 @@ async def test_case_insensitive_column_names(self) -> None: ) csv_content = "name,AGE,CiTy\nJohn,25,Paris" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output=csv_content, + workload_trace=[], + workload_output=csv_content, ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"content": csv_content}, expected_agent_behavior="", @@ -153,14 +153,14 @@ async def test_json_element_with_content_property( ) -> None: """Test handling dict with 'content' property.""" csv_content = "Name,Age\nJohn,25" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output={"content": csv_content}, + workload_trace=[], + workload_output={"content": csv_content}, ) result = await evaluator_single_column.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"content": csv_content}, expected_agent_behavior="", @@ -193,14 +193,14 @@ async def test_column_not_found_returns_error( **_make_base_params(target_sub_output_key="NonExistentColumn") ) - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output=csv_content, + workload_trace=[], + workload_output=csv_content, ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"content": csv_content}, expected_agent_behavior="", @@ -214,14 +214,14 @@ async def test_column_not_found_returns_error( @pytest.mark.asyncio async def test_empty_csv_returns_error(self, evaluator_single_column) -> None: """Test that empty CSV returns error result.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output="", + workload_trace=[], + workload_output="", ) result = await evaluator_single_column.validate_and_evaluate_criteria( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"content": "Name,Age\nJohn,25"}, expected_agent_behavior="", @@ -244,14 +244,14 @@ async def test_complex_csv_with_quotes_and_commas( csv_content = ( 'Name,Description,Status\n"John, Jr.","Software Engineer, Senior","Active"' ) - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output=csv_content, + workload_trace=[], + workload_output=csv_content, ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"content": csv_content}, expected_agent_behavior="", @@ -269,14 +269,14 @@ async def test_empty_values(self) -> None: ) csv_content = "Name,Age,City\nJohn,,Paris" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output=csv_content, + workload_trace=[], + workload_output=csv_content, ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"content": csv_content}, expected_agent_behavior="", @@ -289,14 +289,14 @@ async def test_empty_values(self) -> None: async def test_no_data_rows_returns_error(self, evaluator_single_column) -> None: """Test that CSV with headers but no data rows returns error result.""" csv_content = "Name,Age" # Headers only, no data - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output=csv_content, + workload_trace=[], + workload_output=csv_content, ) result = await evaluator_single_column.validate_and_evaluate_criteria( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"content": csv_content}, expected_agent_behavior="", @@ -315,14 +315,14 @@ async def test_whitespace_only_target_columns_returns_error(self) -> None: ) csv_content = "Name,Age\nJohn,25" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output=csv_content, + workload_trace=[], + workload_output=csv_content, ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"content": csv_content}, expected_agent_behavior="", @@ -339,15 +339,15 @@ async def test_expected_output_without_content_property_returns_error( ) -> None: """Test that expected output without 'content' property returns error result.""" csv_content = "Name,Age\nJohn,25" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output=csv_content, + workload_trace=[], + workload_output=csv_content, ) # Expected output as empty string result = await evaluator_single_column.validate_and_evaluate_criteria( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output="", expected_agent_behavior="", @@ -371,14 +371,14 @@ async def test_actual_missing_column_in_comparison(self) -> None: actual_csv = "Name\nJohn" # Missing Age column expected_csv = "Name,Age\nJohn,25" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output=actual_csv, + workload_trace=[], + workload_output=actual_csv, ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"content": expected_csv}, expected_agent_behavior="", @@ -395,14 +395,14 @@ async def test_whitespace_trimming_in_values(self, evaluator_single_column) -> N actual_csv = "Name,Age\n John ,25" expected_csv = "Name,Age\nJohn,25" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output=actual_csv, + workload_trace=[], + workload_output=actual_csv, ) result = await evaluator_single_column.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"content": expected_csv}, expected_agent_behavior="", @@ -419,14 +419,14 @@ async def test_case_sensitive_value_comparison( actual_csv = "Name,Age\nJohn,25" expected_csv = "Name,Age\njohn,25" # Different case - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output=actual_csv, + workload_trace=[], + workload_output=actual_csv, ) result = await evaluator_single_column.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"content": expected_csv}, expected_agent_behavior="", @@ -441,14 +441,14 @@ async def test_extra_columns_are_ignored(self, evaluator_single_column) -> None: actual_csv = "Name,Age,City,Country\nJohn,25,Paris,France" expected_csv = "Name,Age,City\nJohn,30,London" # Different Age and City, but we only check Name - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output=actual_csv, + workload_trace=[], + workload_output=actual_csv, ) result = await evaluator_single_column.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"content": expected_csv}, expected_agent_behavior="", @@ -472,14 +472,14 @@ async def test_line_by_line_all_match(): ) csv_content = "Name,Age\nJohn,25\nJane,30\nBob,35" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"input": "test"}, - agent_output=csv_content, - agent_trace=[], + workload_output=csv_content, + workload_trace=[], ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"content": csv_content}, expected_agent_behavior="", @@ -509,14 +509,14 @@ async def test_line_by_line_partial_match(): actual_csv = "Name,Age\nJohn,25\nDifferent,30\nBob,35" expected_csv = "Name,Age\nJohn,25\nJane,30\nBob,35" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"input": "test"}, - agent_output=actual_csv, - agent_trace=[], + workload_output=actual_csv, + workload_trace=[], ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"content": expected_csv}, expected_agent_behavior="", @@ -548,14 +548,14 @@ async def test_line_by_line_multiple_columns(): ) csv_content = "Name,Age,City\nJohn,25,Paris\nJane,30,London" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"input": "test"}, - agent_output=csv_content, - agent_trace=[], + workload_output=csv_content, + workload_trace=[], ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"content": csv_content}, expected_agent_behavior="", @@ -582,14 +582,14 @@ async def test_line_by_line_unequal_line_counts(): actual_csv = "Name,Age\nJohn,25\nJane,30" # 2 data rows expected_csv = "Name,Age\nJohn,25\nJane,30\nBob,35" # 3 data rows - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"input": "test"}, - agent_output=actual_csv, - agent_trace=[], + workload_output=actual_csv, + workload_trace=[], ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"content": expected_csv}, expected_agent_behavior="", @@ -621,15 +621,15 @@ async def test_line_by_line_with_job_attachment(): "uipath.eval.evaluators.base_legacy_evaluator.download_attachment_as_string", return_value=csv_content, ): - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"input": "test"}, # Simulate job attachment URI - agent_output="urn:uipath:cas:file:orchestrator:12345678-1234-1234-1234-123456789abc", - agent_trace=[], + workload_output="urn:uipath:cas:file:orchestrator:12345678-1234-1234-1234-123456789abc", + workload_trace=[], ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"content": csv_content}, expected_agent_behavior="", diff --git a/packages/uipath/tests/cli/evaluators/test_legacy_exact_match_evaluator.py b/packages/uipath/tests/cli/evaluators/test_legacy_exact_match_evaluator.py index b5b5bfd4f..cb1d5d9df 100644 --- a/packages/uipath/tests/cli/evaluators/test_legacy_exact_match_evaluator.py +++ b/packages/uipath/tests/cli/evaluators/test_legacy_exact_match_evaluator.py @@ -13,9 +13,9 @@ from uipath.eval.evaluators.base_legacy_evaluator import LegacyEvaluationCriteria from uipath.eval.evaluators.output_evaluator import LineByLineEvaluationDetails from uipath.eval.models.models import ( - AgentExecution, LegacyEvaluatorCategory, LegacyEvaluatorType, + WorkloadExecution, ) @@ -55,14 +55,14 @@ class TestLegacyExactMatchEvaluator: @pytest.mark.asyncio async def test_exact_match_same_strings(self, evaluator) -> None: """Test exact match with identical string outputs.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output="Hello World", + workload_trace=[], + workload_output="Hello World", ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output="Hello World", expected_agent_behavior="", @@ -75,14 +75,14 @@ async def test_exact_match_same_strings(self, evaluator) -> None: @pytest.mark.asyncio async def test_exact_match_different_strings(self, evaluator) -> None: """Test exact match with different string outputs.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output="Hello World", + workload_trace=[], + workload_output="Hello World", ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output="Goodbye World", expected_agent_behavior="", @@ -95,14 +95,14 @@ async def test_exact_match_different_strings(self, evaluator) -> None: async def test_exact_match_identical_dicts(self, evaluator) -> None: """Test exact match with identical dictionaries.""" output = {"name": "John", "age": 30} - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output=output, + workload_trace=[], + workload_output=output, ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output=output, expected_agent_behavior="", @@ -114,14 +114,14 @@ async def test_exact_match_identical_dicts(self, evaluator) -> None: @pytest.mark.asyncio async def test_exact_match_different_dicts(self, evaluator) -> None: """Test exact match with different dictionaries.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output={"name": "John", "age": 30}, + workload_trace=[], + workload_output={"name": "John", "age": 30}, ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"name": "Jane", "age": 25}, expected_agent_behavior="", @@ -133,14 +133,14 @@ async def test_exact_match_different_dicts(self, evaluator) -> None: @pytest.mark.asyncio async def test_exact_match_dict_key_order_doesnt_matter(self, evaluator) -> None: """Test that canonical JSON normalization handles key order.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output={"a": 1, "b": 2}, + workload_trace=[], + workload_output={"a": 1, "b": 2}, ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"b": 2, "a": 1}, expected_agent_behavior="", @@ -154,14 +154,14 @@ async def test_exact_match_number_normalization_int_to_float( self, evaluator ) -> None: """Test that integers are normalized to floats for comparison.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output={"value": 42}, + workload_trace=[], + workload_output={"value": 42}, ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"value": 42.0}, expected_agent_behavior="", @@ -173,14 +173,14 @@ async def test_exact_match_number_normalization_int_to_float( @pytest.mark.asyncio async def test_exact_match_number_normalization_in_list(self, evaluator) -> None: """Test number normalization in lists.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output={"values": [1, 2, 3]}, + workload_trace=[], + workload_output={"values": [1, 2, 3]}, ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"values": [1.0, 2.0, 3.0]}, expected_agent_behavior="", @@ -192,14 +192,14 @@ async def test_exact_match_number_normalization_in_list(self, evaluator) -> None @pytest.mark.asyncio async def test_exact_match_booleans_preserved(self, evaluator) -> None: """Test that booleans are not converted to numbers.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output={"active": True, "deleted": False}, + workload_trace=[], + workload_output={"active": True, "deleted": False}, ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"active": True, "deleted": False}, expected_agent_behavior="", @@ -220,14 +220,14 @@ async def test_exact_match_nested_structures(self, evaluator) -> None: "metadata": {"version": 1.0}, } - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output=output, + workload_trace=[], + workload_output=output, ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output=output, expected_agent_behavior="", @@ -241,14 +241,14 @@ async def test_exact_match_with_target_key_both_have_key( self, evaluator_with_target_key ) -> None: """Test target_output_key extraction when both outputs have the key.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output={"result": {"status": "success"}, "other": "ignore"}, + workload_trace=[], + workload_output={"result": {"status": "success"}, "other": "ignore"}, ) result = await evaluator_with_target_key.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"result": {"status": "success"}, "other": "different"}, expected_agent_behavior="", @@ -263,14 +263,14 @@ async def test_exact_match_with_target_key_missing_in_both( self, evaluator_with_target_key ) -> None: """Test target_output_key when key is missing in both outputs.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output={"other": "data"}, + workload_trace=[], + workload_output={"other": "data"}, ) result = await evaluator_with_target_key.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"other": "different"}, expected_agent_behavior="", @@ -285,14 +285,14 @@ async def test_exact_match_with_target_key_missing_in_actual( self, evaluator_with_target_key ) -> None: """Test target_output_key when key is missing in actual output.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output={"other": "data"}, + workload_trace=[], + workload_output={"other": "data"}, ) result = await evaluator_with_target_key.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"result": {"status": "success"}}, expected_agent_behavior="", @@ -307,14 +307,14 @@ async def test_exact_match_with_target_key_missing_in_expected( self, evaluator_with_target_key ) -> None: """Test target_output_key when key is missing in expected output.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output={"result": {"status": "success"}}, + workload_trace=[], + workload_output={"result": {"status": "success"}}, ) result = await evaluator_with_target_key.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"other": "data"}, expected_agent_behavior="", @@ -327,14 +327,14 @@ async def test_exact_match_with_target_key_missing_in_expected( @pytest.mark.asyncio async def test_exact_match_with_wildcard_target_key(self, evaluator) -> None: """Test that wildcard target_output_key compares full outputs.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output={"data": "value", "extra": "field"}, + workload_trace=[], + workload_output={"data": "value", "extra": "field"}, ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"data": "value", "extra": "field"}, expected_agent_behavior="", @@ -348,14 +348,14 @@ async def test_exact_match_with_target_key_non_dict_inputs( self, evaluator_with_target_key ) -> None: """Test target_output_key with non-dict inputs (should compare as-is).""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output="string_value", + workload_trace=[], + workload_output="string_value", ) result = await evaluator_with_target_key.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output="other_string", expected_agent_behavior="", @@ -367,14 +367,14 @@ async def test_exact_match_with_target_key_non_dict_inputs( @pytest.mark.asyncio async def test_exact_match_null_values(self, evaluator) -> None: """Test exact match with None values.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output={"value": None}, + workload_trace=[], + workload_output={"value": None}, ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"value": None}, expected_agent_behavior="", @@ -386,14 +386,14 @@ async def test_exact_match_null_values(self, evaluator) -> None: @pytest.mark.asyncio async def test_exact_match_empty_dict(self, evaluator) -> None: """Test exact match with empty dictionaries.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output={}, + workload_trace=[], + workload_output={}, ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={}, expected_agent_behavior="", @@ -405,14 +405,14 @@ async def test_exact_match_empty_dict(self, evaluator) -> None: @pytest.mark.asyncio async def test_exact_match_empty_string(self, evaluator) -> None: """Test exact match with empty strings.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output="", + workload_trace=[], + workload_output="", ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output="", expected_agent_behavior="", @@ -425,14 +425,14 @@ async def test_exact_match_empty_string(self, evaluator) -> None: async def test_exact_match_unicode_characters(self, evaluator) -> None: """Test exact match with unicode characters.""" output = {"greeting": "你好世界", "emoji": "🎉"} - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output=output, + workload_trace=[], + workload_output=output, ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output=output, expected_agent_behavior="", @@ -444,14 +444,14 @@ async def test_exact_match_unicode_characters(self, evaluator) -> None: @pytest.mark.asyncio async def test_exact_match_whitespace_matters(self, evaluator) -> None: """Test that whitespace differences are detected.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output="Hello World", + workload_trace=[], + workload_output="Hello World", ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output="Hello World", expected_agent_behavior="", @@ -463,14 +463,14 @@ async def test_exact_match_whitespace_matters(self, evaluator) -> None: @pytest.mark.asyncio async def test_exact_match_case_sensitivity(self, evaluator) -> None: """Test that string comparison is case sensitive.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output="Hello", + workload_trace=[], + workload_output="Hello", ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output="hello", expected_agent_behavior="", @@ -482,14 +482,14 @@ async def test_exact_match_case_sensitivity(self, evaluator) -> None: @pytest.mark.asyncio async def test_exact_match_large_numbers(self, evaluator) -> None: """Test exact match with large numbers.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output={"value": 999999999999999}, + workload_trace=[], + workload_output={"value": 999999999999999}, ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"value": 999999999999999.0}, expected_agent_behavior="", @@ -501,14 +501,14 @@ async def test_exact_match_large_numbers(self, evaluator) -> None: @pytest.mark.asyncio async def test_exact_match_floating_point_precision(self, evaluator) -> None: """Test exact match with floating point numbers.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output={"pi": 3.14159}, + workload_trace=[], + workload_output={"pi": 3.14159}, ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"pi": 3.14159}, expected_agent_behavior="", @@ -520,14 +520,14 @@ async def test_exact_match_floating_point_precision(self, evaluator) -> None: @pytest.mark.asyncio async def test_exact_match_float_vs_int_zero(self, evaluator) -> None: """Test that 0 and 0.0 are considered equal.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output={"value": 0}, + workload_trace=[], + workload_output={"value": 0}, ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"value": 0.0}, expected_agent_behavior="", @@ -541,14 +541,14 @@ async def test_exact_match_with_target_key_different_values( self, evaluator_with_target_key ) -> None: """Test target_output_key with different values in target key.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output={"result": {"status": "success"}, "other": "ignore"}, + workload_trace=[], + workload_output={"result": {"status": "success"}, "other": "ignore"}, ) result = await evaluator_with_target_key.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"result": {"status": "failed"}, "other": "ignore"}, expected_agent_behavior="", @@ -561,10 +561,10 @@ async def test_exact_match_with_target_key_different_values( async def test_canonical_json_normalization(self, evaluator) -> None: """Test that canonical JSON normalization works correctly.""" # Create complex nested structure with mixed types - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output={ + workload_trace=[], + workload_output={ "z_key": 1, "a_key": [3, 2, 1], "m_key": {"nested": 42}, @@ -572,7 +572,7 @@ async def test_canonical_json_normalization(self, evaluator) -> None: ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={ "a_key": [3, 2, 1], @@ -597,14 +597,14 @@ async def test_line_by_line_all_match(): lineDelimiter="\n", ) - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"input": "test"}, - agent_output="Line 1\nLine 2\nLine 3", - agent_trace=[], + workload_output="Line 1\nLine 2\nLine 3", + workload_trace=[], ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output="Line 1\nLine 2\nLine 3", expected_agent_behavior="", @@ -631,14 +631,14 @@ async def test_line_by_line_partial_match(): lineDelimiter="\n", ) - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"input": "test"}, - agent_output="Line 1\nDifferent\nLine 3", - agent_trace=[], + workload_output="Line 1\nDifferent\nLine 3", + workload_trace=[], ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output="Line 1\nLine 2\nLine 3", expected_agent_behavior="", @@ -669,14 +669,14 @@ async def test_line_by_line_with_target_output_key(): lineDelimiter="\n", ) - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"input": "test"}, - agent_output={"result": "Line 1\nLine 2\nLine 3"}, - agent_trace=[], + workload_output={"result": "Line 1\nLine 2\nLine 3"}, + workload_trace=[], ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output={"result": "Line 1\nLine 2\nLine 3"}, expected_agent_behavior="", @@ -701,14 +701,14 @@ async def test_line_by_line_custom_delimiter(): lineDelimiter="|", ) - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"input": "test"}, - agent_output="Item1|Item2|Item3", - agent_trace=[], + workload_output="Item1|Item2|Item3", + workload_trace=[], ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output="Item1|Item2|Item3", expected_agent_behavior="", @@ -734,14 +734,14 @@ async def test_line_by_line_unequal_line_counts(): lineDelimiter="\n", ) - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"input": "test"}, - agent_output="Line 1\nLine 2", - agent_trace=[], + workload_output="Line 1\nLine 2", + workload_trace=[], ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output="Line 1\nLine 2\nLine 3", expected_agent_behavior="", diff --git a/packages/uipath/tests/cli/evaluators/test_legacy_faithfulness_evaluator.py b/packages/uipath/tests/cli/evaluators/test_legacy_faithfulness_evaluator.py index 68d8b407b..4e609575b 100644 --- a/packages/uipath/tests/cli/evaluators/test_legacy_faithfulness_evaluator.py +++ b/packages/uipath/tests/cli/evaluators/test_legacy_faithfulness_evaluator.py @@ -13,9 +13,9 @@ from uipath.eval.evaluators import LegacyFaithfulnessEvaluator from uipath.eval.evaluators.base_legacy_evaluator import LegacyEvaluationCriteria from uipath.eval.models.models import ( - AgentExecution, LegacyEvaluatorCategory, LegacyEvaluatorType, + WorkloadExecution, ) @@ -142,7 +142,7 @@ async def test_select_verifiable_sentences(self, evaluator_with_mocked_llm) -> N """Test Stage 1: Selection of verifiable sentences.""" evaluator = evaluator_with_mocked_llm - agent_output = ( + workload_output = ( "The capital of France is Paris. Do you agree? This is important." ) @@ -153,7 +153,7 @@ async def test_select_verifiable_sentences(self, evaluator_with_mocked_llm) -> N ) as mock_llm: mock_llm.return_value = mock_response - sentences = await evaluator._select_verifiable_sentences(agent_output) + sentences = await evaluator._select_verifiable_sentences(workload_output) assert len(sentences) == 1 assert "capital of France" in sentences[0] @@ -355,14 +355,14 @@ async def test_full_evaluation_with_no_agent_output( """Test evaluation when no agent output is provided.""" evaluator = evaluator_with_mocked_llm - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output="", + workload_trace=[], + workload_output="", ) result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output="", expected_agent_behavior="", @@ -370,7 +370,7 @@ async def test_full_evaluation_with_no_agent_output( ) assert result.score == 0.0 - assert "no agent output" in result.details.lower() + assert "no workload output" in result.details.lower() @pytest.mark.asyncio async def test_full_evaluation_with_no_context_sources( @@ -388,15 +388,15 @@ class NoOutputSpan: } ) - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output="The sky is blue.", + workload_trace=[], + workload_output="The sky is blue.", ) with patch.object(evaluator, "_extract_context_sources", return_value=[]): result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output="The sky is blue.", expected_agent_behavior="", @@ -413,10 +413,10 @@ async def test_full_evaluation_with_no_verifiable_claims( """Test evaluation when no verifiable claims are found.""" evaluator = evaluator_with_mocked_llm - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output="Just a greeting.", + workload_trace=[], + workload_output="Just a greeting.", ) with ( @@ -432,7 +432,7 @@ async def test_full_evaluation_with_no_verifiable_claims( mock_claims.return_value = [] result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output="Just a greeting.", expected_agent_behavior="", @@ -449,10 +449,10 @@ async def test_full_evaluation_with_grounded_claims( """Test full evaluation flow with grounded claims.""" evaluator = evaluator_with_mocked_llm - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output="Paris is in France.", + workload_trace=[], + workload_output="Paris is in France.", ) # Mock the extraction and evaluation steps @@ -488,7 +488,7 @@ async def test_full_evaluation_with_grounded_claims( ] result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output="Paris is in France.", expected_agent_behavior="", @@ -506,10 +506,10 @@ async def test_full_evaluation_with_mixed_claims( """Test full evaluation with both grounded and ungrounded claims.""" evaluator = evaluator_with_mocked_llm - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_trace=[], - agent_output="Paris is in France. The sky is green.", + workload_trace=[], + workload_output="Paris is in France. The sky is green.", ) # Mock the extraction and evaluation steps @@ -553,7 +553,7 @@ async def test_full_evaluation_with_mixed_claims( ] result = await evaluator.evaluate( - agent_execution, + workload_execution, evaluation_criteria=LegacyEvaluationCriteria( expected_output="Paris is in France. The sky is green.", expected_agent_behavior="", diff --git a/packages/uipath/tests/evaluators/test_documentation_examples.py b/packages/uipath/tests/evaluators/test_documentation_examples.py index 559df3a01..f9265d267 100644 --- a/packages/uipath/tests/evaluators/test_documentation_examples.py +++ b/packages/uipath/tests/evaluators/test_documentation_examples.py @@ -1,7 +1,7 @@ """Tests for examples in the eval documentation. This module ensures all code examples in the documentation actually work by -testing them with proper agent execution data. For LLM judge examples, we use +testing them with proper workload execution data. For LLM judge examples, we use mocked completions to avoid API calls. """ @@ -30,7 +30,7 @@ from uipath.eval.evaluators.tool_call_order_evaluator import ( ToolCallOrderEvaluatorJustification, ) -from uipath.eval.models import AgentExecution +from uipath.eval.models import WorkloadExecution class TestIndexExamples: @@ -39,11 +39,11 @@ class TestIndexExamples: @pytest.mark.asyncio async def test_getting_started_example(self) -> None: """Test the getting started example from index.md.""" - # Sample agent execution (this is what the docs were missing!) - agent_execution = AgentExecution( + # Sample workload execution (this is what the docs were missing!) + workload_execution = WorkloadExecution( agent_input={"query": "Greet the world"}, - agent_output={"result": "hello, world!"}, - agent_trace=[], + workload_output={"result": "hello, world!"}, + workload_trace=[], ) # Create evaluator @@ -60,7 +60,7 @@ async def test_getting_started_example(self) -> None: # Evaluate result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"expected_output": {"result": "Hello, World!"}}, ) @@ -85,16 +85,16 @@ async def test_basic_usage(self) -> None: ) ) - # agent_output must be a dict - agent_execution = AgentExecution( + # workload_output must be a dict + workload_execution = WorkloadExecution( agent_input={"query": "What is the capital of France?"}, - agent_output={"response": "The capital of France is Paris."}, - agent_trace=[], + workload_output={"response": "The capital of France is Paris."}, + workload_trace=[], ) # Evaluate - searches in the "response" field value result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"search_text": "Paris"}, ) @@ -114,15 +114,15 @@ async def test_case_sensitive_search(self) -> None: ) ) - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_output={"message": "Hello World"}, - agent_trace=[], + workload_output={"message": "Hello World"}, + workload_trace=[], ) # This will fail because of case mismatch result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"search_text": "hello"}, ) @@ -142,15 +142,15 @@ async def test_negated_search(self) -> None: ) ) - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_output={"status": "Success: Operation completed"}, - agent_trace=[], + workload_output={"status": "Success: Operation completed"}, + workload_trace=[], ) # Passes because "error" is NOT found result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"search_text": "error"}, ) @@ -169,18 +169,18 @@ async def test_target_specific_output_field(self) -> None: ) ) - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_output={ + workload_output={ "status": "success", "message": "User profile updated successfully", }, - agent_trace=[], + workload_trace=[], ) # Only searches within the "message" field result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"search_text": "updated"}, ) @@ -193,11 +193,11 @@ class TestExactMatchExamples: @pytest.mark.asyncio async def test_basic_usage(self) -> None: """Test basic usage example.""" - # agent_output must be a dict - agent_execution = AgentExecution( + # workload_output must be a dict + workload_execution = WorkloadExecution( agent_input={"query": "What is 2+2?"}, - agent_output={"result": "4"}, - agent_trace=[], + workload_output={"result": "4"}, + workload_trace=[], ) # Create evaluator - extracts "result" field for comparison @@ -214,7 +214,7 @@ async def test_basic_usage(self) -> None: # Evaluate - compares just the "result" field value result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"expected_output": {"result": "4"}}, ) @@ -223,10 +223,10 @@ async def test_basic_usage(self) -> None: @pytest.mark.asyncio async def test_case_sensitive_matching(self) -> None: """Test case-sensitive matching example.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_output={"status": "SUCCESS"}, - agent_trace=[], + workload_output={"status": "SUCCESS"}, + workload_trace=[], ) evaluator = TypeAdapter(ExactMatchEvaluator).validate_python( @@ -242,7 +242,7 @@ async def test_case_sensitive_matching(self) -> None: # Fails due to case mismatch result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"expected_output": {"status": "success"}}, ) @@ -250,7 +250,7 @@ async def test_case_sensitive_matching(self) -> None: # This would pass result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"expected_output": {"status": "SUCCESS"}}, ) @@ -259,10 +259,10 @@ async def test_case_sensitive_matching(self) -> None: @pytest.mark.asyncio async def test_matching_structured_outputs(self) -> None: """Test matching structured outputs example.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_output={"status": "success", "code": 200}, - agent_trace=[], + workload_output={"status": "success", "code": 200}, + workload_trace=[], ) evaluator = TypeAdapter(ExactMatchEvaluator).validate_python( @@ -277,7 +277,7 @@ async def test_matching_structured_outputs(self) -> None: # Entire dict structure must match result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"expected_output": {"status": "success", "code": 200}}, ) @@ -286,10 +286,10 @@ async def test_matching_structured_outputs(self) -> None: @pytest.mark.asyncio async def test_negated_mode(self) -> None: """Test negated mode example.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_output={"result": "error"}, - agent_trace=[], + workload_output={"result": "error"}, + workload_trace=[], ) evaluator = TypeAdapter(ExactMatchEvaluator).validate_python( @@ -305,7 +305,7 @@ async def test_negated_mode(self) -> None: # Passes because outputs do NOT match result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"expected_output": {"result": "success"}}, ) @@ -314,10 +314,10 @@ async def test_negated_mode(self) -> None: @pytest.mark.asyncio async def test_using_default_criteria(self) -> None: """Test using default criteria example.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_output={"status": "OK"}, - agent_trace=[], + workload_output={"status": "OK"}, + workload_trace=[], ) evaluator = TypeAdapter(ExactMatchEvaluator).validate_python( @@ -335,7 +335,7 @@ async def test_using_default_criteria(self) -> None: # Use default criteria result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, evaluation_criteria=None + workload_execution=workload_execution, evaluation_criteria=None ) assert result.score == 1.0 @@ -347,14 +347,14 @@ async def test_list_target_output_key(self) -> None: Mirrors the multi-output-agent sample (list-keys-exact-match evaluator). """ # Agent returns a rich nested output; we only care about two summary fields. - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"customer_name": "John Doe", "items": []}, - agent_output={ + workload_output={ "order_id": "ORD-001", "summary": {"status": "completed", "total": 44.97, "item_count": 3}, "tags": ["priority", "express"], }, - agent_trace=[], + workload_trace=[], ) evaluator = TypeAdapter(ExactMatchEvaluator).validate_python( @@ -370,7 +370,7 @@ async def test_list_target_output_key(self) -> None: # Both keys match → score 1.0 result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": {"summary": {"status": "completed", "total": 44.97}} }, @@ -379,7 +379,7 @@ async def test_list_target_output_key(self) -> None: # One key differs → score 0.0 result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": {"summary": {"status": "completed", "total": 999.0}} }, @@ -393,10 +393,10 @@ class TestJsonSimilarityExamples: @pytest.mark.asyncio async def test_basic_json_comparison(self) -> None: """Test basic JSON comparison example.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_output={"name": "John Doe", "age": 30, "city": "New York"}, - agent_trace=[], + workload_output={"name": "John Doe", "age": 30, "city": "New York"}, + workload_trace=[], ) evaluator = TypeAdapter(JsonSimilarityEvaluator).validate_python( @@ -410,7 +410,7 @@ async def test_basic_json_comparison(self) -> None: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": {"name": "John Doe", "age": 30, "city": "New York"} }, @@ -425,10 +425,10 @@ async def test_basic_json_comparison(self) -> None: @pytest.mark.asyncio async def test_numeric_tolerance(self) -> None: """Test numeric tolerance example.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_output={"temperature": 20.5, "humidity": 65}, - agent_trace=[], + workload_output={"temperature": 20.5, "humidity": 65}, + workload_trace=[], ) evaluator = TypeAdapter(JsonSimilarityEvaluator).validate_python( @@ -440,7 +440,7 @@ async def test_numeric_tolerance(self) -> None: # Slightly different numbers result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": {"temperature": 20.3, "humidity": 65} }, @@ -452,10 +452,10 @@ async def test_numeric_tolerance(self) -> None: @pytest.mark.asyncio async def test_string_similarity(self) -> None: """Test string similarity example.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_output={"status": "completed successfully"}, - agent_trace=[], + workload_output={"status": "completed successfully"}, + workload_trace=[], ) evaluator = TypeAdapter(JsonSimilarityEvaluator).validate_python( @@ -467,7 +467,7 @@ async def test_string_similarity(self) -> None: # Similar but not exact string result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": {"status": "completed sucessfully"} # typo }, @@ -480,13 +480,13 @@ async def test_string_similarity(self) -> None: @pytest.mark.asyncio async def test_nested_structures(self) -> None: """Test nested structures example.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_output={ + workload_output={ "user": {"name": "Alice", "profile": {"age": 25, "location": "Paris"}}, "status": "active", }, - agent_trace=[], + workload_trace=[], ) evaluator = TypeAdapter(JsonSimilarityEvaluator).validate_python( @@ -497,7 +497,7 @@ async def test_nested_structures(self) -> None: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": { "user": { @@ -514,10 +514,10 @@ async def test_nested_structures(self) -> None: @pytest.mark.asyncio async def test_array_comparison(self) -> None: """Test array comparison example.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_output={"items": ["apple", "banana", "orange"]}, - agent_trace=[], + workload_output={"items": ["apple", "banana", "orange"]}, + workload_trace=[], ) evaluator = TypeAdapter(JsonSimilarityEvaluator).validate_python( @@ -529,7 +529,7 @@ async def test_array_comparison(self) -> None: # Partial match (2 out of 3 correct) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": {"items": ["apple", "banana", "grape"]} }, @@ -542,14 +542,14 @@ async def test_array_comparison(self) -> None: @pytest.mark.asyncio async def test_handling_extra_keys(self) -> None: """Test handling extra keys in actual output example.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_output={ + workload_output={ "name": "Bob", "age": 30, "extra_field": "ignored", # Extra field in actual output }, - agent_trace=[], + workload_trace=[], ) evaluator = TypeAdapter(JsonSimilarityEvaluator).validate_python( @@ -561,7 +561,7 @@ async def test_handling_extra_keys(self) -> None: # Only expected keys are evaluated result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"expected_output": {"name": "Bob", "age": 30}}, ) @@ -570,13 +570,13 @@ async def test_handling_extra_keys(self) -> None: @pytest.mark.asyncio async def test_target_specific_field(self) -> None: """Test target specific field example.""" - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_output={ + workload_output={ "result": {"score": 95, "passed": True}, "metadata": {"timestamp": "2024-01-01"}, }, - agent_trace=[], + workload_trace=[], ) evaluator = TypeAdapter(JsonSimilarityEvaluator).validate_python( @@ -591,7 +591,7 @@ async def test_target_specific_field(self) -> None: # Only compares the "result" field result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": {"result": {"score": 95, "passed": True}} }, @@ -625,10 +625,10 @@ async def test_basic_semantic_similarity(self, mocker: MockerFixture) -> None: async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: return mock_response - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"query": "What is the capital of France?"}, - agent_output={"answer": "Paris is the capital city of France."}, - agent_trace=[], + workload_output={"answer": "Paris is the capital city of France."}, + workload_trace=[], ) evaluator = TypeAdapter(LLMJudgeOutputEvaluator).validate_python( @@ -645,7 +645,7 @@ async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": {"answer": "The capital of France is Paris."} }, @@ -685,12 +685,12 @@ async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: Provide a score from 0-100 based on semantic similarity. """ - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_output={ + workload_output={ "message": "The product has been successfully added to your cart." }, - agent_trace=[], + workload_trace=[], ) evaluator = TypeAdapter(LLMJudgeOutputEvaluator).validate_python( @@ -708,7 +708,7 @@ async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": {"message": "Item added to shopping cart."} }, @@ -740,9 +740,9 @@ async def test_evaluating_natural_language_quality( async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: return mock_response - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"task": "Write a professional email"}, - agent_output={ + workload_output={ "email": """Dear Customer, Thank you for your inquiry. We have reviewed your request @@ -752,7 +752,7 @@ async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: Best regards, Support Team""" }, - agent_trace=[], + workload_trace=[], ) evaluator = TypeAdapter(LLMJudgeOutputEvaluator).validate_python( @@ -769,7 +769,7 @@ async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": { "email": "A professional, courteous response addressing the customer's inquiry" @@ -802,15 +802,15 @@ async def test_strict_json_similarity(self, mocker: MockerFixture) -> None: async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: return mock_response - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={}, - agent_output={ + workload_output={ "status": "success", "user_id": 12345, "name": "John Doe", "email": "john@example.com", }, - agent_trace=[], + workload_trace=[], ) evaluator = TypeAdapter( @@ -828,7 +828,7 @@ async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_output": { "status": "success", @@ -867,10 +867,10 @@ async def test_basic_trajectory_evaluation(self, mocker: MockerFixture) -> None: async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: return mock_response - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"user_query": "Book a flight to Paris"}, - agent_output={"booking_id": "FL123", "status": "confirmed"}, - agent_trace=[ + workload_output={"booking_id": "FL123", "status": "confirmed"}, + workload_trace=[ # Trace contains spans showing the agent's execution path # Each span represents a step in the agent's decision-making ], @@ -889,7 +889,7 @@ async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_agent_behavior": """ The agent should: @@ -925,10 +925,10 @@ async def test_validating_tool_usage_sequence(self, mocker: MockerFixture) -> No async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: return mock_response - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"task": "Update user profile and send notification"}, - agent_output={"status": "completed"}, - agent_trace=[ + workload_output={"status": "completed"}, + workload_trace=[ # Spans showing: validate_user -> update_profile -> send_notification ], ) @@ -946,7 +946,7 @@ async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_agent_behavior": """ The agent must: @@ -983,10 +983,10 @@ async def test_trajectory_simulation(self, mocker: MockerFixture) -> None: async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: return mock_response - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"query": "Book a flight to Paris for tomorrow"}, - agent_output={"booking_id": "FL123", "status": "confirmed"}, - agent_trace=[ + workload_output={"booking_id": "FL123", "status": "confirmed"}, + workload_trace=[ # Execution spans showing tool calls and their simulated responses ], simulation_instructions=""" @@ -1011,7 +1011,7 @@ async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "expected_agent_behavior": """ The agent should: @@ -1042,7 +1042,7 @@ async def test_basic_tool_call_order(self) -> None: """Test basic tool call order validation example with sample trace.""" from opentelemetry.sdk.trace import ReadableSpan - # Sample agent execution with tool calls in trace (this is what was missing in docs!) + # Sample workload execution with tool calls in trace (this is what was missing in docs!) mock_spans = [ ReadableSpan( name="validate_user", @@ -1064,10 +1064,10 @@ async def test_basic_tool_call_order(self) -> None: ), ] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"task": "Process user order"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = TypeAdapter(ToolCallOrderEvaluator).validate_python( @@ -1078,7 +1078,7 @@ async def test_basic_tool_call_order(self) -> None: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls_order": ["validate_user", "check_inventory", "create_order"] }, @@ -1113,10 +1113,10 @@ async def test_strict_order_validation(self) -> None: ), ] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"task": "Access secured resource"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = TypeAdapter(ToolCallOrderEvaluator).validate_python( @@ -1127,7 +1127,7 @@ async def test_strict_order_validation(self) -> None: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls_order": [ "authenticate_user", @@ -1167,10 +1167,10 @@ async def test_partial_credit_with_lcs(self) -> None: ), ] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"task": "Search and display"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = TypeAdapter(ToolCallOrderEvaluator).validate_python( @@ -1184,7 +1184,7 @@ async def test_partial_credit_with_lcs(self) -> None: expected = ["search", "filter", "sort", "display"] result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={"tool_calls_order": expected}, ) @@ -1225,10 +1225,10 @@ async def test_database_transaction_sequence(self) -> None: ), ] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"task": "Update database"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = TypeAdapter(ToolCallOrderEvaluator).validate_python( @@ -1239,7 +1239,7 @@ async def test_database_transaction_sequence(self) -> None: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls_order": [ "begin_transaction", @@ -1291,10 +1291,10 @@ async def test_api_integration_workflow(self) -> None: ), ] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"task": "API integration"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = TypeAdapter(ToolCallOrderEvaluator).validate_python( @@ -1305,7 +1305,7 @@ async def test_api_integration_workflow(self) -> None: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls_order": [ "get_api_token", @@ -1345,10 +1345,10 @@ async def test_using_default_criteria(self) -> None: ), ] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"task": "Standard workflow"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = TypeAdapter(ToolCallOrderEvaluator).validate_python( @@ -1366,7 +1366,7 @@ async def test_using_default_criteria(self) -> None: # Use default criteria result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, evaluation_criteria=None + workload_execution=workload_execution, evaluation_criteria=None ) assert result.score == 1.0 @@ -1380,7 +1380,7 @@ async def test_basic_count_validation(self) -> None: """Test basic count validation example with sample trace.""" from opentelemetry.sdk.trace import ReadableSpan - # Sample agent execution with tool calls (this is what was missing in docs!) + # Sample workload execution with tool calls (this is what was missing in docs!) mock_spans = [ ReadableSpan( name="fetch_data", @@ -1426,10 +1426,10 @@ async def test_basic_count_validation(self) -> None: ), ] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"task": "Fetch and process data"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = TypeAdapter(ToolCallCountEvaluator).validate_python( @@ -1440,7 +1440,7 @@ async def test_basic_count_validation(self) -> None: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls_count": { "fetch_data": ("=", 1), @@ -1484,10 +1484,10 @@ async def test_using_comparison_operators(self) -> None: ), ] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"task": "API operation"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = TypeAdapter(ToolCallCountEvaluator).validate_python( @@ -1498,7 +1498,7 @@ async def test_using_comparison_operators(self) -> None: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls_count": { "validate": (">=", 1), # At least once @@ -1537,10 +1537,10 @@ async def test_strict_mode_all_or_nothing(self) -> None: ), ] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"task": "Database operation"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = TypeAdapter(ToolCallCountEvaluator).validate_python( @@ -1551,7 +1551,7 @@ async def test_strict_mode_all_or_nothing(self) -> None: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls_count": { "authenticate": ("=", 1), @@ -1597,10 +1597,10 @@ async def test_preventing_redundant_calls(self) -> None: ), ] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"task": "Optimize resource usage"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = TypeAdapter(ToolCallCountEvaluator).validate_python( @@ -1612,7 +1612,7 @@ async def test_preventing_redundant_calls(self) -> None: # Ensure expensive operations aren't called too many times result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls_count": { "expensive_api_call": ( @@ -1658,10 +1658,10 @@ async def test_loop_validation(self) -> None: ] ) - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"task": "Process 10 items"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = TypeAdapter(ToolCallCountEvaluator).validate_python( @@ -1673,7 +1673,7 @@ async def test_loop_validation(self) -> None: # Verify loop processed correct number of items result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls_count": { "process_item": ("=", 10), # Should process 10 items @@ -1694,7 +1694,7 @@ async def test_basic_argument_validation(self) -> None: """Test basic argument validation example with sample trace.""" from opentelemetry.sdk.trace import ReadableSpan - # Sample agent execution with tool calls and arguments (this is what was missing in docs!) + # Sample workload execution with tool calls and arguments (this is what was missing in docs!) mock_spans = [ ReadableSpan( name="update_user", @@ -1707,10 +1707,10 @@ async def test_basic_argument_validation(self) -> None: ), ] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"user_id": 123, "action": "update"}, - agent_output={"status": "success"}, - agent_trace=mock_spans, + workload_output={"status": "success"}, + workload_trace=mock_spans, ) evaluator = TypeAdapter(ToolCallArgsEvaluator).validate_python( @@ -1725,7 +1725,7 @@ async def test_basic_argument_validation(self) -> None: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls": [ { @@ -1759,10 +1759,10 @@ async def test_strict_mode_exact_matching(self) -> None: ), ] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"task": "Send email"}, - agent_output={"status": "sent"}, - agent_trace=mock_spans, + workload_output={"status": "sent"}, + workload_trace=mock_spans, ) evaluator = TypeAdapter(ToolCallArgsEvaluator).validate_python( @@ -1777,7 +1777,7 @@ async def test_strict_mode_exact_matching(self) -> None: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls": [ { @@ -1811,10 +1811,10 @@ async def test_subset_mode_partial_validation(self) -> None: ), ] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"task": "Create user"}, - agent_output={"status": "created"}, - agent_trace=mock_spans, + workload_output={"status": "created"}, + workload_trace=mock_spans, ) evaluator = TypeAdapter(ToolCallArgsEvaluator).validate_python( @@ -1830,7 +1830,7 @@ async def test_subset_mode_partial_validation(self) -> None: # Only validate critical fields result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls": [ { @@ -1878,10 +1878,10 @@ async def test_multiple_tool_calls(self) -> None: ), ] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"task": "Data pipeline"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = TypeAdapter(ToolCallArgsEvaluator).validate_python( @@ -1896,7 +1896,7 @@ async def test_multiple_tool_calls(self) -> None: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls": [ { @@ -1934,10 +1934,10 @@ async def test_nested_arguments(self) -> None: ), ] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"task": "evaluatorConfigure API service"}, - agent_output={"status": "evaluatorConfigured"}, - agent_trace=mock_spans, + workload_output={"status": "evaluatorConfigured"}, + workload_trace=mock_spans, ) evaluator = TypeAdapter(ToolCallArgsEvaluator).validate_python( @@ -1952,7 +1952,7 @@ async def test_nested_arguments(self) -> None: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls": [ { @@ -2008,10 +2008,10 @@ async def test_non_strict_proportional_scoring(self) -> None: ), ] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"task": "Update user profile"}, - agent_output={"status": "updated"}, - agent_trace=mock_spans, + workload_output={"status": "updated"}, + workload_trace=mock_spans, ) evaluator = TypeAdapter(ToolCallArgsEvaluator).validate_python( @@ -2026,7 +2026,7 @@ async def test_non_strict_proportional_scoring(self) -> None: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_calls": [ { @@ -2060,7 +2060,7 @@ async def test_basic_output_validation(self) -> None: """Test basic output validation example with sample trace.""" from opentelemetry.sdk.trace import ReadableSpan - # Sample agent execution with tool calls and outputs (this is what was missing in docs!) + # Sample workload execution with tool calls and outputs (this is what was missing in docs!) mock_spans = [ ReadableSpan( name="get_user", @@ -2073,10 +2073,10 @@ async def test_basic_output_validation(self) -> None: ), ] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"user_id": 123}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = TypeAdapter(ToolCallOutputEvaluator).validate_python( @@ -2087,7 +2087,7 @@ async def test_basic_output_validation(self) -> None: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_outputs": [ { @@ -2117,10 +2117,10 @@ async def test_strict_mode_exact_output_matching(self) -> None: ), ] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"operation": "multiply"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = TypeAdapter(ToolCallOutputEvaluator).validate_python( @@ -2131,7 +2131,7 @@ async def test_strict_mode_exact_output_matching(self) -> None: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_outputs": [ { @@ -2170,10 +2170,10 @@ async def test_multiple_tool_outputs(self) -> None: ), ] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"task": "Process items"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = TypeAdapter(ToolCallOutputEvaluator).validate_python( @@ -2184,7 +2184,7 @@ async def test_multiple_tool_outputs(self) -> None: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_outputs": [ {"name": "fetch", "output": '{"data": ["item1", "item2"]}'}, @@ -2212,10 +2212,10 @@ async def test_error_handling_validation(self) -> None: ), ] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"email": "invalid-email"}, - agent_output={"status": "validation_failed"}, - agent_trace=mock_spans, + workload_output={"status": "validation_failed"}, + workload_trace=mock_spans, ) evaluator = TypeAdapter(ToolCallOutputEvaluator).validate_python( @@ -2226,7 +2226,7 @@ async def test_error_handling_validation(self) -> None: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_outputs": [ { @@ -2275,10 +2275,10 @@ async def test_non_strict_proportional_scoring(self) -> None: ), ] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"task": "Process data pipeline"}, - agent_output={"status": "completed"}, - agent_trace=mock_spans, + workload_output={"status": "completed"}, + workload_trace=mock_spans, ) evaluator = TypeAdapter(ToolCallOutputEvaluator).validate_python( @@ -2292,7 +2292,7 @@ async def test_non_strict_proportional_scoring(self) -> None: ) result = await evaluator.validate_and_evaluate_criteria( - agent_execution=agent_execution, + workload_execution=workload_execution, evaluation_criteria={ "tool_outputs": [ { diff --git a/packages/uipath/tests/evaluators/test_eval_level_expected_output.py b/packages/uipath/tests/evaluators/test_eval_level_expected_output.py index 1c2b8fd44..c161cbfc6 100644 --- a/packages/uipath/tests/evaluators/test_eval_level_expected_output.py +++ b/packages/uipath/tests/evaluators/test_eval_level_expected_output.py @@ -28,7 +28,7 @@ EvaluationItem, EvaluationSet, ) -from uipath.eval.models.models import AgentExecution +from uipath.eval.models.models import WorkloadExecution # ───────────────────────────────────────────────────────────────── # Model Tests @@ -387,10 +387,10 @@ class TestExactMatchWithEvaluationLevelExpectedOutput: @pytest.mark.asyncio async def test_exact_match_with_evaluation_level_expected_output(self) -> None: """ExactMatchEvaluator uses evaluation-level expectedOutput when criteria is null.""" - execution = AgentExecution( + execution = WorkloadExecution( agent_input={"query": "2+2"}, - agent_output={"result": 4}, - agent_trace=[], + workload_output={"result": 4}, + workload_trace=[], ) evaluator = ExactMatchEvaluator.model_validate( {"evaluatorConfig": {"name": "Test"}, "id": str(uuid.uuid4())} @@ -408,10 +408,10 @@ async def test_exact_match_with_evaluation_level_expected_output(self) -> None: @pytest.mark.asyncio async def test_exact_match_per_evaluator_overrides_evaluation_level(self) -> None: """Per-evaluator expectedOutput overrides evaluation-level.""" - execution = AgentExecution( + execution = WorkloadExecution( agent_input={"query": "2+2"}, - agent_output={"result": 4}, - agent_trace=[], + workload_output={"result": 4}, + workload_trace=[], ) evaluator = ExactMatchEvaluator.model_validate( {"evaluatorConfig": {"name": "Test"}, "id": str(uuid.uuid4())} @@ -433,10 +433,10 @@ class TestJsonSimilarityWithEvaluationLevelExpectedOutput: @pytest.mark.asyncio async def test_json_similarity_with_evaluation_level_expected_output(self) -> None: """JsonSimilarityEvaluator uses evaluation-level expectedOutput.""" - execution = AgentExecution( + execution = WorkloadExecution( agent_input={"input": "Test"}, - agent_output={"name": "John", "age": 30, "city": "NYC"}, - agent_trace=[], + workload_output={"name": "John", "age": 30, "city": "NYC"}, + workload_trace=[], ) evaluator = JsonSimilarityEvaluator.model_validate( {"evaluatorConfig": {"name": "Test"}, "id": str(uuid.uuid4())} @@ -490,10 +490,10 @@ async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: } ) - execution = AgentExecution( + execution = WorkloadExecution( agent_input={"query": "test"}, - agent_output={"result": "test output"}, - agent_trace=[], + workload_output={"result": "test output"}, + workload_trace=[], ) # Criteria built from evaluation-level expectedOutput @@ -627,10 +627,10 @@ async def test_e2e_exact_match_null_criteria_with_evaluation_level(self) -> None "evaluationCriterias": {evaluator_id: None}, } ) - execution = AgentExecution( + execution = WorkloadExecution( agent_input={"query": "2+2"}, - agent_output={"result": 4}, - agent_trace=[], + workload_output={"result": 4}, + workload_trace=[], ) # Simulate runtime merge @@ -680,10 +680,10 @@ async def test_e2e_mixed_evaluators_with_evaluation_level(self) -> None: }, } ) - execution = AgentExecution( + execution = WorkloadExecution( agent_input={"query": "hello"}, - agent_output="Hello World", - agent_trace=[], + workload_output="Hello World", + workload_trace=[], ) # Process exact-match (output-based) @@ -729,10 +729,10 @@ async def test_e2e_per_evaluator_override_with_evaluation_level(self) -> None: }, } ) - execution = AgentExecution( + execution = WorkloadExecution( agent_input={"query": "2+2"}, - agent_output={"result": 4}, - agent_trace=[], + workload_output={"result": 4}, + workload_trace=[], ) # Simulate runtime merge diff --git a/packages/uipath/tests/evaluators/test_evaluator_methods.py b/packages/uipath/tests/evaluators/test_evaluator_methods.py index ec795499d..98efb94b1 100644 --- a/packages/uipath/tests/evaluators/test_evaluator_methods.py +++ b/packages/uipath/tests/evaluators/test_evaluator_methods.py @@ -58,26 +58,26 @@ ) from uipath.eval.models import NumericEvaluationResult from uipath.eval.models.models import ( - AgentExecution, ToolCall, ToolOutput, UiPathEvaluationError, + WorkloadExecution, ) @pytest.fixture -def sample_agent_execution() -> AgentExecution: - """Create a sample AgentExecution for testing.""" - return AgentExecution( +def sample_agent_execution() -> WorkloadExecution: + """Create a sample WorkloadExecution for testing.""" + return WorkloadExecution( agent_input={"input": "Test input"}, - agent_output={"output": "Test output"}, - agent_trace=[], # Empty trace for basic tests + workload_output={"output": "Test output"}, + workload_trace=[], # Empty trace for basic tests ) @pytest.fixture -def sample_agent_execution_with_trace() -> AgentExecution: - """Create a sample AgentExecution with tool call trace.""" +def sample_agent_execution_with_trace() -> WorkloadExecution: + """Create a sample WorkloadExecution with tool call trace.""" # Mock spans that represent tool calls - simplified for testing mock_spans = [ ReadableSpan( @@ -122,12 +122,12 @@ def sample_agent_execution_with_trace() -> AgentExecution: ), ] - return AgentExecution( + return WorkloadExecution( agent_input={"input": "Test input with tools"}, - agent_output={ + workload_output={ "output": "Test output with tools", }, - agent_trace=mock_spans, + workload_trace=mock_spans, ) @@ -136,7 +136,7 @@ class TestExactMatchEvaluator: @pytest.mark.asyncio async def test_exact_match_string_success( - self, sample_agent_execution: AgentExecution + self, sample_agent_execution: WorkloadExecution ) -> None: """Test exact match with matching strings.""" config = { @@ -156,7 +156,7 @@ async def test_exact_match_string_success( @pytest.mark.asyncio async def test_exact_match_string_failure( - self, sample_agent_execution: AgentExecution + self, sample_agent_execution: WorkloadExecution ) -> None: """Test exact match with non-matching strings.""" config = { @@ -178,7 +178,7 @@ async def test_exact_match_string_failure( @pytest.mark.asyncio async def test_exact_match_negated( - self, sample_agent_execution: AgentExecution + self, sample_agent_execution: WorkloadExecution ) -> None: """Test exact match with negated criteria.""" config = { @@ -215,10 +215,10 @@ async def test_exact_match_numeric_normalization( self, actual_output: Any, expected_output: Any, expected_score: float ) -> None: """Test that int and float scalar values are normalized before comparison.""" - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output={"value": actual_output}, - agent_trace=[], + workload_output={"value": actual_output}, + workload_trace=[], ) config = {"name": "ExactMatchNumericTest", "target_output_key": "value"} evaluator = ExactMatchEvaluator.model_validate( @@ -260,10 +260,10 @@ async def test_exact_match_recursive_normalization( expected_score: float, ) -> None: """Test that int/float normalization works recursively for dicts, lists, and nested structures.""" - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output=actual_output, - agent_trace=[], + workload_output=actual_output, + workload_trace=[], ) config = {"name": "ExactMatchRecursiveTest", "target_output_key": target_key} evaluator = ExactMatchEvaluator.model_validate( @@ -278,7 +278,7 @@ async def test_exact_match_recursive_normalization( @pytest.mark.asyncio async def test_exact_match_validate_and_evaluate_criteria( - self, sample_agent_execution: AgentExecution + self, sample_agent_execution: WorkloadExecution ) -> None: """Test exact match using validate_and_evaluate_criteria.""" config = { @@ -311,15 +311,15 @@ async def test_exact_match_line_by_line_all_match(self) -> None: ) # Multi-line output - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"input": "Test input"}, - agent_output="line1\nline2\nline3", - agent_trace=[], + workload_output="line1\nline2\nline3", + workload_trace=[], ) criteria = OutputEvaluationCriteria(expected_output="line1\nline2\nline3") # pyright: ignore[reportCallIssue] result = await evaluator.validate_and_evaluate_criteria( - agent_execution, criteria + workload_execution, criteria ) assert isinstance(result, NumericEvaluationResult) @@ -346,15 +346,15 @@ async def test_exact_match_line_by_line_partial_match(self) -> None: ) # Multi-line output with 2 out of 3 lines matching - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"input": "Test input"}, - agent_output="line1\nwrong\nline3", - agent_trace=[], + workload_output="line1\nwrong\nline3", + workload_trace=[], ) criteria = OutputEvaluationCriteria(expected_output="line1\nline2\nline3") # pyright: ignore[reportCallIssue] result = await evaluator.validate_and_evaluate_criteria( - agent_execution, criteria + workload_execution, criteria ) assert isinstance(result, NumericEvaluationResult) @@ -384,15 +384,15 @@ async def test_exact_match_line_by_line_custom_delimiter(self) -> None: ) # Pipe-delimited output - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"input": "Test input"}, - agent_output="part1|part2|part3", - agent_trace=[], + workload_output="part1|part2|part3", + workload_trace=[], ) criteria = OutputEvaluationCriteria(expected_output="part1|part2|part3") # pyright: ignore[reportCallIssue] result = await evaluator.validate_and_evaluate_criteria( - agent_execution, criteria + workload_execution, criteria ) assert isinstance(result, NumericEvaluationResult) @@ -415,15 +415,15 @@ async def test_exact_match_line_by_line_has_individual_results(self) -> None: ) # Multi-line output with 2 out of 3 lines matching - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"input": "Test input"}, - agent_output="line1\nwrong\nline3", - agent_trace=[], + workload_output="line1\nwrong\nline3", + workload_trace=[], ) criteria = OutputEvaluationCriteria(expected_output="line1\nline2\nline3") # pyright: ignore[reportCallIssue] result = await evaluator.validate_and_evaluate_criteria( - agent_execution, criteria + workload_execution, criteria ) # Check that the result has the _line_by_line_results attribute @@ -453,10 +453,10 @@ class TestListTargetOutputKey: @pytest.mark.asyncio async def test_exact_match_list_keys_all_match(self) -> None: """All listed keys match → score 1.0.""" - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output={"status": "ok", "total": 42, "extra": "ignored"}, - agent_trace=[], + workload_output={"status": "ok", "total": 42, "extra": "ignored"}, + workload_trace=[], ) config = { "name": "ExactMatchListKeys", @@ -475,10 +475,10 @@ async def test_exact_match_list_keys_all_match(self) -> None: @pytest.mark.asyncio async def test_exact_match_list_keys_value_mismatch(self) -> None: """One key's value differs → score 0.0.""" - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output={"status": "ok", "total": 99}, - agent_trace=[], + workload_output={"status": "ok", "total": 99}, + workload_trace=[], ) config = { "name": "ExactMatchListKeys", @@ -497,10 +497,10 @@ async def test_exact_match_list_keys_value_mismatch(self) -> None: @pytest.mark.asyncio async def test_exact_match_list_keys_dot_notation(self) -> None: """Nested dot-notation paths inside a list of keys.""" - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output={"order": {"status": "shipped"}, "qty": 3}, - agent_trace=[], + workload_output={"order": {"status": "shipped"}, "qty": 3}, + workload_trace=[], ) config = { "name": "ExactMatchListDotKeys", @@ -521,10 +521,10 @@ async def test_list_keys_missing_key_in_actual_raises(self) -> None: """Missing key in actual output returns an ErrorEvaluationResult.""" from uipath.eval.models.models import ErrorEvaluationResult - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output={"status": "ok"}, # 'total' is missing - agent_trace=[], + workload_output={"status": "ok"}, # 'total' is missing + workload_trace=[], ) config = { "name": "ExactMatchListKeys", @@ -545,10 +545,10 @@ async def test_list_keys_missing_key_in_expected_raises(self) -> None: """Missing key in expected output returns an ErrorEvaluationResult.""" from uipath.eval.models.models import ErrorEvaluationResult - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output={"status": "ok", "total": 42}, - agent_trace=[], + workload_output={"status": "ok", "total": 42}, + workload_trace=[], ) config = { "name": "ExactMatchListKeys", @@ -571,10 +571,10 @@ async def test_list_keys_expected_as_json_string(self) -> None: """Expected output as a JSON string is parsed when key is a list.""" import json - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output={"status": "ok", "total": 5}, - agent_trace=[], + workload_output={"status": "ok", "total": 5}, + workload_trace=[], ) config = { "name": "ExactMatchListKeys", @@ -595,10 +595,10 @@ async def test_list_keys_invalid_json_string_expected_raises(self) -> None: """Invalid JSON string for expected output returns an ErrorEvaluationResult.""" from uipath.eval.models.models import ErrorEvaluationResult - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output={"status": "ok"}, - agent_trace=[], + workload_output={"status": "ok"}, + workload_trace=[], ) config = {"name": "ExactMatchListKeys", "target_output_key": ["status"]} evaluator = ExactMatchEvaluator.model_validate( @@ -614,10 +614,10 @@ async def test_list_keys_invalid_json_string_expected_raises(self) -> None: @pytest.mark.asyncio async def test_list_keys_disables_line_by_line(self) -> None: """line_by_line_evaluator=True is ignored when target_output_key is a list.""" - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output={"a": "x", "b": "y"}, - agent_trace=[], + workload_output={"a": "x", "b": "y"}, + workload_trace=[], ) config = { "name": "ExactMatchListLbl", @@ -645,10 +645,10 @@ async def test_json_similarity_list_keys_perfect_match(self) -> None: JsonSimilarityEvaluator, ) - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output={"name": "Alice", "score": 100, "extra": "ignored"}, - agent_trace=[], + workload_output={"name": "Alice", "score": 100, "extra": "ignored"}, + workload_trace=[], ) config = { "name": "JsonSimListKeys", @@ -666,13 +666,13 @@ async def test_json_similarity_list_keys_perfect_match(self) -> None: @pytest.mark.asyncio async def test_list_keys_non_dict_actual_raises(self) -> None: - """Non-dict agent_output with list key returns ErrorEvaluationResult.""" + """Non-dict workload_output with list key returns ErrorEvaluationResult.""" from uipath.eval.models.models import ErrorEvaluationResult - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output="just a string", # pyright: ignore[reportArgumentType] - agent_trace=[], + workload_output="just a string", # pyright: ignore[reportArgumentType] + workload_trace=[], ) config = {"name": "ExactMatchListKeys", "target_output_key": ["status"]} evaluator = ExactMatchEvaluator.model_validate( @@ -692,10 +692,10 @@ async def test_list_keys_non_dict_json_expected_raises(self) -> None: from uipath.eval.models.models import ErrorEvaluationResult - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output={"status": "ok"}, - agent_trace=[], + workload_output={"status": "ok"}, + workload_trace=[], ) config = {"name": "ExactMatchListKeys", "target_output_key": ["status"]} evaluator = ExactMatchEvaluator.model_validate( @@ -721,10 +721,10 @@ async def test_list_keys_attachment_uri_downloaded( "uipath.eval.evaluators.output_evaluator.download_attachment_as_string", return_value="downloaded_content", ) - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output={"file": att_uri, "status": "ok"}, - agent_trace=[], + workload_output={"file": att_uri, "status": "ok"}, + workload_trace=[], ) config = {"name": "ExactMatchListKeys", "target_output_key": ["file", "status"]} evaluator = ExactMatchEvaluator.model_validate( @@ -749,10 +749,10 @@ async def test_scalar_key_attachment_uri_downloaded( "uipath.eval.evaluators.output_evaluator.download_attachment_as_string", return_value="file_content", ) - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output={"report": att_uri}, - agent_trace=[], + workload_output={"report": att_uri}, + workload_trace=[], ) config = {"name": "ExactMatchScalarAtt", "target_output_key": "report"} evaluator = ExactMatchEvaluator.model_validate( @@ -770,10 +770,10 @@ async def test_scalar_key_missing_in_actual_raises(self) -> None: """Missing scalar target_output_key in actual output returns ErrorEvaluationResult.""" from uipath.eval.models.models import ErrorEvaluationResult - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output={"other": "value"}, - agent_trace=[], + workload_output={"other": "value"}, + workload_trace=[], ) config = {"name": "ExactMatchMissingActual", "target_output_key": "missing_key"} evaluator = ExactMatchEvaluator.model_validate( @@ -791,10 +791,10 @@ async def test_scalar_key_missing_in_expected_raises(self) -> None: """Missing scalar target_output_key in expected output returns ErrorEvaluationResult.""" from uipath.eval.models.models import ErrorEvaluationResult - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output={"status": "ok"}, - agent_trace=[], + workload_output={"status": "ok"}, + workload_trace=[], ) config = {"name": "ExactMatchMissingExpected", "target_output_key": "status"} evaluator = ExactMatchEvaluator.model_validate( @@ -813,10 +813,10 @@ async def test_scalar_key_invalid_json_expected_raises(self) -> None: """Invalid JSON string for expected output with scalar key returns ErrorEvaluationResult.""" from uipath.eval.models.models import ErrorEvaluationResult - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output={"status": "ok"}, - agent_trace=[], + workload_output={"status": "ok"}, + workload_trace=[], ) config = {"name": "ExactMatchInvalidJson", "target_output_key": "status"} evaluator = ExactMatchEvaluator.model_validate( @@ -832,10 +832,10 @@ async def test_scalar_key_invalid_json_expected_raises(self) -> None: @pytest.mark.asyncio async def test_validate_and_evaluate_criteria_none_raises(self) -> None: """None criteria with no default configured raises UiPathEvaluationError.""" - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output={"status": "ok"}, - agent_trace=[], + workload_output={"status": "ok"}, + workload_trace=[], ) config = {"name": "ExactMatchNoCriteria"} evaluator = ExactMatchEvaluator.model_validate( @@ -867,7 +867,7 @@ def get_evaluator_id(cls) -> str: return "uipath-minimal-test" async def evaluate( - self, agent_execution: Any, evaluation_criteria: Any + self, workload_execution: Any, evaluation_criteria: Any ) -> EvaluationResult: return None # type: ignore[return-value] @@ -894,7 +894,7 @@ class TestContainsEvaluator: @pytest.mark.asyncio @pytest.mark.parametrize( - "agent_output, search_text, target_key, case_sensitive, negated, expected_score", + "workload_output, search_text, target_key, case_sensitive, negated, expected_score", [ # Basic match ("Test output", "Test output", "*", False, False, 1.0), @@ -918,24 +918,24 @@ class TestContainsEvaluator: ) async def test_contains_evaluator( self, - agent_output: Any, + workload_output: Any, search_text: str, target_key: str, case_sensitive: bool, negated: bool, expected_score: float, - sample_agent_execution: AgentExecution, + sample_agent_execution: WorkloadExecution, ) -> None: """Test ContainsEvaluator across match, no-match, case sensitivity, and negation cases.""" if target_key == "output": execution = ( - sample_agent_execution # has agent_output={"output": "Test output"} + sample_agent_execution # has workload_output={"output": "Test output"} ) else: - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output=agent_output, - agent_trace=[], + workload_output=workload_output, + workload_trace=[], ) config = { "name": "ContainsTest", @@ -954,7 +954,7 @@ async def test_contains_evaluator( @pytest.mark.asyncio async def test_contains_evaluator_validate_and_evaluate_criteria( - self, sample_agent_execution: AgentExecution + self, sample_agent_execution: WorkloadExecution ) -> None: """Test contains evaluator with validate_and_evaluate_criteria.""" config = { @@ -978,10 +978,10 @@ class TestJsonSimilarityEvaluator: @pytest.mark.asyncio async def test_json_similarity_identical(self) -> None: """Test JSON similarity with identical structures.""" - execution = AgentExecution( + execution = WorkloadExecution( agent_input={"input": "Test"}, - agent_output={"name": "John", "age": 30, "city": "NYC"}, - agent_trace=[], + workload_output={"name": "John", "age": 30, "city": "NYC"}, + workload_trace=[], ) config = { "name": "JsonSimilarityTest", @@ -1001,10 +1001,10 @@ async def test_json_similarity_identical(self) -> None: @pytest.mark.asyncio async def test_json_similarity_partial_match(self) -> None: """Test JSON similarity with partial matches.""" - execution = AgentExecution( + execution = WorkloadExecution( agent_input={"input": "Test"}, - agent_output={"name": "John", "age": 30, "city": "LA"}, - agent_trace=[], + workload_output={"name": "John", "age": 30, "city": "LA"}, + workload_trace=[], ) config = { "name": "JsonSimilarityTest", @@ -1044,10 +1044,10 @@ async def test_json_similarity_numeric_normalization( expected_score: float, ) -> None: """Test that int/float normalization is applied before JSON similarity comparison.""" - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output=actual_output, - agent_trace=[], + workload_output=actual_output, + workload_trace=[], ) config = {"name": "JsonSimilarityTest"} evaluator = JsonSimilarityEvaluator.model_validate( @@ -1063,10 +1063,10 @@ async def test_json_similarity_numeric_normalization( @pytest.mark.asyncio async def test_json_similarity_validate_and_evaluate_criteria(self) -> None: """Test JSON similarity using validate_and_evaluate_criteria.""" - execution = AgentExecution( + execution = WorkloadExecution( agent_input={"input": "Test"}, - agent_output={"name": "John", "age": 30, "city": "NYC"}, - agent_trace=[], + workload_output={"name": "John", "age": 30, "city": "NYC"}, + workload_trace=[], ) config = { "name": "JsonSimilarityTest", @@ -1087,7 +1087,7 @@ class TestToolCallOrderEvaluator: @pytest.mark.asyncio async def test_tool_call_order_perfect_match( - self, sample_agent_execution_with_trace: AgentExecution + self, sample_agent_execution_with_trace: WorkloadExecution ) -> None: """Test tool call order with perfect order match.""" @@ -1110,7 +1110,7 @@ async def test_tool_call_order_perfect_match( @pytest.mark.asyncio async def test_tool_call_order_no_perfect_match( - self, sample_agent_execution_with_trace: AgentExecution + self, sample_agent_execution_with_trace: WorkloadExecution ) -> None: """Test tool call order with perfect order match.""" @@ -1133,7 +1133,7 @@ async def test_tool_call_order_no_perfect_match( @pytest.mark.asyncio async def test_tool_call_order_lcs_match( - self, sample_agent_execution_with_trace: AgentExecution + self, sample_agent_execution_with_trace: WorkloadExecution ) -> None: """Test tool call order with lcs order match.""" @@ -1155,7 +1155,7 @@ async def test_tool_call_order_lcs_match( @pytest.mark.asyncio async def test_tool_call_order_validate_and_evaluate_criteria( - self, sample_agent_execution_with_trace: AgentExecution + self, sample_agent_execution_with_trace: WorkloadExecution ) -> None: """Test tool call order using validate_and_evaluate_criteria.""" config = { @@ -1180,7 +1180,7 @@ class TestToolCallCountEvaluator: @pytest.mark.asyncio async def test_tool_call_count_exact_match( - self, sample_agent_execution_with_trace: AgentExecution + self, sample_agent_execution_with_trace: WorkloadExecution ) -> None: """Test tool call count with exact count match.""" config = { @@ -1201,7 +1201,7 @@ async def test_tool_call_count_exact_match( @pytest.mark.asyncio async def test_tool_call_count_with_gt( - self, sample_agent_execution_with_trace: AgentExecution + self, sample_agent_execution_with_trace: WorkloadExecution ) -> None: """Test tool call count with strict count match.""" config = { @@ -1222,7 +1222,7 @@ async def test_tool_call_count_with_gt( @pytest.mark.asyncio async def test_tool_call_count_no_exact_match( - self, sample_agent_execution_with_trace: AgentExecution + self, sample_agent_execution_with_trace: WorkloadExecution ) -> None: """Test tool call count with no exact count match.""" config = { @@ -1243,7 +1243,7 @@ async def test_tool_call_count_no_exact_match( @pytest.mark.asyncio async def test_tool_call_count_partial_match( - self, sample_agent_execution_with_trace: AgentExecution + self, sample_agent_execution_with_trace: WorkloadExecution ) -> None: """Test tool call count with partial count match.""" config = { @@ -1264,7 +1264,7 @@ async def test_tool_call_count_partial_match( @pytest.mark.asyncio async def test_tool_call_count_validate_and_evaluate_criteria( - self, sample_agent_execution_with_trace: AgentExecution + self, sample_agent_execution_with_trace: WorkloadExecution ) -> None: """Test tool call count using validate_and_evaluate_criteria.""" config = { @@ -1289,7 +1289,7 @@ class TestToolCallArgsEvaluator: @pytest.mark.asyncio async def test_tool_call_args_perfect_match( - self, sample_agent_execution_with_trace: AgentExecution + self, sample_agent_execution_with_trace: WorkloadExecution ) -> None: """Test tool call args with perfect match.""" config = { @@ -1315,7 +1315,7 @@ async def test_tool_call_args_perfect_match( @pytest.mark.asyncio async def test_tool_call_args_partial_match( - self, sample_agent_execution_with_trace: AgentExecution + self, sample_agent_execution_with_trace: WorkloadExecution ) -> None: """Test tool call args with partial match.""" config = { @@ -1341,7 +1341,7 @@ async def test_tool_call_args_partial_match( @pytest.mark.asyncio async def test_tool_call_args_validate_and_evaluate_criteria( - self, sample_agent_execution_with_trace: AgentExecution + self, sample_agent_execution_with_trace: WorkloadExecution ) -> None: """Test tool call args using validate_and_evaluate_criteria.""" config = { @@ -1373,7 +1373,7 @@ class TestToolCallOutputEvaluator: @pytest.mark.asyncio async def test_tool_call_output_perfect_match( - self, sample_agent_execution_with_trace: AgentExecution + self, sample_agent_execution_with_trace: WorkloadExecution ) -> None: """Test tool call output with perfect output match.""" config = { @@ -1399,7 +1399,7 @@ async def test_tool_call_output_perfect_match( @pytest.mark.asyncio async def test_tool_call_output_partial_match( - self, sample_agent_execution_with_trace: AgentExecution + self, sample_agent_execution_with_trace: WorkloadExecution ) -> None: """Test tool call output with partial output match.""" config = { @@ -1425,7 +1425,7 @@ async def test_tool_call_output_partial_match( @pytest.mark.asyncio async def test_tool_call_output_no_match_strict( - self, sample_agent_execution_with_trace: AgentExecution + self, sample_agent_execution_with_trace: WorkloadExecution ) -> None: """Test tool call output with no match in strict mode.""" config = { @@ -1451,7 +1451,7 @@ async def test_tool_call_output_no_match_strict( @pytest.mark.asyncio async def test_tool_call_output_partial_match_non_strict( - self, sample_agent_execution_with_trace: AgentExecution + self, sample_agent_execution_with_trace: WorkloadExecution ) -> None: """Test tool call output with partial match in non-strict mode.""" config = { @@ -1475,7 +1475,7 @@ async def test_tool_call_output_partial_match_non_strict( @pytest.mark.asyncio async def test_tool_call_output_empty_criteria( - self, sample_agent_execution_with_trace: AgentExecution + self, sample_agent_execution_with_trace: WorkloadExecution ) -> None: """Test tool call output with empty criteria.""" config = { @@ -1494,7 +1494,7 @@ async def test_tool_call_output_empty_criteria( @pytest.mark.asyncio async def test_tool_call_output_validate_and_evaluate_criteria( - self, sample_agent_execution_with_trace: AgentExecution + self, sample_agent_execution_with_trace: WorkloadExecution ) -> None: """Test tool call output using validate_and_evaluate_criteria.""" config = { @@ -1526,7 +1526,7 @@ class TestLlmAsAJudgeEvaluator: @pytest.mark.asyncio async def test_llm_judge_basic_evaluation( - self, sample_agent_execution: AgentExecution, mocker: MockerFixture + self, sample_agent_execution: WorkloadExecution, mocker: MockerFixture ) -> None: """Test LLM as judge basic evaluation functionality with function calling.""" mock_tool_call = mocker.MagicMock() @@ -1576,7 +1576,7 @@ async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: @pytest.mark.asyncio async def test_llm_judge_basic_evaluation_with_llm_service( - self, sample_agent_execution: AgentExecution, mocker: MockerFixture + self, sample_agent_execution: WorkloadExecution, mocker: MockerFixture ) -> None: """Test LLM judge basic evaluation functionality with a custom LLM service and function calling.""" # Mock tool call for function calling approach @@ -1623,7 +1623,7 @@ async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: @pytest.mark.asyncio async def test_llm_judge_validate_and_evaluate_criteria( - self, sample_agent_execution: AgentExecution, mocker: MockerFixture + self, sample_agent_execution: WorkloadExecution, mocker: MockerFixture ) -> None: """Test LLM judge using validate_and_evaluate_criteria with function calling.""" mock_tool_call = mocker.MagicMock() @@ -1678,7 +1678,7 @@ class TestLlmJudgeTrajectoryEvaluator: @pytest.mark.asyncio async def test_llm_trajectory_basic_evaluation( - self, sample_agent_execution: AgentExecution, mocker: MockerFixture + self, sample_agent_execution: WorkloadExecution, mocker: MockerFixture ) -> None: """Test LLM trajectory judge basic evaluation functionality with function calling.""" mock_tool_call = mocker.MagicMock() @@ -1730,7 +1730,7 @@ async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: @pytest.mark.asyncio async def test_llm_trajectory_validate_and_evaluate_criteria( - self, sample_agent_execution: AgentExecution, mocker: MockerFixture + self, sample_agent_execution: WorkloadExecution, mocker: MockerFixture ) -> None: """Test LLM trajectory judge using validate_and_evaluate_criteria with function calling.""" mock_tool_call = mocker.MagicMock() @@ -1825,7 +1825,7 @@ class TestEvaluationResultTypes: @pytest.mark.asyncio async def test_evaluators_return_results_with_scores( - self, sample_agent_execution: AgentExecution + self, sample_agent_execution: WorkloadExecution ) -> None: """Test that evaluators return results with scores.""" config = { @@ -1847,7 +1847,7 @@ class TestJustificationHandling: @pytest.mark.asyncio async def test_exact_match_evaluator_justification( - self, sample_agent_execution: AgentExecution + self, sample_agent_execution: WorkloadExecution ) -> None: """Test that ExactMatchEvaluator provides BaseEvaluatorJustification.""" @@ -1872,10 +1872,10 @@ async def test_exact_match_evaluator_justification( async def test_json_similarity_evaluator_justification(self) -> None: """Test that JsonSimilarityEvaluator provides JsonSimilarityJustification.""" - execution = AgentExecution( + execution = WorkloadExecution( agent_input={"input": "Test"}, - agent_output={"name": "John", "age": 30, "city": "NYC"}, - agent_trace=[], + workload_output={"name": "John", "age": 30, "city": "NYC"}, + workload_trace=[], ) config = { "name": "JsonSimilarityTest", @@ -1897,7 +1897,7 @@ async def test_json_similarity_evaluator_justification(self) -> None: @pytest.mark.asyncio async def test_tool_call_order_evaluator_justification( - self, sample_agent_execution_with_trace: AgentExecution + self, sample_agent_execution_with_trace: WorkloadExecution ) -> None: """Test that ToolCallOrderEvaluator provides structured justification.""" @@ -1920,7 +1920,7 @@ async def test_tool_call_order_evaluator_justification( @pytest.mark.asyncio async def test_tool_call_count_evaluator_justification( - self, sample_agent_execution_with_trace: AgentExecution + self, sample_agent_execution_with_trace: WorkloadExecution ) -> None: """Test that ToolCallCountEvaluator provides structured justification.""" @@ -1943,7 +1943,7 @@ async def test_tool_call_count_evaluator_justification( @pytest.mark.asyncio async def test_tool_call_args_evaluator_justification( - self, sample_agent_execution_with_trace: AgentExecution + self, sample_agent_execution_with_trace: WorkloadExecution ) -> None: """Test that ToolCallArgsEvaluator provides structured justification.""" @@ -1971,7 +1971,7 @@ async def test_tool_call_args_evaluator_justification( @pytest.mark.asyncio async def test_tool_call_output_evaluator_justification( - self, sample_agent_execution_with_trace: AgentExecution + self, sample_agent_execution_with_trace: WorkloadExecution ) -> None: """Test that ToolCallOutputEvaluator handles justification correctly.""" config = { @@ -2003,7 +2003,7 @@ async def test_tool_call_output_evaluator_justification( @pytest.mark.asyncio async def test_llm_judge_output_evaluator_justification( - self, sample_agent_execution: AgentExecution, mocker: MockerFixture + self, sample_agent_execution: WorkloadExecution, mocker: MockerFixture ) -> None: """Test that LLMJudgeOutputEvaluator handles str justification correctly with function calling.""" mock_tool_call = mocker.MagicMock() @@ -2059,7 +2059,7 @@ async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: @pytest.mark.asyncio async def test_llm_judge_trajectory_evaluator_justification( - self, sample_agent_execution: AgentExecution, mocker: MockerFixture + self, sample_agent_execution: WorkloadExecution, mocker: MockerFixture ) -> None: """Test that LLMJudgeTrajectoryEvaluator handles str justification correctly.""" mock_tool_call = mocker.MagicMock() @@ -2205,7 +2205,7 @@ def test_justification_type_extraction_all_evaluators(self) -> None: @pytest.mark.asyncio async def test_llm_judge_omits_max_tokens_when_none( - self, sample_agent_execution: AgentExecution, mocker: MockerFixture + self, sample_agent_execution: WorkloadExecution, mocker: MockerFixture ) -> None: """Test that max_tokens is omitted from API request when None (fixes 400 error).""" mock_tool_call = mocker.MagicMock() @@ -2252,7 +2252,7 @@ async def capture_chat_completions(**kwargs: Any) -> Any: ) result = await evaluator.evaluate( - agent_execution=sample_agent_execution, + workload_execution=sample_agent_execution, evaluation_criteria=OutputEvaluationCriteria(expected_output="42"), ) @@ -2292,7 +2292,7 @@ class TestClaude45ModelSupport: async def test_claude_45_evaluator_uses_function_calling( self, model_name: str, - sample_agent_execution: AgentExecution, + sample_agent_execution: WorkloadExecution, mocker: MockerFixture, ) -> None: """Test that Claude 4.5 evaluators use function calling (tools/tool_choice).""" @@ -2365,7 +2365,7 @@ async def capture_chat_completions(**kwargs: Any) -> Any: async def test_claude_45_sets_default_max_tokens( self, model_name: str, - sample_agent_execution: AgentExecution, + sample_agent_execution: WorkloadExecution, mocker: MockerFixture, ) -> None: """Test that Claude 4.5 models get default max_tokens=8000 when not configured.""" @@ -2420,7 +2420,7 @@ async def capture_chat_completions(**kwargs: Any) -> Any: @pytest.mark.asyncio async def test_claude_45_respects_configured_max_tokens( self, - sample_agent_execution: AgentExecution, + sample_agent_execution: WorkloadExecution, mocker: MockerFixture, ) -> None: """Test that explicitly configured max_tokens overrides the Claude 4.5 default.""" @@ -2502,10 +2502,10 @@ async def test_binary_classification_scoring( BinaryClassificationEvaluator, ) - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output={"class": predicted}, - agent_trace=[], + workload_output={"class": predicted}, + workload_trace=[], ) config = { "name": "BinaryClassificationTest", @@ -2557,10 +2557,10 @@ async def test_multiclass_classification_scoring( MulticlassClassificationEvaluator, ) - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output={"class": predicted}, - agent_trace=[], + workload_output={"class": predicted}, + workload_trace=[], ) config = { "name": "MulticlassClassificationTest", @@ -2588,10 +2588,10 @@ async def test_multiclass_classification_invalid_expected_class(self) -> None: ) from uipath.eval.models.models import ErrorEvaluationResult - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output={"class": "cat"}, - agent_trace=[], + workload_output={"class": "cat"}, + workload_trace=[], ) config = { "name": "MulticlassClassificationTest", @@ -2615,10 +2615,10 @@ async def test_multiclass_classification_invalid_predicted_class(self) -> None: ) from uipath.eval.models.models import ErrorEvaluationResult - execution = AgentExecution( + execution = WorkloadExecution( agent_input={}, - agent_output={"class": "fish"}, - agent_trace=[], + workload_output={"class": "fish"}, + workload_trace=[], ) config = { "name": "MulticlassClassificationTest", diff --git a/packages/uipath/tests/evaluators/test_legacy_target_output_key_paths.py b/packages/uipath/tests/evaluators/test_legacy_target_output_key_paths.py index 01d2235c7..66d5b59cf 100644 --- a/packages/uipath/tests/evaluators/test_legacy_target_output_key_paths.py +++ b/packages/uipath/tests/evaluators/test_legacy_target_output_key_paths.py @@ -14,9 +14,9 @@ ) from uipath.eval.evaluators.base_legacy_evaluator import LegacyEvaluationCriteria from uipath.eval.models.models import ( - AgentExecution, LegacyEvaluatorCategory, LegacyEvaluatorType, + WorkloadExecution, ) NESTED_OUTPUT = { @@ -74,7 +74,9 @@ async def test_nested_dot_path_summary_status(self) -> None: **_make_exact_match_params("summary.status") ) result = await evaluator.evaluate( - AgentExecution(agent_input={}, agent_trace=[], agent_output=NESTED_OUTPUT), + WorkloadExecution( + agent_input={}, workload_trace=[], workload_output=NESTED_OUTPUT + ), LegacyEvaluationCriteria( expected_output=NESTED_OUTPUT, expected_agent_behavior="" ), @@ -87,7 +89,9 @@ async def test_nested_dot_path_customer_address_city(self) -> None: **_make_exact_match_params("customer.address.city") ) result = await evaluator.evaluate( - AgentExecution(agent_input={}, agent_trace=[], agent_output=NESTED_OUTPUT), + WorkloadExecution( + agent_input={}, workload_trace=[], workload_output=NESTED_OUTPUT + ), LegacyEvaluationCriteria( expected_output=NESTED_OUTPUT, expected_agent_behavior="" ), @@ -100,7 +104,9 @@ async def test_array_index_items_0_name(self) -> None: **_make_exact_match_params("items[0].name") ) result = await evaluator.evaluate( - AgentExecution(agent_input={}, agent_trace=[], agent_output=NESTED_OUTPUT), + WorkloadExecution( + agent_input={}, workload_trace=[], workload_output=NESTED_OUTPUT + ), LegacyEvaluationCriteria( expected_output=NESTED_OUTPUT, expected_agent_behavior="" ), @@ -111,7 +117,9 @@ async def test_array_index_items_0_name(self) -> None: async def test_array_index_tags_1(self) -> None: evaluator = LegacyExactMatchEvaluator(**_make_exact_match_params("tags[1]")) result = await evaluator.evaluate( - AgentExecution(agent_input={}, agent_trace=[], agent_output=NESTED_OUTPUT), + WorkloadExecution( + agent_input={}, workload_trace=[], workload_output=NESTED_OUTPUT + ), LegacyEvaluationCriteria( expected_output=NESTED_OUTPUT, expected_agent_behavior="" ), @@ -128,8 +136,8 @@ async def test_nested_path_mismatch_fails(self) -> None: modified_summary = {**original_summary, "status": "pending"} different_output = {**NESTED_OUTPUT, "summary": modified_summary} result = await evaluator.evaluate( - AgentExecution( - agent_input={}, agent_trace=[], agent_output=different_output + WorkloadExecution( + agent_input={}, workload_trace=[], workload_output=different_output ), LegacyEvaluationCriteria( expected_output=NESTED_OUTPUT, expected_agent_behavior="" @@ -144,7 +152,9 @@ async def test_missing_path_in_both_passes(self) -> None: **_make_exact_match_params("nonexistent.path") ) result = await evaluator.evaluate( - AgentExecution(agent_input={}, agent_trace=[], agent_output=NESTED_OUTPUT), + WorkloadExecution( + agent_input={}, workload_trace=[], workload_output=NESTED_OUTPUT + ), LegacyEvaluationCriteria( expected_output=NESTED_OUTPUT, expected_agent_behavior="" ), @@ -156,7 +166,9 @@ async def test_flat_key_backward_compatible(self) -> None: """Flat key like 'order_id' still works as before.""" evaluator = LegacyExactMatchEvaluator(**_make_exact_match_params("order_id")) result = await evaluator.evaluate( - AgentExecution(agent_input={}, agent_trace=[], agent_output=NESTED_OUTPUT), + WorkloadExecution( + agent_input={}, workload_trace=[], workload_output=NESTED_OUTPUT + ), LegacyEvaluationCriteria( expected_output=NESTED_OUTPUT, expected_agent_behavior="" ), @@ -172,7 +184,9 @@ async def test_json_similarity_with_target_key_summary(self) -> None: """JSON similarity on nested 'summary' object should score 100.""" evaluator = LegacyJsonSimilarityEvaluator(**_make_json_sim_params("summary")) result = await evaluator.evaluate( - AgentExecution(agent_input={}, agent_trace=[], agent_output=NESTED_OUTPUT), + WorkloadExecution( + agent_input={}, workload_trace=[], workload_output=NESTED_OUTPUT + ), LegacyEvaluationCriteria( expected_output=NESTED_OUTPUT, expected_agent_behavior="" ), @@ -184,7 +198,9 @@ async def test_json_similarity_with_target_key_customer(self) -> None: """JSON similarity on nested 'customer' object should score 100.""" evaluator = LegacyJsonSimilarityEvaluator(**_make_json_sim_params("customer")) result = await evaluator.evaluate( - AgentExecution(agent_input={}, agent_trace=[], agent_output=NESTED_OUTPUT), + WorkloadExecution( + agent_input={}, workload_trace=[], workload_output=NESTED_OUTPUT + ), LegacyEvaluationCriteria( expected_output=NESTED_OUTPUT, expected_agent_behavior="" ), @@ -196,7 +212,9 @@ async def test_json_similarity_with_target_key_items_0(self) -> None: """JSON similarity on items[0] should score 100 when matching.""" evaluator = LegacyJsonSimilarityEvaluator(**_make_json_sim_params("items[0]")) result = await evaluator.evaluate( - AgentExecution(agent_input={}, agent_trace=[], agent_output=NESTED_OUTPUT), + WorkloadExecution( + agent_input={}, workload_trace=[], workload_output=NESTED_OUTPUT + ), LegacyEvaluationCriteria( expected_output=NESTED_OUTPUT, expected_agent_behavior="" ), @@ -208,7 +226,9 @@ async def test_json_similarity_wildcard_unchanged(self) -> None: """Wildcard '*' should compare the full output (backward compatible).""" evaluator = LegacyJsonSimilarityEvaluator(**_make_json_sim_params("*")) result = await evaluator.evaluate( - AgentExecution(agent_input={}, agent_trace=[], agent_output=NESTED_OUTPUT), + WorkloadExecution( + agent_input={}, workload_trace=[], workload_output=NESTED_OUTPUT + ), LegacyEvaluationCriteria( expected_output=NESTED_OUTPUT, expected_agent_behavior="" ), @@ -224,8 +244,8 @@ async def test_json_similarity_partial_match_with_target(self) -> None: "summary": {"total": 45.0, "item_count": 3, "status": "completed"}, } result = await evaluator.evaluate( - AgentExecution( - agent_input={}, agent_trace=[], agent_output=different_summary + WorkloadExecution( + agent_input={}, workload_trace=[], workload_output=different_summary ), LegacyEvaluationCriteria( expected_output=NESTED_OUTPUT, expected_agent_behavior="" diff --git a/packages/uipath/tests/evaluators/test_line_by_line_utils.py b/packages/uipath/tests/evaluators/test_line_by_line_utils.py index 89449600c..dcf06f5f0 100644 --- a/packages/uipath/tests/evaluators/test_line_by_line_utils.py +++ b/packages/uipath/tests/evaluators/test_line_by_line_utils.py @@ -13,7 +13,7 @@ LineByLineEvaluationDetails, LineEvaluationDetail, ) -from uipath.eval.models.models import AgentExecution, NumericEvaluationResult +from uipath.eval.models.models import NumericEvaluationResult, WorkloadExecution class TestSplitIntoLines: @@ -154,10 +154,10 @@ async def test_evaluate_lines_all_match(self): actual_lines = ["line1", "line2", "line3"] expected_lines = ["line1", "line2", "line3"] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"test": "input"}, - agent_output="line1\nline2\nline3", - agent_trace=[], + workload_output="line1\nline2\nline3", + workload_trace=[], ) async def mock_evaluate(execution, criteria): @@ -170,7 +170,7 @@ def mock_create_criteria(expected_line): actual_lines=actual_lines, expected_lines=expected_lines, target_output_key="*", - agent_execution=agent_execution, + workload_execution=workload_execution, evaluate_fn=mock_evaluate, create_line_criteria_fn=mock_create_criteria, ) @@ -186,10 +186,10 @@ async def test_evaluate_lines_unequal_counts(self): actual_lines = ["line1", "line2"] expected_lines = ["line1", "line2", "line3", "line4"] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"test": "input"}, - agent_output="line1\nline2", - agent_trace=[], + workload_output="line1\nline2", + workload_trace=[], ) async def mock_evaluate(execution, criteria): @@ -202,7 +202,7 @@ def mock_create_criteria(expected_line): actual_lines=actual_lines, expected_lines=expected_lines, target_output_key="*", - agent_execution=agent_execution, + workload_execution=workload_execution, evaluate_fn=mock_evaluate, create_line_criteria_fn=mock_create_criteria, ) @@ -229,15 +229,15 @@ async def test_evaluate_lines_with_target_output_key(self): actual_lines = ["line1", "line2"] expected_lines = ["line1", "line2"] - agent_execution = AgentExecution( + workload_execution = WorkloadExecution( agent_input={"test": "input"}, - agent_output={"result": "line1\nline2"}, - agent_trace=[], + workload_output={"result": "line1\nline2"}, + workload_trace=[], ) async def mock_evaluate(execution, criteria): # Verify the execution has the wrapped structure - assert "result" in execution.agent_output + assert "result" in execution.workload_output return NumericEvaluationResult(score=1.0, details="match") def mock_create_criteria(expected_line): @@ -247,7 +247,7 @@ def mock_create_criteria(expected_line): actual_lines=actual_lines, expected_lines=expected_lines, target_output_key="result", - agent_execution=agent_execution, + workload_execution=workload_execution, evaluate_fn=mock_evaluate, create_line_criteria_fn=mock_create_criteria, ) diff --git a/packages/uipath/tests/evaluators/test_workload_execution_deprecation.py b/packages/uipath/tests/evaluators/test_workload_execution_deprecation.py new file mode 100644 index 000000000..98ccddcbf --- /dev/null +++ b/packages/uipath/tests/evaluators/test_workload_execution_deprecation.py @@ -0,0 +1,87 @@ +"""Tests for the ``AgentExecution`` -> ``WorkloadExecution`` rename (v2.12.0). + +Covers the soft-deprecated class-name shim, the intentional hard break on the +renamed model fields, and the positional-dispatch contract that keeps custom +evaluators using the old ``agent_execution`` parameter name working. +""" + +import importlib +import uuid + +import pytest +from pydantic import ValidationError + +from uipath.eval.evaluators.exact_match_evaluator import ExactMatchEvaluator +from uipath.eval.models import NumericEvaluationResult, WorkloadExecution + + +@pytest.mark.parametrize( + "module_path", + ["uipath.eval.models", "uipath.eval.models.models"], +) +def test_agent_execution_alias_warns_and_resolves(module_path: str) -> None: + """Accessing the legacy ``AgentExecution`` name warns and returns the new class.""" + module = importlib.import_module(module_path) + + with pytest.warns(DeprecationWarning, match="AgentExecution is deprecated"): + legacy = module.AgentExecution + + assert legacy is WorkloadExecution + + +@pytest.mark.parametrize( + "module_path", + ["uipath.eval.models", "uipath.eval.models.models"], +) +def test_unknown_attribute_raises(module_path: str) -> None: + """Unknown attributes still raise ``AttributeError`` via the module ``__getattr__``.""" + module = importlib.import_module(module_path) + + with pytest.raises(AttributeError, match="does_not_exist"): + _ = module.does_not_exist + + +def test_old_field_names_are_a_hard_break() -> None: + """The renamed fields are NOT aliased — old field names raise ValidationError. + + This is the intentional breaking change in v2.12.0 (no field-level back-compat + shim); only the class *name* is soft-deprecated. + """ + with pytest.raises(ValidationError): + WorkloadExecution( + agent_input={}, + agent_output={"result": "ok"}, # type: ignore[call-arg] + agent_trace=[], + ) + + +async def test_old_param_name_still_dispatches_positionally() -> None: + """A custom evaluator overriding ``evaluate`` with the old ``agent_execution`` + parameter name keeps working, because the base dispatches positionally. + """ + + class CustomEvaluator(ExactMatchEvaluator): + # Deliberately uses the pre-2.12.0 parameter name. + async def evaluate(self, agent_execution, evaluation_criteria): + return await super().evaluate(agent_execution, evaluation_criteria) + + evaluator = CustomEvaluator.model_validate( + { + "evaluatorConfig": {"name": "CustomExactMatch", "case_sensitive": True}, + "id": str(uuid.uuid4()), + } + ) + workload_execution = WorkloadExecution( + agent_input={}, + workload_output={"output": "Test output"}, + workload_trace=[], + ) + raw_criteria = {"expected_output": {"output": "Test output"}} + + # Called positionally (as the runtime does) — must not raise TypeError. + result = await evaluator.validate_and_evaluate_criteria( + workload_execution, raw_criteria + ) + + assert isinstance(result, NumericEvaluationResult) + assert result.score == 1.0 diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index dc9d3e918..490985180 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.18" +version = "2.12.0" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 47b0aa2686e8471b495b5b685d01be1e7ae1daab Mon Sep 17 00:00:00 2001 From: ctiliescuuipath Date: Wed, 1 Jul 2026 11:44:36 +0300 Subject: [PATCH 137/191] feat(platform): use process_uuid for trace baggage and bump versions (#1779) Co-authored-by: Claude Opus 4.6 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/chat/llm_trace_context.py | 4 ++-- .../uipath-platform/tests/services/test_llm_trace_context.py | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 4 ++-- packages/uipath/uv.lock | 4 ++-- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 3b44e14cc..ee43dd4f2 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.85" +version = "0.1.86" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py b/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py index b9c1f74a0..916ced322 100644 --- a/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py +++ b/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py @@ -42,8 +42,8 @@ def build_trace_context_headers( baggage_parts.append(f"folderKey={folder_key}") if agent_id := resolve_project_id(): baggage_parts.append(f"agentId={agent_id}") - if process_key := UiPathConfig.process_key: - baggage_parts.append(f"processKey={process_key}") + if process_uuid := UiPathConfig.process_uuid: + baggage_parts.append(f"processKey={process_uuid}") if baggage_parts: headers["x-uipath-tracebaggage"] = ",".join(baggage_parts) diff --git a/packages/uipath-platform/tests/services/test_llm_trace_context.py b/packages/uipath-platform/tests/services/test_llm_trace_context.py index 2b078cd62..234bce050 100644 --- a/packages/uipath-platform/tests/services/test_llm_trace_context.py +++ b/packages/uipath-platform/tests/services/test_llm_trace_context.py @@ -121,7 +121,7 @@ def test_all_env_vars_present(self) -> None: env = { "UIPATH_FOLDER_KEY": "folder-abc", ENV_PROJECT_KEY: "agent-123", - "UIPATH_PROCESS_KEY": "process-789", + "UIPATH_PROCESS_UUID": "process-789", } with patch.dict(os.environ, env, clear=True): headers = build_trace_context_headers() diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 0f6f56edb..247e0ab04 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.85" +version = "0.1.86" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 9abd4a08b..dbbcb2085 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.12.0" +version = "2.12.1" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.26, <0.6.0", "uipath-runtime>=0.11.5, <0.12.0", - "uipath-platform>=0.1.83, <0.2.0", + "uipath-platform>=0.1.86, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 490985180..e5f4b6647 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.12.0" +version = "2.12.1" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.85" +version = "0.1.86" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 11130141b78a9c6e24779363c59838797d2232d0 Mon Sep 17 00:00:00 2001 From: Vlad Cimpeanu Date: Wed, 1 Jul 2026 13:32:38 +0300 Subject: [PATCH 138/191] chore: consolidate uipath._utils.constants into uipath-platform.common.constants (#1707) Co-authored-by: Claude Opus 4.8 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/common/constants.py | 3 + packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 4 +- packages/uipath/src/uipath/_cli/__init__.py | 2 +- .../uipath/_cli/_evals/_progress_reporter.py | 8 +- .../uipath/src/uipath/_cli/_utils/_common.py | 3 +- .../src/uipath/_cli/_utils/_studio_project.py | 14 +-- packages/uipath/src/uipath/_utils/_auth.py | 5 +- .../src/uipath/_utils/_request_override.py | 6 +- .../uipath/src/uipath/_utils/_user_agent.py | 2 +- .../uipath/src/uipath/_utils/constants.py | 97 +++---------------- .../eval/evaluators/legacy_evaluator_utils.py | 2 +- .../legacy_llm_as_judge_evaluator.py | 2 +- .../evaluators/legacy_trajectory_evaluator.py | 2 +- .../uipath/src/uipath/telemetry/_track.py | 3 +- .../tests/sdk/test_utils_constants_shim.py | 80 +++++++++++++++ packages/uipath/uv.lock | 4 +- sonar-project.properties | 2 + 19 files changed, 128 insertions(+), 115 deletions(-) create mode 100644 packages/uipath/tests/sdk/test_utils_constants_shim.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index ee43dd4f2..505d139f0 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.86" +version = "0.1.87" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/common/constants.py b/packages/uipath-platform/src/uipath/platform/common/constants.py index fa710993c..162402aa8 100644 --- a/packages/uipath-platform/src/uipath/platform/common/constants.py +++ b/packages/uipath-platform/src/uipath/platform/common/constants.py @@ -69,6 +69,9 @@ DROPBOX_DATA_SOURCE = "#UiPath.Vdbs.Domain.Api.V20Models.DropboxDataSource" GOOGLE_DRIVE_DATA_SOURCE = "#UiPath.Vdbs.Domain.Api.V20Models.GoogleDriveDataSource" ONEDRIVE_DATA_SOURCE = "#UiPath.Vdbs.Domain.Api.V20Models.OneDriveDataSource" +LLMV3Mini_REQUEST = "#UiPath.Vdbs.Domain.Api.V20Models.LLMV3MiniPreProcessingRequest" +LLMV4_REQUEST = "#UiPath.Vdbs.Domain.Api.V20Models.LLMV4PreProcessingRequest" +NativeV1_REQUEST = "#UiPath.Vdbs.Domain.Api.V20Models.NativeV1PreProcessingRequest" # Local storage diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 247e0ab04..5d36225ba 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.86" +version = "0.1.87" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index dbbcb2085..ee98b97ad 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.12.1" +version = "2.12.2" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.26, <0.6.0", "uipath-runtime>=0.11.5, <0.12.0", - "uipath-platform>=0.1.86, <0.2.0", + "uipath-platform>=0.1.87, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/src/uipath/_cli/__init__.py b/packages/uipath/src/uipath/_cli/__init__.py index d8d3a8a46..e48b3d4d3 100644 --- a/packages/uipath/src/uipath/_cli/__init__.py +++ b/packages/uipath/src/uipath/_cli/__init__.py @@ -7,7 +7,7 @@ from uipath._cli._utils._context import CliContext from uipath._utils._logs import setup_logging -from uipath._utils.constants import DOTENV_FILE +from uipath.platform.common.constants import DOTENV_FILE # Windows console uses codepages (e.g. cp1252) that can't encode Unicode # characters used by Rich spinners (Braille) and emoji output. diff --git a/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py b/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py index af7825cab..325d53f50 100644 --- a/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py +++ b/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py @@ -16,11 +16,6 @@ from uipath._cli._utils._console import ConsoleLogger from uipath._utils import Endpoint, RequestSpec -from uipath._utils.constants import ( - ENV_EVAL_BACKEND_URL, - ENV_TENANT_ID, - HEADER_INTERNAL_TENANT_ID, -) from uipath.core.events import EventBus from uipath.eval.evaluators import ( BaseEvaluator, @@ -39,9 +34,12 @@ from uipath.platform import UiPath from uipath.platform.common import UiPathConfig from uipath.platform.common.constants import ( + ENV_EVAL_BACKEND_URL, + ENV_TENANT_ID, ENV_UIPATH_AGENT_ID, ENV_UIPATH_PROJECT_FILES_SOURCE, ENV_UIPATH_PROJECT_ID, + HEADER_INTERNAL_TENANT_ID, ) logger = logging.getLogger(__name__) diff --git a/packages/uipath/src/uipath/_cli/_utils/_common.py b/packages/uipath/src/uipath/_cli/_utils/_common.py index da6dec36c..b67d5071c 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_common.py +++ b/packages/uipath/src/uipath/_cli/_utils/_common.py @@ -13,9 +13,8 @@ ResourceOverwriteParser, UiPathConfig, ) -from uipath.platform.common.constants import ENV_BASE_URL +from uipath.platform.common.constants import ENV_BASE_URL, ENV_UIPATH_ACCESS_TOKEN -from ..._utils.constants import ENV_UIPATH_ACCESS_TOKEN from ..models.runtime_schema import EntryPoint from ..spinner import Spinner from ._console import ConsoleLogger diff --git a/packages/uipath/src/uipath/_cli/_utils/_studio_project.py b/packages/uipath/src/uipath/_cli/_utils/_studio_project.py index f70c3e96a..eb9e06d29 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_studio_project.py +++ b/packages/uipath/src/uipath/_cli/_utils/_studio_project.py @@ -8,19 +8,19 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator -from uipath._utils.constants import ( - ENV_TENANT_ID, - HEADER_SW_LOCK_KEY, - HEADER_TENANT_ID, - PYTHON_CONFIGURATION_FILE, - STUDIO_METADATA_FILE, -) from uipath.platform import UiPath from uipath.platform.common import ( ResourceOverwrite, ResourceOverwriteParser, UiPathConfig, ) +from uipath.platform.common.constants import ( + ENV_TENANT_ID, + HEADER_SW_LOCK_KEY, + HEADER_TENANT_ID, + PYTHON_CONFIGURATION_FILE, + STUDIO_METADATA_FILE, +) from uipath.platform.errors import EnrichedException from uipath.tracing import traced diff --git a/packages/uipath/src/uipath/_utils/_auth.py b/packages/uipath/src/uipath/_utils/_auth.py index 873c13edc..18bd36e8a 100644 --- a/packages/uipath/src/uipath/_utils/_auth.py +++ b/packages/uipath/src/uipath/_utils/_auth.py @@ -4,9 +4,8 @@ from pathlib import Path from typing import Optional -from uipath.platform.common.constants import DOTENV_FILE - -from .constants import ( +from uipath.platform.common.constants import ( + DOTENV_FILE, ENV_BASE_URL, ENV_UIPATH_ACCESS_TOKEN, ENV_UNATTENDED_USER_ACCESS_TOKEN, diff --git a/packages/uipath/src/uipath/_utils/_request_override.py b/packages/uipath/src/uipath/_utils/_request_override.py index 07d5e2ebc..f2bc3c062 100644 --- a/packages/uipath/src/uipath/_utils/_request_override.py +++ b/packages/uipath/src/uipath/_utils/_request_override.py @@ -1,7 +1,11 @@ from base64 import b64encode from typing import Optional -from .constants import HEADER_FOLDER_KEY, HEADER_FOLDER_PATH, HEADER_FOLDER_PATH_ENCODED +from uipath.platform.common.constants import ( + HEADER_FOLDER_KEY, + HEADER_FOLDER_PATH, + HEADER_FOLDER_PATH_ENCODED, +) def folder_path_header(folder_path: str) -> dict[str, str]: diff --git a/packages/uipath/src/uipath/_utils/_user_agent.py b/packages/uipath/src/uipath/_utils/_user_agent.py index dcf28d3e0..0b930d917 100644 --- a/packages/uipath/src/uipath/_utils/_user_agent.py +++ b/packages/uipath/src/uipath/_utils/_user_agent.py @@ -1,6 +1,6 @@ import importlib -from .constants import HEADER_USER_AGENT +from uipath.platform.common.constants import HEADER_USER_AGENT def user_agent_value(specific_component: str) -> str: diff --git a/packages/uipath/src/uipath/_utils/constants.py b/packages/uipath/src/uipath/_utils/constants.py index e5a2c06da..186a55352 100644 --- a/packages/uipath/src/uipath/_utils/constants.py +++ b/packages/uipath/src/uipath/_utils/constants.py @@ -1,89 +1,16 @@ -# Environment variables -DOTENV_FILE = ".env" -ENV_BASE_URL = "UIPATH_URL" -ENV_EVAL_BACKEND_URL = "UIPATH_EVAL_BACKEND_URL" -ENV_UNATTENDED_USER_ACCESS_TOKEN = "UNATTENDED_USER_ACCESS_TOKEN" -ENV_UIPATH_ACCESS_TOKEN = "UIPATH_ACCESS_TOKEN" -ENV_FOLDER_KEY = "UIPATH_FOLDER_KEY" -ENV_FOLDER_PATH = "UIPATH_FOLDER_PATH" -ENV_JOB_KEY = "UIPATH_JOB_KEY" -ENV_JOB_ID = "UIPATH_JOB_ID" -ENV_ROBOT_KEY = "UIPATH_ROBOT_KEY" -ENV_TENANT_ID = "UIPATH_TENANT_ID" -ENV_TENANT_NAME = "UIPATH_TENANT_NAME" -ENV_ORGANIZATION_ID = "UIPATH_ORGANIZATION_ID" -ENV_TELEMETRY_ENABLED = "UIPATH_TELEMETRY_ENABLED" -ENV_TRACING_ENABLED = "UIPATH_TRACING_ENABLED" -ENV_UIPATH_PROJECT_ID = "UIPATH_PROJECT_ID" -ENV_PROJECT_KEY = "PROJECT_KEY" -ENV_PROCESS_KEY = "UIPATH_PROCESS_KEY" -ENV_UIPATH_PROCESS_UUID = "UIPATH_PROCESS_UUID" -ENV_UIPATH_TRACE_ID = "UIPATH_TRACE_ID" -ENV_UIPATH_PROCESS_VERSION = "UIPATH_PROCESS_VERSION" +"""Deprecated alias for ``uipath.platform.common.constants``. -# Headers -HEADER_AGENTHUB_CONFIG = "x-uipath-agenthub-config" -HEADER_FOLDER_KEY = "x-uipath-folderkey" -HEADER_FOLDER_PATH = "x-uipath-folderpath" -HEADER_FOLDER_PATH_ENCODED = "x-uipath-folderpath-encoded" -HEADER_INTERNAL_ACCOUNT_ID = "x-uipath-internal-accountid" -HEADER_INTERNAL_TENANT_ID = "x-uipath-internal-tenantid" -HEADER_JOB_KEY = "x-uipath-jobkey" -HEADER_LLMGATEWAY_BYO_CONNECTION_ID = "x-uipath-llmgateway-byoisconnectionid" -HEADER_PROCESS_KEY = "x-uipath-processkey" -HEADER_SW_LOCK_KEY = "x-uipath-sw-lockkey" -HEADER_TENANT_ID = "x-uipath-tenantid" -HEADER_TRACE_ID = "x-uipath-traceid" -HEADER_USER_AGENT = "x-uipath-user-agent" +This module is kept as a backward-compatibility shim so existing imports keep +working. New code should import from ``uipath.platform.common.constants``. +""" -# Data sources (request types) -ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE_REQUEST = ( - "#UiPath.Vdbs.Domain.Api.V20Models.StorageBucketDataSourceRequest" -) -CONFLUENCE_DATA_SOURCE_REQUEST = ( - "#UiPath.Vdbs.Domain.Api.V20Models.ConfluenceDataSourceRequest" -) -DROPBOX_DATA_SOURCE_REQUEST = ( - "#UiPath.Vdbs.Domain.Api.V20Models.DropboxDataSourceRequest" -) -GOOGLE_DRIVE_DATA_SOURCE_REQUEST = ( - "#UiPath.Vdbs.Domain.Api.V20Models.GoogleDriveDataSourceRequest" -) -ONEDRIVE_DATA_SOURCE_REQUEST = ( - "#UiPath.Vdbs.Domain.Api.V20Models.OneDriveDataSourceRequest" -) - -# Data sources -ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE = ( - "#UiPath.Vdbs.Domain.Api.V20Models.StorageBucketDataSource" -) -CONFLUENCE_DATA_SOURCE = "#UiPath.Vdbs.Domain.Api.V20Models.ConfluenceDataSource" -DROPBOX_DATA_SOURCE = "#UiPath.Vdbs.Domain.Api.V20Models.DropboxDataSource" -GOOGLE_DRIVE_DATA_SOURCE = "#UiPath.Vdbs.Domain.Api.V20Models.GoogleDriveDataSource" -ONEDRIVE_DATA_SOURCE = "#UiPath.Vdbs.Domain.Api.V20Models.OneDriveDataSource" +import warnings as _warnings -# Preprocessing request types -LLMV3Mini_REQUEST = "#UiPath.Vdbs.Domain.Api.V20Models.LLMV3MiniPreProcessingRequest" -LLMV4_REQUEST = "#UiPath.Vdbs.Domain.Api.V20Models.LLMV4PreProcessingRequest" -NativeV1_REQUEST = "#UiPath.Vdbs.Domain.Api.V20Models.NativeV1PreProcessingRequest" +from uipath.platform.common.constants import * # noqa: F401,F403 - -# Local storage -TEMP_ATTACHMENTS_FOLDER = "uipath_attachments" - -# LLM models -COMMUNITY_agents_SUFFIX = "-community-agents" - -# File names -PYTHON_CONFIGURATION_FILE = "pyproject.toml" -UIPATH_CONFIG_FILE = "uipath.json" -UIPATH_BINDINGS_FILE = "bindings.json" -ENTRY_POINTS_FILE = "entry-points.json" -STUDIO_METADATA_FILE = "studio_metadata.json" - - -# Folder names -LEGACY_EVAL_FOLDER = "evals" -EVALS_FOLDER = "evaluations" -# Evaluators -CUSTOM_EVALUATOR_PREFIX = "file://" +_warnings.warn( + "uipath._utils.constants is deprecated and will be removed in a future " + "release; import from uipath.platform.common.constants instead.", + FutureWarning, + stacklevel=2, +) diff --git a/packages/uipath/src/uipath/eval/evaluators/legacy_evaluator_utils.py b/packages/uipath/src/uipath/eval/evaluators/legacy_evaluator_utils.py index b8c20f372..4bd241eaa 100644 --- a/packages/uipath/src/uipath/eval/evaluators/legacy_evaluator_utils.py +++ b/packages/uipath/src/uipath/eval/evaluators/legacy_evaluator_utils.py @@ -3,7 +3,7 @@ import json from typing import Any, Optional -from ..._utils.constants import COMMUNITY_agents_SUFFIX +from uipath.platform.common.constants import COMMUNITY_agents_SUFFIX def clean_model_name(model: str) -> str: diff --git a/packages/uipath/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py index ec0662ae8..f37b62dba 100644 --- a/packages/uipath/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py @@ -8,8 +8,8 @@ from uipath.platform import UiPath from uipath.platform.chat import UiPathLlmChatService from uipath.platform.chat.llm_gateway import RequiredToolChoice +from uipath.platform.common.constants import COMMUNITY_agents_SUFFIX -from ..._utils.constants import COMMUNITY_agents_SUFFIX from .._execution_context import eval_set_run_id_context from .._helpers.helpers import is_empty_value from .._helpers.output_path import resolve_output_path diff --git a/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py index b7cb627e8..9cc516062 100644 --- a/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py @@ -9,8 +9,8 @@ from uipath.platform import UiPath from uipath.platform.chat import UiPathLlmChatService from uipath.platform.chat.llm_gateway import RequiredToolChoice +from uipath.platform.common.constants import COMMUNITY_agents_SUFFIX -from ..._utils.constants import COMMUNITY_agents_SUFFIX from .._execution_context import eval_set_run_id_context from .._helpers.evaluators_helpers import trace_to_str from .._helpers.helpers import is_empty_value diff --git a/packages/uipath/src/uipath/telemetry/_track.py b/packages/uipath/src/uipath/telemetry/_track.py index 3e585f8e5..603796a17 100644 --- a/packages/uipath/src/uipath/telemetry/_track.py +++ b/packages/uipath/src/uipath/telemetry/_track.py @@ -9,12 +9,13 @@ from opentelemetry.sdk._logs import LoggingHandler from opentelemetry.util.types import AnyValue -from .._utils.constants import ( +from uipath.platform.common.constants import ( ENV_BASE_URL, ENV_ORGANIZATION_ID, ENV_TELEMETRY_ENABLED, ENV_TENANT_ID, ) + from ._constants import ( _APP_INSIGHTS_EVENT_MARKER_ATTRIBUTE, _APP_NAME, diff --git a/packages/uipath/tests/sdk/test_utils_constants_shim.py b/packages/uipath/tests/sdk/test_utils_constants_shim.py new file mode 100644 index 000000000..b5d038aa8 --- /dev/null +++ b/packages/uipath/tests/sdk/test_utils_constants_shim.py @@ -0,0 +1,80 @@ +"""Regression tests for the uipath._utils.constants deprecation shim. + +The shim re-exports from uipath.platform.common.constants and emits a +FutureWarning so external consumers can migrate. Internal callsites are +already on the canonical path; these tests pin the shim's behavior so it +keeps working for downstream code. +""" + +import importlib +import sys +import warnings + + +def _reload_shim(): + """Force a fresh import of the shim so FutureWarning re-fires.""" + for name in [ + "uipath._utils.constants", + "uipath.platform.common.constants", + ]: + sys.modules.pop(name, None) + return importlib.import_module("uipath._utils.constants") + + +def test_shim_emits_future_warning(): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _reload_shim() + + shim_warnings = [ + w + for w in caught + if issubclass(w.category, FutureWarning) + and "uipath._utils.constants" in str(w.message) + and "uipath.platform.common.constants" in str(w.message) + ] + assert len(shim_warnings) == 1, ( + f"expected exactly one shim FutureWarning, got {len(shim_warnings)}: " + f"{[str(w.message) for w in caught]}" + ) + + +def test_shim_re_exports_canonical_symbols(): + shim = _reload_shim() + canonical = importlib.import_module("uipath.platform.common.constants") + + # Sample a representative set: env vars, headers, mixed-case symbols, + # file constants, data-source magic strings. + sample = [ + "DOTENV_FILE", + "ENV_BASE_URL", + "ENV_TENANT_ID", + "HEADER_INTERNAL_TENANT_ID", + "HEADER_INTERNAL_ACCOUNT_ID", + "HEADER_USER_AGENT", + "LLMV3Mini_REQUEST", + "LLMV4_REQUEST", + "NativeV1_REQUEST", + "COMMUNITY_agents_SUFFIX", + "PYTHON_CONFIGURATION_FILE", + "ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE_REQUEST", + ] + for name in sample: + assert hasattr(shim, name), f"shim missing {name}" + assert hasattr(canonical, name), f"canonical missing {name}" + assert getattr(shim, name) == getattr(canonical, name), ( + f"value drift for {name}: shim={getattr(shim, name)!r} " + f"canonical={getattr(canonical, name)!r}" + ) + + +def test_shim_does_not_leak_warnings_module_via_star_import(): + """The shim binds `warnings` under a private alias to keep it out of + `from uipath._utils.constants import *`.""" + _reload_shim() + ns: dict[str, object] = {} + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + exec("from uipath._utils.constants import *", ns) + assert "warnings" not in ns + assert "_warnings" not in ns diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index e5f4b6647..dae2ffe65 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.12.1" +version = "2.12.2" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.86" +version = "0.1.87" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, diff --git a/sonar-project.properties b/sonar-project.properties index 939a3a112..3a4a84529 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -10,4 +10,6 @@ sonar.python.coverage.reportPaths=packages/uipath/coverage.xml,packages/uipath-c sonar.exclusions=**/samples/**,**/testcases/**,**/template/**,**/_resources/** +sonar.cpd.exclusions=**/__init__.py + sonar.sourceEncoding=UTF-8 From e00f3f4bdbc84af1e0d3ce700e6fdda067befe5a Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan <84002867+viswa-uipath@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:13:59 +0530 Subject: [PATCH 139/191] feat(governance): non-blocking LiveTrackEventDispatcher for /runtime/log (#1776) Co-authored-by: Claude Opus 4.7 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../uipath/platform/governance/__init__.py | 9 + .../_live_track_event_dispatcher.py | 287 +++++++++ .../test_live_track_event_dispatcher.py | 581 ++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 6 files changed, 880 insertions(+), 3 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/governance/_live_track_event_dispatcher.py create mode 100644 packages/uipath-platform/tests/services/test_live_track_event_dispatcher.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 505d139f0..1358b712e 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.87" +version = "0.1.88" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/governance/__init__.py b/packages/uipath-platform/src/uipath/platform/governance/__init__.py index e1f587606..1d9bdf7ee 100644 --- a/packages/uipath-platform/src/uipath/platform/governance/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/governance/__init__.py @@ -10,6 +10,15 @@ from .compensate import FiredRule, GovernRequest from .policy import PolicyContext, PolicyResponse +# ``_live_track_event_dispatcher.LiveTrackEventDispatcher`` is intentionally +# **not** re-exported. It is host-wiring glue (the runtime sink's +# non-blocking ``track_event`` adapter), not a customer-facing API. +# Internal callers import it via the explicit private path: +# +# from uipath.platform.governance._live_track_event_dispatcher import ( +# LiveTrackEventDispatcher, +# ) + __all__ = [ "FiredRule", "GovernRequest", diff --git a/packages/uipath-platform/src/uipath/platform/governance/_live_track_event_dispatcher.py b/packages/uipath-platform/src/uipath/platform/governance/_live_track_event_dispatcher.py new file mode 100644 index 000000000..432fd91c5 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/governance/_live_track_event_dispatcher.py @@ -0,0 +1,287 @@ +"""Non-blocking dispatcher for governance track-event telemetry. + +Wraps :meth:`UiPathPlatformGovernanceProvider.track_event_async` on a +private background ``asyncio`` event loop so sync callers can fire +telemetry events without blocking on the underlying ``POST /runtime/log`` +HTTP round-trip. + +:meth:`LiveTrackEventDispatcher.dispatch` is a sync fire-and-forget +method that mirrors the kwargs of ``track_event_async``. Internally it +schedules the async HTTP call onto a dedicated background loop, so the +calling thread never blocks on network I/O and the underlying HTTP call +remains async end-to-end. + +Design notes: + +- **Async HTTP inside, sync interface outside.** ``dispatch`` is a + sync function. Internally it enqueues a coroutine that awaits + ``provider.track_event_async``. + +- **Loop affinity.** ``httpx.AsyncClient`` lazy-binds its connection + pool to the first event loop that awaits on it. This dispatcher + assumes it owns the provider's async HTTP path — nothing else in + the process should await ``track_event_async`` (or any other + ``*_async`` method on the same underlying service) on a *different* + loop. See "one dispatcher per provider" below. + +- **Backpressure.** A ``BoundedSemaphore`` caps in-flight coroutines; + submissions that exceed the cap are dropped with a warning so + memory stays bounded when the backend is slow. + +- **Fire-and-forget contract.** Coroutine exceptions are observed on + the returned ``concurrent.futures.Future`` (to suppress asyncio's + "exception was never retrieved" warning) and logged at debug — they + cannot reach the caller because ``dispatch`` returns before the + coroutine runs. + +One dispatcher per provider. The dispatcher's background loop must be +the only loop that awaits the provider's async methods. +""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +import logging +import threading +from typing import Any + +from ._governance_provider import UiPathPlatformGovernanceProvider + +logger = logging.getLogger(__name__) + + +class LiveTrackEventDispatcher: + """Non-blocking sync adapter around ``provider.track_event_async``. + + Schedules governance telemetry events on a private background + ``asyncio`` loop so the calling thread is never blocked on the + platform's ``/runtime/log`` HTTP call — and the HTTP call itself + is awaited (not run on a sync thread pool). + + .. code-block:: python + + provider = UiPathPlatformGovernanceProvider(config=..., execution_context=...) + dispatcher = LiveTrackEventDispatcher(provider) + dispatcher.dispatch(event_name="agent.started") + # ... + dispatcher.shutdown() # at process exit + + ``dispatch`` has the same kwargs as + :meth:`UiPathPlatformGovernanceProvider.track_event_async` so it is + a drop-in sync callable for anywhere the async method would go. + """ + + _DEFAULT_MAX_INFLIGHT = 40 + + def __init__( + self, + provider: UiPathPlatformGovernanceProvider, + *, + max_inflight: int = _DEFAULT_MAX_INFLIGHT, + ) -> None: + """Construct a dispatcher bound to one provider. + + Starts a daemon thread that runs a private ``asyncio`` event + loop. All HTTP awaits happen on that loop; nothing else in the + process should await the provider's async methods on a + different loop (see the module docstring on loop affinity). + + Args: + provider: The platform governance provider whose + ``track_event_async`` will be awaited on the background + loop. + max_inflight: Cap on concurrent in-flight coroutines. When + exceeded, further ``dispatch`` calls are dropped with a + warning so memory stays bounded under a slow backend. + Default 40 is sized for a bursty-but-not-sustained + event stream. + """ + self._provider = provider + self._max_inflight = max_inflight + self._inflight = threading.BoundedSemaphore(max_inflight) + self._shutdown_event = threading.Event() + self._futures_lock = threading.Lock() + self._futures: set[concurrent.futures.Future[None]] = set() + + self._loop = asyncio.new_event_loop() + self._loop_ready = threading.Event() + self._loop_thread = threading.Thread( + target=self._run_loop, + name="governance-track-event-loop", + daemon=True, + ) + self._loop_thread.start() + # Block until the loop is running so the first ``dispatch`` cannot + # race with startup and hit "loop not running" errors. + self._loop_ready.wait() + + def _run_loop(self) -> None: + """Body of the background loop thread — runs until ``shutdown``.""" + asyncio.set_event_loop(self._loop) + self._loop_ready.set() + try: + self._loop.run_forever() + finally: + # After ``run_forever`` returns (from ``stop()``), any tasks + # that were still awaiting mid-flight need to be cancelled + # and finalized before the loop can close cleanly. Without + # this, ``loop.close()`` warns "Task was destroyed but it is + # pending" for every unfinished awaiter. + try: + pending = asyncio.all_tasks(self._loop) + for task in pending: + task.cancel() + if pending: + self._loop.run_until_complete( + asyncio.gather(*pending, return_exceptions=True) + ) + except Exception as exc: # noqa: BLE001 - teardown must not raise + logger.debug("Loop cleanup swallowed exception: %s", exc) + finally: + try: + self._loop.close() + except Exception as exc: # noqa: BLE001 + logger.debug("Loop close swallowed exception: %s", exc) + + def dispatch( + self, + *, + event_name: str, + data: dict[str, Any] | None = None, + operation_id: str | None = None, + ) -> None: + """Schedule a track-event call on the background loop — returns immediately. + + The kwargs mirror + :meth:`UiPathPlatformGovernanceProvider.track_event_async` so + this method is a drop-in sync callable for the async provider + method. + + Failure modes — all silent, never raised to the caller: + + - **Post-shutdown**: dispatch after :meth:`shutdown` returns + silently; the provider is not called. + - **Saturated in-flight cap**: when ``max_inflight`` coroutines + are already scheduled, the call is dropped with a warning. + Telemetry must never grow memory without bound when the + backend is slow. + - **Loop unavailable**: ``asyncio.run_coroutine_threadsafe`` + raises ``RuntimeError`` if the loop is stopped/closed + (late-firing atexit path); the dispatcher rolls back the + semaphore slot, closes the coroutine, and logs at debug. + - **Coroutine exception**: the provider's HTTP call may raise + for any reason (serialization, 5xx, transport). ``_run`` + catches, logs at debug with ``exc_info=True``, and the + done-callback observes the future to suppress asyncio's + "exception was never retrieved" warning. + """ + if self._shutdown_event.is_set(): + logger.debug( + "Dispatcher shut down; dropping track_event (event_name=%s)", + event_name, + ) + return + + if not self._inflight.acquire(blocking=False): + logger.warning( + "Telemetry pool saturated (>%d in flight); dropping track_event " + "(event_name=%s)", + self._max_inflight, + event_name, + ) + return + + coro = self._run(event_name=event_name, data=data, operation_id=operation_id) + try: + future = asyncio.run_coroutine_threadsafe(coro, self._loop) + except RuntimeError as exc: + # Loop is stopped/closed — release the slot we took and + # close the coroutine so it doesn't warn at GC time. + coro.close() + self._inflight.release() + logger.debug( + "Telemetry loop unavailable (event_name=%s): %s", + event_name, + exc, + ) + return + + with self._futures_lock: + self._futures.add(future) + future.add_done_callback(self._on_future_done) + + async def _run( + self, + *, + event_name: str, + data: dict[str, Any] | None, + operation_id: str | None, + ) -> None: + """Coroutine body — the async HTTP call itself.""" + try: + await self._provider.track_event_async( + event_name=event_name, + data=data, + operation_id=operation_id, + ) + except Exception as exc: # noqa: BLE001 - fire-and-forget contract + logger.debug("Failed to dispatch track_event: %s", exc, exc_info=True) + + def _on_future_done(self, future: concurrent.futures.Future[None]) -> None: + """Observe the future, drop it from the pending set, release the slot. + + Uses ``future.exception()`` to observe the outcome so asyncio + doesn't warn "exception was never retrieved" at GC time. + ``concurrent.futures.Future.exception()`` *raises* + ``CancelledError`` when the future was cancelled (the observe- + without-raise semantics apply only to :class:`asyncio.Future`, + not this ``concurrent.futures`` type), so the observation is + wrapped in a targeted catch. The accounting — semaphore release + and pending-set discard — runs in ``finally`` so success, + failure, and cancellation all clean up correctly. + """ + try: + future.exception() + except concurrent.futures.CancelledError: + # Cancellation during shutdown is expected; the underlying + # coroutine's own exception (if any) was already logged by + # ``_run``. + pass + finally: + with self._futures_lock: + self._futures.discard(future) + self._inflight.release() + + def shutdown(self, *, wait: bool = True, timeout: float = 30.0) -> None: + """Stop accepting new submissions; optionally drain pending, then stop the loop. + + Call at process exit to avoid losing in-flight telemetry. + Safe to call more than once — subsequent calls are no-ops. + + Args: + wait: When ``True`` (default), block until pending + coroutines finish (bounded by ``timeout``) before + stopping the loop. When ``False``, stop immediately; + in-flight coroutines are cancelled by the loop's + teardown path. + timeout: Maximum seconds to wait for pending coroutines + when ``wait=True``. Coroutines still in flight after + the timeout are cancelled by loop teardown. + """ + if self._shutdown_event.is_set(): + return + self._shutdown_event.set() + + if wait: + with self._futures_lock: + pending = list(self._futures) + if pending: + concurrent.futures.wait(pending, timeout=timeout) + + try: + self._loop.call_soon_threadsafe(self._loop.stop) + except RuntimeError: + # Loop already stopped. + pass + self._loop_thread.join(timeout=5.0) diff --git a/packages/uipath-platform/tests/services/test_live_track_event_dispatcher.py b/packages/uipath-platform/tests/services/test_live_track_event_dispatcher.py new file mode 100644 index 000000000..8e6b0ebb7 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_live_track_event_dispatcher.py @@ -0,0 +1,581 @@ +"""Tests for LiveTrackEventDispatcher. + +The dispatcher schedules ``provider.track_event_async`` on a private +background asyncio loop so a sync caller never blocks on the +underlying HTTP. Tests focus on: + +- ``dispatch`` returns immediately (doesn't block on the coroutine) +- The provider's ``track_event_async`` is awaited with the same kwargs +- Exceptions in the coroutine are swallowed (fire-and-forget contract) +- Multiple concurrent submissions all reach the provider +- ``shutdown`` drains pending coroutines +- Post-shutdown dispatch is silent and does not call the provider +- Saturated in-flight cap drops submissions rather than queueing +""" + +from __future__ import annotations + +import asyncio +import logging +import threading +import time +from collections.abc import Generator +from unittest.mock import MagicMock + +import pytest + +from uipath.platform.governance import UiPathPlatformGovernanceProvider +from uipath.platform.governance._live_track_event_dispatcher import ( + LiveTrackEventDispatcher, +) + +_DISPATCHER_MODULE = "uipath.platform.governance._live_track_event_dispatcher" +_DISPATCHER_LOGGER = _DISPATCHER_MODULE + + +@pytest.fixture +def provider() -> MagicMock: + """Mock provider — ``track_event_async`` becomes an ``AsyncMock`` via spec.""" + # ``MagicMock(spec=...)`` auto-detects coroutine functions on the spec + # and creates ``AsyncMock`` attributes for them, so + # ``provider.track_event_async(...)`` returns an awaitable. + return MagicMock(spec=UiPathPlatformGovernanceProvider) + + +@pytest.fixture +def dispatcher( + provider: MagicMock, +) -> Generator[LiveTrackEventDispatcher, None, None]: + """Dispatcher with a small in-flight cap for fast tests.""" + d = LiveTrackEventDispatcher(provider, max_inflight=4) + yield d + d.shutdown() + + +def _wait_for(predicate, *, timeout: float = 2.0, interval: float = 0.01) -> bool: + """Spin-wait helper — returns True when predicate passes, False on timeout.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(interval) + return False + + +# --------------------------------------------------------------------------- +# dispatch is non-blocking +# --------------------------------------------------------------------------- + + +def test_dispatch_returns_before_provider_completes( + provider: MagicMock, + dispatcher: LiveTrackEventDispatcher, +) -> None: + """dispatch must not wait for the coroutine — the calling thread must not block.""" + started = threading.Event() + + async def _slow_track_event(**_: object) -> None: + started.set() + # Sleep long enough that a blocking dispatch would fail the timing bound. + await asyncio.sleep(0.5) + + provider.track_event_async.side_effect = _slow_track_event + + t0 = time.monotonic() + dispatcher.dispatch(event_name="agent.started") + elapsed = time.monotonic() - t0 + + # dispatch should return in well under 100ms even though the coroutine + # is sleeping for 500ms. + assert elapsed < 0.1, f"dispatch blocked for {elapsed:.3f}s" + + # Coroutine did start (proves the submission landed on the loop). + assert started.wait(timeout=2.0), "coroutine never started" + + +# --------------------------------------------------------------------------- +# provider receives the exact kwargs +# --------------------------------------------------------------------------- + + +def test_dispatch_forwards_kwargs_to_provider( + provider: MagicMock, + dispatcher: LiveTrackEventDispatcher, +) -> None: + """The dispatcher is a thin adapter — every kwarg must reach track_event_async.""" + dispatcher.dispatch( + event_name="agent.tool_call", + data={"tool": "browser.open", "url": "https://example.com"}, + operation_id="op-abc-123", + ) + + # Wait for the coroutine to be awaited. + assert _wait_for(lambda: provider.track_event_async.await_count >= 1), ( + "track_event_async never awaited" + ) + + provider.track_event_async.assert_awaited_once_with( + event_name="agent.tool_call", + data={"tool": "browser.open", "url": "https://example.com"}, + operation_id="op-abc-123", + ) + + +def test_dispatch_passes_none_data_and_operation_id( + provider: MagicMock, + dispatcher: LiveTrackEventDispatcher, +) -> None: + """Defaults — ``data`` and ``operation_id`` flow through as ``None``.""" + dispatcher.dispatch(event_name="agent.idle") + + assert _wait_for(lambda: provider.track_event_async.await_count >= 1) + provider.track_event_async.assert_awaited_once_with( + event_name="agent.idle", + data=None, + operation_id=None, + ) + + +# --------------------------------------------------------------------------- +# exceptions are swallowed +# --------------------------------------------------------------------------- + + +def test_worker_exception_does_not_propagate( + provider: MagicMock, + dispatcher: LiveTrackEventDispatcher, +) -> None: + """Fire-and-forget — dispatch returns before the coroutine runs, so an + exception raised inside it cannot reach the caller. The dispatcher + must catch and log internally rather than letting the future + finalize with an unobserved exception. + """ + provider.track_event_async.side_effect = RuntimeError("simulated backend 5xx") + + # If the dispatcher leaked the exception, this call would raise. + dispatcher.dispatch(event_name="agent.deny") + + # And subsequent calls keep working — one bad event doesn't poison + # the loop. + provider.track_event_async.side_effect = None + dispatcher.dispatch(event_name="agent.deny") + + assert _wait_for(lambda: provider.track_event_async.await_count >= 2) + assert provider.track_event_async.await_count == 2 + + +# --------------------------------------------------------------------------- +# concurrency +# --------------------------------------------------------------------------- + + +def test_multiple_dispatches_all_reach_provider(provider: MagicMock) -> None: + """A burst of submissions must all be delivered, in any order.""" + # Use a dedicated dispatcher with headroom well above the burst + # size so the fast-burst doesn't race the loop into saturation. + dispatcher = LiveTrackEventDispatcher(provider, max_inflight=64) + try: + for i in range(20): + dispatcher.dispatch(event_name=f"event.{i}") + + assert _wait_for(lambda: provider.track_event_async.await_count == 20) + + seen = { + call.kwargs["event_name"] + for call in provider.track_event_async.await_args_list + } + assert seen == {f"event.{i}" for i in range(20)} + finally: + dispatcher.shutdown() + + +# --------------------------------------------------------------------------- +# shutdown +# --------------------------------------------------------------------------- + + +def test_shutdown_waits_for_pending(provider: MagicMock) -> None: + """``shutdown(wait=True)`` must let in-flight coroutines finish before + returning so process teardown doesn't lose telemetry. + """ + completed: list[str] = [] + + async def _record(*, event_name: str, **_: object) -> None: + # Small await so submissions overlap the shutdown call. + await asyncio.sleep(0.05) + completed.append(event_name) + + provider.track_event_async.side_effect = _record + dispatcher = LiveTrackEventDispatcher(provider, max_inflight=10) + + for i in range(5): + dispatcher.dispatch(event_name=f"event.{i}") + + dispatcher.shutdown(wait=True) + + # Every submission ran to completion by the time shutdown returned. + assert sorted(completed) == [f"event.{i}" for i in range(5)] + + +def test_shutdown_is_idempotent(provider: MagicMock) -> None: + """Calling shutdown twice must not raise — process teardown paths + sometimes invoke close/shutdown from multiple atexit hooks. + """ + dispatcher = LiveTrackEventDispatcher(provider, max_inflight=4) + dispatcher.shutdown() + dispatcher.shutdown() # second call: no crash, no exception + + +# --------------------------------------------------------------------------- +# fire-and-forget safety: dispatch never raises +# --------------------------------------------------------------------------- + + +def test_dispatch_after_shutdown_is_silent(provider: MagicMock) -> None: + """After :meth:`shutdown` the dispatcher must silently drop late + dispatches — a late dispatch (e.g. from an atexit cleanup after + the loop already stopped) cannot be allowed to raise. + """ + dispatcher = LiveTrackEventDispatcher(provider, max_inflight=4) + dispatcher.shutdown(wait=True) + + # If the dispatcher leaked an exception, this call would raise. + dispatcher.dispatch(event_name="agent.late") + + # And the provider must not have been awaited — the loop is down. + assert not provider.track_event_async.await_count + + +def test_shutdown_no_wait_cancels_inflight_cleanly(provider: MagicMock) -> None: + """``shutdown(wait=False)`` while coroutines are mid-await must cancel + them and let :meth:`_on_future_done` complete its accounting on the + cancellation path — no semaphore leak, no leftover future in the + pending set. + + Regression: ``concurrent.futures.Future.exception()`` *raises* + ``CancelledError`` when the future was cancelled. If the callback + doesn't wrap the observation in a targeted ``except``, the discard + + release calls are skipped → semaphore slots leak (silently, + because ``Future._invoke_callbacks`` swallows the exception into + the logger). Without this guard the leak isn't visible at a + process-level assertion — the loop still stops — so the test must + check the semaphore state directly. + """ + n = 3 + release = threading.Event() + + async def _hang_forever(**_: object) -> None: + # Yield to the loop but never complete — cancelled at teardown. + while not release.is_set(): + await asyncio.sleep(0.02) + + provider.track_event_async.side_effect = _hang_forever + + dispatcher = LiveTrackEventDispatcher(provider, max_inflight=n) + try: + # Fill every in-flight slot so shutdown teardown will cancel + # every one of them. + for i in range(n): + dispatcher.dispatch(event_name=f"event.hang.{i}") + assert _wait_for(lambda: provider.track_event_async.await_count >= 1) + + # wait=False: don't drain, just stop the loop. The loop's finally + # block cancels the pending tasks; each cancellation triggers + # ``_on_future_done`` on the cancelled ``concurrent.futures.Future``. + dispatcher.shutdown(wait=False) + + # Loop thread joined. + assert not dispatcher._loop_thread.is_alive(), ( + "loop thread did not stop after shutdown(wait=False)" + ) + + # Accounting cleanly reset — the callback ran to its finally + # even on the CancelledError path. + # 1. Pending set drained: every cancelled future was discarded. + assert not dispatcher._futures, ( + f"cancellation left {len(dispatcher._futures)} future(s) in " + f"the pending set — semaphore slot(s) leaked" + ) + # 2. Every in-flight slot released: we can immediately re-acquire + # all ``n`` semaphore slots without blocking. + acquired = 0 + for _ in range(n): + if dispatcher._inflight.acquire(blocking=False): + acquired += 1 + for _ in range(acquired): + dispatcher._inflight.release() + assert acquired == n, ( + f"expected all {n} semaphore slots free after cancellation, " + f"only {acquired} were released" + ) + finally: + release.set() + + +def test_dispatch_drops_when_inflight_saturated(provider: MagicMock) -> None: + """When the in-flight cap is reached, further dispatches are dropped + rather than queueing unboundedly. The drop must NOT call the + provider for the saturated submission. + """ + release = threading.Event() + + async def _blocked(**_: object) -> None: + # Poll a threading.Event without blocking the loop — a bare + # ``release.wait()`` would freeze the whole event loop; the + # small await yields between checks so other coroutines can + # progress and the semaphore state can be inspected. + while not release.is_set(): + await asyncio.sleep(0.02) + + provider.track_event_async.side_effect = _blocked + + dispatcher = LiveTrackEventDispatcher(provider, max_inflight=2) + try: + # Fill the cap. + dispatcher.dispatch(event_name="event.1") + dispatcher.dispatch(event_name="event.2") + + # Wait for at least one coroutine to be awaited (so the + # semaphore is held by an in-flight task, not just queued). + assert _wait_for(lambda: provider.track_event_async.await_count >= 1) + + # Third submission should be dropped — semaphore is exhausted. + dispatcher.dispatch(event_name="event.dropped") + + # event.dropped never reaches the provider. + assert "event.dropped" not in { + call.kwargs.get("event_name") + for call in provider.track_event_async.call_args_list + } + finally: + release.set() + dispatcher.shutdown(wait=True) + + +# --------------------------------------------------------------------------- +# construction defaults +# --------------------------------------------------------------------------- + + +def test_default_max_inflight_matches_module_constant(provider: MagicMock) -> None: + """Constructor default equals the documented module constant. + + Guards against silent drift between the docstring's cited default + and the actual value passed to :class:`BoundedSemaphore`. + """ + dispatcher = LiveTrackEventDispatcher(provider) + try: + assert ( + dispatcher._max_inflight == LiveTrackEventDispatcher._DEFAULT_MAX_INFLIGHT + ) + assert LiveTrackEventDispatcher._DEFAULT_MAX_INFLIGHT == 40 + finally: + dispatcher.shutdown() + + +# --------------------------------------------------------------------------- +# uncovered branches: loop-unavailable during dispatch, loop-already-stopped +# during shutdown, and explicit exception-log path +# --------------------------------------------------------------------------- + + +def test_dispatch_swallows_when_loop_unavailable( + provider: MagicMock, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Covers the ``RuntimeError`` catch in :meth:`dispatch`. + + Simulates the race where the loop is stopped/closed between the + shutdown-event check and ``run_coroutine_threadsafe``. dispatch + must: + + - not raise + - not call the provider + - release the semaphore slot it took (so subsequent live dispatches + can still acquire) + - log at debug + """ + dispatcher = LiveTrackEventDispatcher(provider, max_inflight=2) + try: + # Force run_coroutine_threadsafe to raise as if the loop were + # already closed. Patched via the dispatcher module's namespace + # so the dispatcher sees the fake at its call site. + def _raise_runtime(*_args: object, **_kwargs: object) -> object: + raise RuntimeError("Event loop is closed") + + monkeypatch.setattr( + f"{_DISPATCHER_MODULE}.asyncio.run_coroutine_threadsafe", + _raise_runtime, + ) + + with caplog.at_level(logging.DEBUG, logger=_DISPATCHER_LOGGER): + # Fire more calls than max_inflight — if the semaphore is + # NOT released on the RuntimeError path, we would see a + # "pool saturated" warning after 2 calls instead of the + # expected "loop unavailable" debug on every call. + for i in range(5): + dispatcher.dispatch(event_name=f"event.oops.{i}") + + # Provider never invoked — the loop is (simulated) dead. + assert provider.track_event_async.await_count == 0 + assert provider.track_event_async.call_count == 0 + + # Debug log fired for each attempt, and no saturation warning. + debug_hits = [ + r for r in caplog.records if "Telemetry loop unavailable" in r.message + ] + saturation_hits = [ + r for r in caplog.records if "Telemetry pool saturated" in r.message + ] + assert len(debug_hits) == 5, ( + f"expected 5 loop-unavailable debug logs, got {len(debug_hits)}" + ) + assert not saturation_hits, ( + "semaphore was not released on RuntimeError — pool saturated after 2 calls" + ) + finally: + dispatcher.shutdown() + + +def test_shutdown_swallows_when_loop_already_stopped(provider: MagicMock) -> None: + """Covers the ``RuntimeError`` catch in :meth:`shutdown`. + + If the loop is already stopped and closed by the time ``shutdown`` + is called (e.g. the ``_run_loop`` finally block has completed after + a direct external stop), ``call_soon_threadsafe`` raises. shutdown + must swallow and complete cleanly. + """ + dispatcher = LiveTrackEventDispatcher(provider, max_inflight=2) + + # Stop the loop directly, bypassing shutdown(). The ``_run_loop`` + # finally block will run: cancel tasks, gather, then close the + # loop. When our shutdown() then tries call_soon_threadsafe, the + # loop is already closed → RuntimeError. + dispatcher._loop.call_soon_threadsafe(dispatcher._loop.stop) + dispatcher._loop_thread.join(timeout=5.0) + assert not dispatcher._loop_thread.is_alive() + + # Must not raise; the internal RuntimeError from the already-closed + # loop is swallowed. + dispatcher.shutdown() + + # And is still idempotent afterwards. + dispatcher.shutdown() + + +def test_worker_exception_is_logged_at_debug( + provider: MagicMock, + dispatcher: LiveTrackEventDispatcher, + caplog: pytest.LogCaptureFixture, +) -> None: + """Covers the ``except Exception`` branch inside ``_run``. + + Complements :func:`test_worker_exception_does_not_propagate` by + asserting the log record (with ``exc_info``) actually fires, so + coverage records the debug-log line inside the coroutine. + """ + provider.track_event_async.side_effect = ValueError("bad payload") + + with caplog.at_level(logging.DEBUG, logger=_DISPATCHER_LOGGER): + dispatcher.dispatch(event_name="agent.bad") + # Wait for the coroutine to run AND the callback to fire so + # coverage collects the except-branch lines from the loop thread. + assert _wait_for(lambda: provider.track_event_async.await_count >= 1) + # Small sleep to let the callback finalize (release semaphore, + # drop from set) — otherwise coverage may race the callback. + time.sleep(0.05) + + matching = [ + r + for r in caplog.records + if "Failed to dispatch track_event" in r.message and r.levelno == logging.DEBUG + ] + assert matching, "expected a debug log for the swallowed exception" + # exc_info is attached so operators can trace the failure. + assert matching[0].exc_info is not None + assert isinstance(matching[0].exc_info[1], ValueError) + + +# --------------------------------------------------------------------------- +# thread safety +# --------------------------------------------------------------------------- + + +def test_dispatch_is_safe_from_many_threads(provider: MagicMock) -> None: + """dispatches from many caller threads all reach the provider. + + Exercises the semaphore, the futures set, and + ``run_coroutine_threadsafe`` under concurrent access from + non-loop threads. If the futures-set mutation weren't locked, this + would race and drop or duplicate futures under load. + """ + dispatcher = LiveTrackEventDispatcher(provider, max_inflight=128) + try: + n_threads = 50 + barrier = threading.Barrier(n_threads) + + def _fire(name: str) -> None: + # Wait until all threads are ready, then fire together. + barrier.wait(timeout=2.0) + dispatcher.dispatch(event_name=name) + + threads = [ + threading.Thread(target=_fire, args=(f"burst.{i}",)) + for i in range(n_threads) + ] + for t in threads: + t.start() + for t in threads: + t.join(timeout=5.0) + + assert _wait_for(lambda: provider.track_event_async.await_count == n_threads), ( + f"expected {n_threads} awaits, got {provider.track_event_async.await_count}" + ) + + seen = { + c.kwargs["event_name"] for c in provider.track_event_async.await_args_list + } + assert seen == {f"burst.{i}" for i in range(n_threads)} + finally: + dispatcher.shutdown() + + +# --------------------------------------------------------------------------- +# shutdown timeout +# --------------------------------------------------------------------------- + + +def test_shutdown_respects_timeout_when_drain_stalls(provider: MagicMock) -> None: + """``shutdown(wait=True, timeout=…)`` must return within the window + even if pending coroutines are stuck. + + Ensures a stalled backend cannot hang process teardown. Coroutines + still in flight past the timeout are cancelled by the loop's + teardown path. + """ + release = threading.Event() + + async def _never_finish(**_: object) -> None: + while not release.is_set(): + await asyncio.sleep(0.02) + + provider.track_event_async.side_effect = _never_finish + dispatcher = LiveTrackEventDispatcher(provider, max_inflight=4) + + dispatcher.dispatch(event_name="stuck.1") + dispatcher.dispatch(event_name="stuck.2") + assert _wait_for(lambda: provider.track_event_async.await_count >= 1) + + t0 = time.monotonic() + try: + dispatcher.shutdown(wait=True, timeout=0.1) + finally: + release.set() + elapsed = time.monotonic() - t0 + + # shutdown(timeout=0.1) waits ≤0.1s on the futures, then stops the + # loop and joins the thread (5s cap). Total must be well under a + # few seconds — a hang here would freeze the whole test suite. + assert elapsed < 3.0, f"shutdown took {elapsed:.3f}s with timeout=0.1s" + assert not dispatcher._loop_thread.is_alive() diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 5d36225ba..39c160fe8 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.87" +version = "0.1.88" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index dae2ffe65..73365026d 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.87" +version = "0.1.88" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 75bcef1eff0b61f9e6f4164c064fdb548ba9e7a4 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Wed, 1 Jul 2026 18:49:19 +0300 Subject: [PATCH 140/191] fix: prevent .whl files from being corrupted during pack (#1782) --- packages/uipath/pyproject.toml | 2 +- .../src/uipath/_cli/_utils/_constants.py | 2 +- packages/uipath/tests/cli/test_pack.py | 52 +++++++++++++++++++ packages/uipath/uv.lock | 2 +- 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index ee98b97ad..6dd4d5091 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.12.2" +version = "2.12.3" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/_utils/_constants.py b/packages/uipath/src/uipath/_cli/_utils/_constants.py index 14e2568b7..88dac9a96 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_constants.py +++ b/packages/uipath/src/uipath/_cli/_utils/_constants.py @@ -20,7 +20,7 @@ DOCUMENT_EXTENSIONS = {".pdf", ".docx", ".pptx", ".xlsx", ".xls"} -ARCHIVE_EXTENSIONS = {".zip", ".tar", ".gz", ".rar", ".7z", ".bz2", ".xz"} +ARCHIVE_EXTENSIONS = {".zip", ".tar", ".gz", ".rar", ".7z", ".bz2", ".xz", ".whl"} MEDIA_EXTENSIONS = { ".mp3", diff --git a/packages/uipath/tests/cli/test_pack.py b/packages/uipath/tests/cli/test_pack.py index c644b3c7d..96d45d07e 100644 --- a/packages/uipath/tests/cli/test_pack.py +++ b/packages/uipath/tests/cli/test_pack.py @@ -1,4 +1,5 @@ # type: ignore +import io import json import os import zipfile @@ -328,6 +329,57 @@ def test_include_file_extensions( "Binary file content was corrupted during packing" ) + def test_include_wheel_file_not_corrupted( + self, + runner: CliRunner, + temp_dir: str, + project_details: ProjectDetails, + ) -> None: + """Test that .whl files included via packOptions are packed byte-for-byte. + + A .whl file is itself a zip archive full of arbitrary binary bytes. If it + is not recognized as binary by the packager, it gets round-tripped through + a text decode/encode (latin-1 -> UTF-8), which corrupts any byte >= 0x80 + and produces an invalid zip file at runtime. + """ + wheel_file_name = "example_pkg-1.0.0-py3-none-any.whl" + + # Minimal valid zip (wheel) content, deliberately containing high bytes + # (0x80-0xff) that would be mangled by a latin-1 -> UTF-8 round trip. + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as wheel_zip: + wheel_zip.writestr("example_pkg/__init__.py", bytes(range(256)) * 4) + wheel_bytes = buf.getvalue() + + pack_options = {"fileExtensionsIncluded": [".whl"]} + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as f: + json.dump(create_uipath_json(pack_options=pack_options), f) + with open("pyproject.toml", "w") as f: + f.write(project_details.to_toml()) + with open("main.py", "w") as f: + f.write("def main(input): return input") + with open(wheel_file_name, "wb") as f: + f.write(wheel_bytes) + + with patch("uipath._cli.cli_init.Middlewares.next") as mock_middleware: + mock_middleware.return_value = MiddlewareResult(should_continue=True) + init_result = runner.invoke(cli, ["init"], env={}) + assert init_result.exit_code == 0 + + result = runner.invoke(cli, ["pack", "./"], env={}) + + assert result.exit_code == 0 + with zipfile.ZipFile( + f".uipath/{project_details.name}.{project_details.version}.nupkg", "r" + ) as z: + assert f"content/{wheel_file_name}" in z.namelist() + extracted_wheel_bytes = z.read(f"content/{wheel_file_name}") + assert extracted_wheel_bytes == wheel_bytes, ( + ".whl file content was corrupted during packing" + ) + def test_include_files( self, runner: CliRunner, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 73365026d..5898c093d 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.12.2" +version = "2.12.3" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From f45f94885560b22722275f8671bffb91ab29e8af Mon Sep 17 00:00:00 2001 From: Vlad Cimpeanu Date: Wed, 1 Jul 2026 19:16:12 +0300 Subject: [PATCH 141/191] refactor(platform): add uipath.platform.constants as the single source of truth (#1781) Co-authored-by: Claude Opus 4.8 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/__init__.py | 30 +++- .../platform/action_center/_tasks_service.py | 9 +- .../platform/chat/_llm_gateway_service.py | 3 +- .../uipath/platform/common/_base_service.py | 3 +- .../src/uipath/platform/common/_config.py | 50 +++--- .../platform/common/_execution_context.py | 2 +- .../common/_external_application_service.py | 3 +- .../uipath/platform/common/_folder_context.py | 2 +- .../uipath/platform/common/_http_config.py | 3 +- .../uipath/platform/common/_job_context.py | 3 +- .../platform/common/_service_url_overrides.py | 6 +- .../src/uipath/platform/common/_span_utils.py | 2 +- .../src/uipath/platform/common/_user_agent.py | 2 +- .../src/uipath/platform/common/auth.py | 2 +- .../src/uipath/platform/common/constants.py | 102 ++--------- .../src/uipath/platform/constants/__init__.py | 105 ++++++++++++ .../_context_grounding_service.py | 7 +- .../context_grounding_payloads.py | 2 +- .../entities/_entity_schema_service.py | 3 +- .../governance/_governance_service.py | 3 +- .../guardrails/_guardrails_service.py | 3 +- .../orchestrator/_attachments_service.py | 3 +- .../platform/orchestrator/_jobs_service.py | 3 +- .../orchestrator/_processes_service.py | 3 +- .../tests/common/test_config_env_vars.py | 161 ++++++++++++++++-- .../tests/common/test_constants_invariants.py | 25 +++ .../tests/common/test_constants_reexport.py | 67 ++++++++ .../tests/common/test_job_context.py | 2 +- .../tests/services/test_actions_service.py | 2 +- .../tests/services/test_api_client.py | 2 +- .../tests/services/test_assets_service.py | 2 +- .../services/test_attachments_service.py | 2 +- .../tests/services/test_base_service.py | 2 +- .../services/test_connections_service.py | 2 +- .../test_context_grounding_service.py | 2 +- .../tests/services/test_folder_context.py | 2 +- .../tests/services/test_folder_service.py | 2 +- .../tests/services/test_jobs_service.py | 2 +- .../tests/services/test_llm_trace_context.py | 2 +- .../tests/services/test_mcp_service.py | 2 +- .../tests/services/test_processes_service.py | 2 +- .../tests/services/test_queues_service.py | 2 +- .../tests/services/test_remote_a2a_service.py | 2 +- .../services/test_resource_catalog_service.py | 2 +- .../services/test_service_url_overrides.py | 2 +- .../tests/services/test_span_utils.py | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 4 +- packages/uipath/src/uipath/_cli/__init__.py | 2 +- .../src/uipath/_cli/_auth/_auth_service.py | 2 +- .../src/uipath/_cli/_auth/_auth_session.py | 2 +- .../src/uipath/_cli/_auth/_url_utils.py | 3 +- .../uipath/src/uipath/_cli/_chat/_bridge.py | 2 +- .../src/uipath/_cli/_chat/_voice_bridge.py | 2 +- .../uipath/src/uipath/_cli/_debug/_bridge.py | 2 +- .../uipath/_cli/_evals/_progress_reporter.py | 2 +- .../src/uipath/_cli/_evals/_telemetry.py | 2 +- .../src/uipath/_cli/_push/sw_file_handler.py | 2 +- packages/uipath/src/uipath/_cli/_telemetry.py | 2 +- .../uipath/src/uipath/_cli/_utils/_common.py | 2 +- .../src/uipath/_cli/_utils/_project_files.py | 2 +- .../src/uipath/_cli/_utils/_service_base.py | 3 +- .../src/uipath/_cli/_utils/_studio_project.py | 2 +- packages/uipath/src/uipath/_cli/cli_eval.py | 2 +- packages/uipath/src/uipath/_cli/cli_init.py | 2 +- packages/uipath/src/uipath/_cli/cli_invoke.py | 3 +- packages/uipath/src/uipath/_cli/cli_new.py | 3 +- packages/uipath/src/uipath/_cli/cli_pack.py | 2 +- .../src/uipath/_cli/models/runtime_schema.py | 2 +- .../uipath/_cli/models/uipath_json_schema.py | 2 +- packages/uipath/src/uipath/_utils/_auth.py | 2 +- .../src/uipath/_utils/_request_override.py | 2 +- .../uipath/src/uipath/_utils/_user_agent.py | 2 +- .../uipath/src/uipath/_utils/constants.py | 10 +- .../eval/evaluators/legacy_evaluator_utils.py | 2 +- .../legacy_llm_as_judge_evaluator.py | 2 +- .../evaluators/legacy_trajectory_evaluator.py | 2 +- .../eval/evaluators/llm_as_judge_evaluator.py | 2 +- .../eval/mocks/_simulate_component_service.py | 2 +- .../uipath/src/uipath/functions/__init__.py | 2 +- .../uipath/src/uipath/functions/factory.py | 2 +- .../uipath/src/uipath/telemetry/_track.py | 2 +- .../src/uipath/tracing/_otel_exporters.py | 4 +- packages/uipath/tests/cli/chat/test_bridge.py | 2 +- .../tests/cli/chat/test_voice_bridge.py | 2 +- .../tests/cli/eval/test_eval_telemetry.py | 2 +- .../tests/sdk/test_utils_constants_shim.py | 18 +- packages/uipath/tests/telemetry/test_track.py | 2 +- .../tests/tracing/test_otel_exporters.py | 2 +- packages/uipath/uv.lock | 4 +- 91 files changed, 533 insertions(+), 236 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/constants/__init__.py create mode 100644 packages/uipath-platform/tests/common/test_constants_invariants.py create mode 100644 packages/uipath-platform/tests/common/test_constants_reexport.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 1358b712e..a304aca15 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.88" +version = "0.1.89" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/__init__.py b/packages/uipath-platform/src/uipath/platform/__init__.py index 61abb103c..e9535b9f2 100644 --- a/packages/uipath-platform/src/uipath/platform/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/__init__.py @@ -34,7 +34,33 @@ ``` """ -from ._uipath import UiPath -from .common import UiPathApiConfig, UiPathExecutionContext +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ._uipath import UiPath + from .common import UiPathApiConfig, UiPathExecutionContext __all__ = ["UiPathApiConfig", "UiPath", "UiPathExecutionContext"] + + +def __getattr__(name: str): + """Resolve top-level exports on demand. + + Keeps this package's ``__init__`` cheap so lightweight submodules such as + ``uipath.platform.constants`` can be imported without pulling in the + ``UiPath`` facade and the full service layer. The heavy import happens only + when ``UiPath`` (or a config type) is actually accessed. + """ + if name == "UiPath": + from ._uipath import UiPath + + return UiPath + if name in ("UiPathApiConfig", "UiPathExecutionContext"): + from . import common + + return getattr(common, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__() -> list[str]: + return sorted(__all__) diff --git a/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py b/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py index 2c4a4bde7..dea78f882 100644 --- a/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py +++ b/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py @@ -5,16 +5,17 @@ from uipath.core.tracing import traced +from uipath.platform.constants import ( + ENV_TENANT_ID, + HEADER_TENANT_ID, +) + from ..common._base_service import BaseService from ..common._bindings import resource_override from ..common._config import UiPathApiConfig, UiPathConfig from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import FolderContext, header_folder from ..common._models import Endpoint, RequestSpec -from ..common.constants import ( - ENV_TENANT_ID, - HEADER_TENANT_ID, -) from .task_schema import TaskSchema from .tasks import Task, TaskRecipient, TaskRecipientType diff --git a/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py b/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py index 12fe94bb0..e07ce68aa 100644 --- a/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py +++ b/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py @@ -22,12 +22,13 @@ from pydantic import BaseModel from uipath.core.tracing import traced +from uipath.platform.constants import HEADER_AGENTHUB_CONFIG + from ..common._base_service import BaseService from ..common._config import UiPathApiConfig from ..common._endpoints_manager import EndpointManager from ..common._execution_context import UiPathExecutionContext from ..common._models import Endpoint -from ..common.constants import HEADER_AGENTHUB_CONFIG from .llm_gateway import ( ChatCompletion, SpecificToolChoice, diff --git a/packages/uipath-platform/src/uipath/platform/common/_base_service.py b/packages/uipath-platform/src/uipath/platform/common/_base_service.py index 95035b852..9d4053f93 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_base_service.py +++ b/packages/uipath-platform/src/uipath/platform/common/_base_service.py @@ -20,6 +20,8 @@ stop_after_attempt, ) +from uipath.platform.constants import HEADER_USER_AGENT + from ..errors import EnrichedException from ._config import UiPathApiConfig from ._execution_context import UiPathExecutionContext @@ -27,7 +29,6 @@ from ._service_url_overrides import inject_routing_headers, resolve_service_url from ._url import UiPathUrl from ._user_agent import user_agent_value -from .constants import HEADER_USER_AGENT from .retry import ( MAX_RETRY_ATTEMPTS, is_retryable_platform_exception, diff --git a/packages/uipath-platform/src/uipath/platform/common/_config.py b/packages/uipath-platform/src/uipath/platform/common/_config.py index 40db82214..549844db8 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_config.py +++ b/packages/uipath-platform/src/uipath/platform/common/_config.py @@ -36,13 +36,13 @@ def __repr__(self) -> str: @property def bindings_file_path(self) -> Path: - from uipath.platform.common.constants import UIPATH_BINDINGS_FILE + from uipath.platform.constants import UIPATH_BINDINGS_FILE return Path(UIPATH_BINDINGS_FILE) @property def config_file_path(self) -> Path: - from uipath.platform.common.constants import ( + from uipath.platform.constants import ( ENV_UIPATH_CONFIG_PATH, UIPATH_CONFIG_FILE, ) @@ -51,97 +51,97 @@ def config_file_path(self) -> Path: @property def config_file_name(self) -> str: - from uipath.platform.common.constants import UIPATH_CONFIG_FILE + from uipath.platform.constants import UIPATH_CONFIG_FILE return UIPATH_CONFIG_FILE @property def project_id(self) -> str | None: - from uipath.platform.common.constants import ENV_UIPATH_PROJECT_ID + from uipath.platform.constants import ENV_UIPATH_PROJECT_ID return os.getenv(ENV_UIPATH_PROJECT_ID, None) @property def agent_id(self) -> str | None: - from uipath.platform.common.constants import ENV_UIPATH_AGENT_ID + from uipath.platform.constants import ENV_UIPATH_AGENT_ID return os.getenv(ENV_UIPATH_AGENT_ID) or self.project_id @property def cloud_user_id(self) -> str | None: - from uipath.platform.common.constants import ENV_UIPATH_CLOUD_USER_ID + from uipath.platform.constants import ENV_UIPATH_CLOUD_USER_ID return os.getenv(ENV_UIPATH_CLOUD_USER_ID, None) @property def project_files_source(self) -> str | None: - from uipath.platform.common.constants import ENV_UIPATH_PROJECT_FILES_SOURCE + from uipath.platform.constants import ENV_UIPATH_PROJECT_FILES_SOURCE return os.getenv(ENV_UIPATH_PROJECT_FILES_SOURCE, None) @property def project_key(self) -> str | None: - from uipath.platform.common.constants import ENV_PROJECT_KEY + from uipath.platform.constants import ENV_PROJECT_KEY return os.getenv(ENV_PROJECT_KEY, None) @property def tenant_name(self) -> str | None: - from uipath.platform.common.constants import ENV_TENANT_NAME + from uipath.platform.constants import ENV_TENANT_NAME return os.getenv(ENV_TENANT_NAME, None) @property def tenant_id(self) -> str | None: - from uipath.platform.common.constants import ENV_TENANT_ID + from uipath.platform.constants import ENV_TENANT_ID return os.getenv(ENV_TENANT_ID, None) @property def organization_id(self) -> str | None: - from uipath.platform.common.constants import ENV_ORGANIZATION_ID + from uipath.platform.constants import ENV_ORGANIZATION_ID return os.getenv(ENV_ORGANIZATION_ID, None) @property def base_url(self) -> str | None: - from uipath.platform.common.constants import ENV_BASE_URL + from uipath.platform.constants import ENV_BASE_URL return os.getenv(ENV_BASE_URL, None) @property def folder_key(self) -> str | None: - from uipath.platform.common.constants import ENV_FOLDER_KEY + from uipath.platform.constants import ENV_FOLDER_KEY return os.getenv(ENV_FOLDER_KEY, None) @property def folder_path(self) -> str | None: - from uipath.platform.common.constants import ENV_FOLDER_PATH + from uipath.platform.constants import ENV_FOLDER_PATH return os.getenv(ENV_FOLDER_PATH, None) @property def process_key(self) -> str | None: - from uipath.platform.common.constants import ENV_PROCESS_KEY + from uipath.platform.constants import ENV_PROCESS_KEY return os.getenv(ENV_PROCESS_KEY, None) @property def process_uuid(self) -> str | None: - from uipath.platform.common.constants import ENV_UIPATH_PROCESS_UUID + from uipath.platform.constants import ENV_UIPATH_PROCESS_UUID return os.getenv(ENV_UIPATH_PROCESS_UUID, None) @property def trace_id(self) -> str | None: - from uipath.platform.common.constants import ENV_UIPATH_TRACE_ID + from uipath.platform.constants import ENV_UIPATH_TRACE_ID return os.getenv(ENV_UIPATH_TRACE_ID, None) @property def process_version(self) -> str | None: - from uipath.platform.common.constants import ENV_UIPATH_PROCESS_VERSION + from uipath.platform.constants import ENV_UIPATH_PROCESS_VERSION return os.getenv(ENV_UIPATH_PROCESS_VERSION, None) @@ -151,39 +151,39 @@ def is_studio_project(self) -> bool: @property def job_key(self) -> str | None: - from uipath.platform.common.constants import ENV_JOB_KEY + from uipath.platform.constants import ENV_JOB_KEY return os.getenv(ENV_JOB_KEY, None) @property def has_legacy_eval_folder(self) -> bool: - from uipath.platform.common.constants import LEGACY_EVAL_FOLDER + from uipath.platform.constants import LEGACY_EVAL_FOLDER eval_path = Path(os.getcwd()) / LEGACY_EVAL_FOLDER return eval_path.exists() and eval_path.is_dir() @property def has_eval_folder(self) -> bool: - from uipath.platform.common.constants import EVALS_FOLDER + from uipath.platform.constants import EVALS_FOLDER coded_eval_path = Path(os.getcwd()) / EVALS_FOLDER return coded_eval_path.exists() and coded_eval_path.is_dir() @property def entry_points_file_path(self) -> Path: - from uipath.platform.common.constants import ENTRY_POINTS_FILE + from uipath.platform.constants import ENTRY_POINTS_FILE return Path(ENTRY_POINTS_FILE) @property def uiproj_file_path(self) -> Path: - from uipath.platform.common.constants import UIPROJ_FILE + from uipath.platform.constants import UIPROJ_FILE return Path(UIPROJ_FILE) @property def studio_metadata_file_path(self) -> Path: - from uipath.platform.common.constants import STUDIO_METADATA_FILE + from uipath.platform.constants import STUDIO_METADATA_FILE return Path(".uipath", STUDIO_METADATA_FILE) @@ -198,7 +198,7 @@ def is_rooted_to_debug_job(self) -> bool: @property def is_tracing_enabled(self) -> bool: - from uipath.platform.common.constants import ENV_TRACING_ENABLED + from uipath.platform.constants import ENV_TRACING_ENABLED return os.getenv(ENV_TRACING_ENABLED, "true").lower() == "true" diff --git a/packages/uipath-platform/src/uipath/platform/common/_execution_context.py b/packages/uipath-platform/src/uipath/platform/common/_execution_context.py index 184a626db..4106afc1e 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_execution_context.py +++ b/packages/uipath-platform/src/uipath/platform/common/_execution_context.py @@ -1,7 +1,7 @@ from contextvars import ContextVar, Token from os import environ as env -from uipath.platform.common.constants import ENV_JOB_ID, ENV_JOB_KEY, ENV_ROBOT_KEY +from uipath.platform.constants import ENV_JOB_ID, ENV_JOB_KEY, ENV_ROBOT_KEY _execution_source: ContextVar[str | None] = ContextVar("execution_source", default=None) diff --git a/packages/uipath-platform/src/uipath/platform/common/_external_application_service.py b/packages/uipath-platform/src/uipath/platform/common/_external_application_service.py index 5c27ab779..8652e7ede 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_external_application_service.py +++ b/packages/uipath-platform/src/uipath/platform/common/_external_application_service.py @@ -5,10 +5,11 @@ import httpx from httpx import HTTPStatusError +from uipath.platform.constants import ENV_BASE_URL + from ..errors import EnrichedException from ..identity import IdentityService from .auth import TokenData -from .constants import ENV_BASE_URL class ExternalApplicationService: diff --git a/packages/uipath-platform/src/uipath/platform/common/_folder_context.py b/packages/uipath-platform/src/uipath/platform/common/_folder_context.py index 4d401aded..6adc9af77 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_folder_context.py +++ b/packages/uipath-platform/src/uipath/platform/common/_folder_context.py @@ -2,7 +2,7 @@ from os import environ as env from typing import Any, Optional -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( ENV_FOLDER_KEY, ENV_FOLDER_PATH, HEADER_FOLDER_KEY, diff --git a/packages/uipath-platform/src/uipath/platform/common/_http_config.py b/packages/uipath-platform/src/uipath/platform/common/_http_config.py index 191d768c4..a367db7a5 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_http_config.py +++ b/packages/uipath-platform/src/uipath/platform/common/_http_config.py @@ -66,8 +66,9 @@ def get_httpx_client_kwargs( ca_bundle = get_ca_bundle_path() client_kwargs["verify"] = create_ssl_context(ca_bundle) if ca_bundle else False + from uipath.platform.constants import HEADER_LICENSING_CONTEXT + from ._config import UiPathConfig - from .constants import HEADER_LICENSING_CONTEXT merged_headers: Dict[str, str] = {} licensing_context = UiPathConfig.licensing_context diff --git a/packages/uipath-platform/src/uipath/platform/common/_job_context.py b/packages/uipath-platform/src/uipath/platform/common/_job_context.py index ccbd99f22..b312adb73 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_job_context.py +++ b/packages/uipath-platform/src/uipath/platform/common/_job_context.py @@ -1,5 +1,6 @@ +from uipath.platform.constants import HEADER_JOB_KEY + from ._config import UiPathConfig -from .constants import HEADER_JOB_KEY def header_job_key() -> dict[str, str]: diff --git a/packages/uipath-platform/src/uipath/platform/common/_service_url_overrides.py b/packages/uipath-platform/src/uipath/platform/common/_service_url_overrides.py index 6b0a1b529..816285506 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_service_url_overrides.py +++ b/packages/uipath-platform/src/uipath/platform/common/_service_url_overrides.py @@ -14,8 +14,12 @@ import os +from uipath.platform.constants import ( + HEADER_INTERNAL_ACCOUNT_ID, + HEADER_INTERNAL_TENANT_ID, +) + from ._config import UiPathConfig -from .constants import HEADER_INTERNAL_ACCOUNT_ID, HEADER_INTERNAL_TENANT_ID def resolve_service_url(endpoint_path: str) -> str | None: diff --git a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py index cbabf8230..3d23bfa5a 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -15,7 +15,7 @@ from pydantic import BaseModel, ConfigDict, Field from uipath.core.serialization import serialize_json -from .constants import ( +from uipath.platform.constants import ( ENV_FOLDER_KEY, ENV_JOB_KEY, ENV_ORGANIZATION_ID, diff --git a/packages/uipath-platform/src/uipath/platform/common/_user_agent.py b/packages/uipath-platform/src/uipath/platform/common/_user_agent.py index dcf28d3e0..1083faa45 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_user_agent.py +++ b/packages/uipath-platform/src/uipath/platform/common/_user_agent.py @@ -1,6 +1,6 @@ import importlib -from .constants import HEADER_USER_AGENT +from uipath.platform.constants import HEADER_USER_AGENT def user_agent_value(specific_component: str) -> str: diff --git a/packages/uipath-platform/src/uipath/platform/common/auth.py b/packages/uipath-platform/src/uipath/platform/common/auth.py index 885a0ef1b..a8f87d3e4 100644 --- a/packages/uipath-platform/src/uipath/platform/common/auth.py +++ b/packages/uipath-platform/src/uipath/platform/common/auth.py @@ -5,7 +5,7 @@ from pydantic import BaseModel -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( ENV_BASE_URL, ENV_UIPATH_ACCESS_TOKEN, ENV_UNATTENDED_USER_ACCESS_TOKEN, diff --git a/packages/uipath-platform/src/uipath/platform/common/constants.py b/packages/uipath-platform/src/uipath/platform/common/constants.py index 162402aa8..5a12ee4b0 100644 --- a/packages/uipath-platform/src/uipath/platform/common/constants.py +++ b/packages/uipath-platform/src/uipath/platform/common/constants.py @@ -1,96 +1,16 @@ -"""Constants.""" +"""Deprecated alias for ``uipath.platform.constants``. -# Environment variables -DOTENV_FILE = ".env" -ENV_BASE_URL = "UIPATH_URL" -ENV_EVAL_BACKEND_URL = "UIPATH_EVAL_BACKEND_URL" -ENV_UNATTENDED_USER_ACCESS_TOKEN = "UNATTENDED_USER_ACCESS_TOKEN" -ENV_UIPATH_ACCESS_TOKEN = "UIPATH_ACCESS_TOKEN" -ENV_FOLDER_KEY = "UIPATH_FOLDER_KEY" -ENV_FOLDER_PATH = "UIPATH_FOLDER_PATH" -ENV_JOB_KEY = "UIPATH_JOB_KEY" -ENV_JOB_ID = "UIPATH_JOB_ID" -ENV_ROBOT_KEY = "UIPATH_ROBOT_KEY" -ENV_TENANT_ID = "UIPATH_TENANT_ID" -ENV_TENANT_NAME = "UIPATH_TENANT_NAME" -ENV_ORGANIZATION_ID = "UIPATH_ORGANIZATION_ID" -ENV_TELEMETRY_ENABLED = "UIPATH_TELEMETRY_ENABLED" -ENV_TRACING_ENABLED = "UIPATH_TRACING_ENABLED" -ENV_UIPATH_PROJECT_ID = "UIPATH_PROJECT_ID" -ENV_UIPATH_AGENT_ID = "UIPATH_AGENT_ID" -ENV_UIPATH_CLOUD_USER_ID = "UIPATH_CLOUD_USER_ID" -ENV_UIPATH_PROJECT_FILES_SOURCE = "UIPATH_PROJECT_FILES_SOURCE" -ENV_PROJECT_KEY = "PROJECT_KEY" -ENV_PROCESS_KEY = "UIPATH_PROCESS_KEY" -ENV_UIPATH_PROCESS_UUID = "UIPATH_PROCESS_UUID" -ENV_UIPATH_TRACE_ID = "UIPATH_TRACE_ID" -ENV_UIPATH_PROCESS_VERSION = "UIPATH_PROCESS_VERSION" -ENV_UIPATH_CONFIG_PATH = "UIPATH_CONFIG_PATH" +This module is kept as a backward-compatibility shim so existing imports keep +working. New code should import from ``uipath.platform.constants``. +""" -# Headers -HEADER_FOLDER_KEY = "x-uipath-folderkey" -HEADER_FOLDER_PATH = "x-uipath-folderpath" -HEADER_FOLDER_PATH_ENCODED = "x-uipath-folderpath-encoded" -HEADER_USER_AGENT = "x-uipath-user-agent" -HEADER_TENANT_ID = "x-uipath-tenantid" -HEADER_INTERNAL_TENANT_ID = "x-uipath-internal-tenantid" -HEADER_INTERNAL_ACCOUNT_ID = "x-uipath-internal-accountid" -HEADER_JOB_KEY = "x-uipath-jobkey" -HEADER_PROCESS_KEY = "x-uipath-processkey" -HEADER_TRACE_ID = "x-uipath-traceid" -HEADER_AGENTHUB_CONFIG = "x-uipath-agenthub-config" -HEADER_GUARDRAILS_SOURCE = "x-uipath-guardrails-source" -HEADER_LLMGATEWAY_BYO_CONNECTION_ID = "x-uipath-llmgateway-byoisconnectionid" -HEADER_SW_LOCK_KEY = "x-uipath-sw-lockkey" -HEADER_LICENSING_CONTEXT = "x-uipath-licensing-context" +import warnings as _warnings -# Data sources (request types) -ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE_REQUEST = ( - "#UiPath.Vdbs.Domain.Api.V20Models.StorageBucketDataSourceRequest" -) -CONFLUENCE_DATA_SOURCE_REQUEST = ( - "#UiPath.Vdbs.Domain.Api.V20Models.ConfluenceDataSourceRequest" -) -DROPBOX_DATA_SOURCE_REQUEST = ( - "#UiPath.Vdbs.Domain.Api.V20Models.DropboxDataSourceRequest" -) -GOOGLE_DRIVE_DATA_SOURCE_REQUEST = ( - "#UiPath.Vdbs.Domain.Api.V20Models.GoogleDriveDataSourceRequest" -) -ONEDRIVE_DATA_SOURCE_REQUEST = ( - "#UiPath.Vdbs.Domain.Api.V20Models.OneDriveDataSourceRequest" -) +from uipath.platform.constants import * # noqa: F401,F403 -# Data sources -ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE = ( - "#UiPath.Vdbs.Domain.Api.V20Models.StorageBucketDataSource" +_warnings.warn( + "uipath.platform.common.constants is deprecated and will be removed in a " + "future release; import from uipath.platform.constants instead.", + FutureWarning, + stacklevel=2, ) -CONFLUENCE_DATA_SOURCE = "#UiPath.Vdbs.Domain.Api.V20Models.ConfluenceDataSource" -DROPBOX_DATA_SOURCE = "#UiPath.Vdbs.Domain.Api.V20Models.DropboxDataSource" -GOOGLE_DRIVE_DATA_SOURCE = "#UiPath.Vdbs.Domain.Api.V20Models.GoogleDriveDataSource" -ONEDRIVE_DATA_SOURCE = "#UiPath.Vdbs.Domain.Api.V20Models.OneDriveDataSource" -LLMV3Mini_REQUEST = "#UiPath.Vdbs.Domain.Api.V20Models.LLMV3MiniPreProcessingRequest" -LLMV4_REQUEST = "#UiPath.Vdbs.Domain.Api.V20Models.LLMV4PreProcessingRequest" -NativeV1_REQUEST = "#UiPath.Vdbs.Domain.Api.V20Models.NativeV1PreProcessingRequest" - - -# Local storage -TEMP_ATTACHMENTS_FOLDER = "uipath_attachments" - -# LLM models -COMMUNITY_agents_SUFFIX = "-community-agents" - -# File names -PYTHON_CONFIGURATION_FILE = "pyproject.toml" -UIPATH_CONFIG_FILE = "uipath.json" -UIPATH_BINDINGS_FILE = "bindings.json" -ENTRY_POINTS_FILE = "entry-points.json" -STUDIO_METADATA_FILE = "studio_metadata.json" -UIPROJ_FILE = "project.uiproj" - - -# Folder names -LEGACY_EVAL_FOLDER = "evals" -EVALS_FOLDER = "evaluations" -# Evaluators -CUSTOM_EVALUATOR_PREFIX = "file://" diff --git a/packages/uipath-platform/src/uipath/platform/constants/__init__.py b/packages/uipath-platform/src/uipath/platform/constants/__init__.py new file mode 100644 index 000000000..a42cf7c84 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/constants/__init__.py @@ -0,0 +1,105 @@ +"""Constants. + +Single source of truth for constants shared across the UiPath SDK packages. +Lives directly under ``uipath.platform`` (sibling to ``common``) so it can be +imported cheaply — ``uipath.platform``'s ``__init__`` resolves its heavy +exports lazily, so importing this module does not pull in the service layer. + +``uipath.platform.common.constants`` and ``uipath._utils.constants`` re-export +from here. +""" + +# Environment variables +DOTENV_FILE = ".env" +ENV_BASE_URL = "UIPATH_URL" +ENV_EVAL_BACKEND_URL = "UIPATH_EVAL_BACKEND_URL" +ENV_UNATTENDED_USER_ACCESS_TOKEN = "UNATTENDED_USER_ACCESS_TOKEN" +ENV_UIPATH_ACCESS_TOKEN = "UIPATH_ACCESS_TOKEN" +ENV_FOLDER_KEY = "UIPATH_FOLDER_KEY" +ENV_FOLDER_PATH = "UIPATH_FOLDER_PATH" +ENV_JOB_KEY = "UIPATH_JOB_KEY" +ENV_JOB_ID = "UIPATH_JOB_ID" +ENV_ROBOT_KEY = "UIPATH_ROBOT_KEY" +ENV_TENANT_ID = "UIPATH_TENANT_ID" +ENV_TENANT_NAME = "UIPATH_TENANT_NAME" +ENV_ORGANIZATION_ID = "UIPATH_ORGANIZATION_ID" +ENV_TELEMETRY_ENABLED = "UIPATH_TELEMETRY_ENABLED" +ENV_TRACING_ENABLED = "UIPATH_TRACING_ENABLED" +ENV_UIPATH_PROJECT_ID = "UIPATH_PROJECT_ID" +ENV_UIPATH_AGENT_ID = "UIPATH_AGENT_ID" +ENV_UIPATH_CLOUD_USER_ID = "UIPATH_CLOUD_USER_ID" +ENV_UIPATH_PROJECT_FILES_SOURCE = "UIPATH_PROJECT_FILES_SOURCE" +ENV_PROJECT_KEY = "PROJECT_KEY" +ENV_PROCESS_KEY = "UIPATH_PROCESS_KEY" +ENV_UIPATH_PROCESS_UUID = "UIPATH_PROCESS_UUID" +ENV_UIPATH_TRACE_ID = "UIPATH_TRACE_ID" +ENV_UIPATH_PROCESS_VERSION = "UIPATH_PROCESS_VERSION" +ENV_UIPATH_CONFIG_PATH = "UIPATH_CONFIG_PATH" + +# Headers +HEADER_FOLDER_KEY = "x-uipath-folderkey" +HEADER_FOLDER_PATH = "x-uipath-folderpath" +HEADER_FOLDER_PATH_ENCODED = "x-uipath-folderpath-encoded" +HEADER_USER_AGENT = "x-uipath-user-agent" +HEADER_TENANT_ID = "x-uipath-tenantid" +HEADER_INTERNAL_TENANT_ID = "x-uipath-internal-tenantid" +HEADER_INTERNAL_ACCOUNT_ID = "x-uipath-internal-accountid" +HEADER_JOB_KEY = "x-uipath-jobkey" +HEADER_PROCESS_KEY = "x-uipath-processkey" +HEADER_TRACE_ID = "x-uipath-traceid" +HEADER_AGENTHUB_CONFIG = "x-uipath-agenthub-config" +HEADER_GUARDRAILS_SOURCE = "x-uipath-guardrails-source" +HEADER_LLMGATEWAY_BYO_CONNECTION_ID = "x-uipath-llmgateway-byoisconnectionid" +HEADER_SW_LOCK_KEY = "x-uipath-sw-lockkey" +HEADER_LICENSING_CONTEXT = "x-uipath-licensing-context" + +# Data sources (request types) +ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE_REQUEST = ( + "#UiPath.Vdbs.Domain.Api.V20Models.StorageBucketDataSourceRequest" +) +CONFLUENCE_DATA_SOURCE_REQUEST = ( + "#UiPath.Vdbs.Domain.Api.V20Models.ConfluenceDataSourceRequest" +) +DROPBOX_DATA_SOURCE_REQUEST = ( + "#UiPath.Vdbs.Domain.Api.V20Models.DropboxDataSourceRequest" +) +GOOGLE_DRIVE_DATA_SOURCE_REQUEST = ( + "#UiPath.Vdbs.Domain.Api.V20Models.GoogleDriveDataSourceRequest" +) +ONEDRIVE_DATA_SOURCE_REQUEST = ( + "#UiPath.Vdbs.Domain.Api.V20Models.OneDriveDataSourceRequest" +) + +# Data sources +ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE = ( + "#UiPath.Vdbs.Domain.Api.V20Models.StorageBucketDataSource" +) +CONFLUENCE_DATA_SOURCE = "#UiPath.Vdbs.Domain.Api.V20Models.ConfluenceDataSource" +DROPBOX_DATA_SOURCE = "#UiPath.Vdbs.Domain.Api.V20Models.DropboxDataSource" +GOOGLE_DRIVE_DATA_SOURCE = "#UiPath.Vdbs.Domain.Api.V20Models.GoogleDriveDataSource" +ONEDRIVE_DATA_SOURCE = "#UiPath.Vdbs.Domain.Api.V20Models.OneDriveDataSource" +LLMV3Mini_REQUEST = "#UiPath.Vdbs.Domain.Api.V20Models.LLMV3MiniPreProcessingRequest" +LLMV4_REQUEST = "#UiPath.Vdbs.Domain.Api.V20Models.LLMV4PreProcessingRequest" +NativeV1_REQUEST = "#UiPath.Vdbs.Domain.Api.V20Models.NativeV1PreProcessingRequest" + + +# Local storage +TEMP_ATTACHMENTS_FOLDER = "uipath_attachments" + +# LLM models +COMMUNITY_agents_SUFFIX = "-community-agents" + +# File names +PYTHON_CONFIGURATION_FILE = "pyproject.toml" +UIPATH_CONFIG_FILE = "uipath.json" +UIPATH_BINDINGS_FILE = "bindings.json" +ENTRY_POINTS_FILE = "entry-points.json" +STUDIO_METADATA_FILE = "studio_metadata.json" +UIPROJ_FILE = "project.uiproj" + + +# Folder names +LEGACY_EVAL_FOLDER = "evals" +EVALS_FOLDER = "evaluations" +# Evaluators +CUSTOM_EVALUATOR_PREFIX = "file://" diff --git a/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py b/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py index 6bff3f77f..fc47d7b8b 100644 --- a/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py +++ b/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py @@ -6,6 +6,10 @@ from typing_extensions import deprecated from uipath.core.tracing import traced +from uipath.platform.constants import ( + ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE, +) + from ..common._base_service import BaseService from ..common._bindings import resource_override from ..common._config import UiPathApiConfig @@ -14,9 +18,6 @@ from ..common._http_config import get_httpx_client_kwargs from ..common._job_context import header_job_key from ..common._models import Endpoint, RequestSpec -from ..common.constants import ( - ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE, -) from ..errors import ( BatchTransformFailedException, BatchTransformNotCompleteException, diff --git a/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding_payloads.py b/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding_payloads.py index a28903462..2dbaf63bd 100644 --- a/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding_payloads.py +++ b/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding_payloads.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic.alias_generators import to_camel -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( CONFLUENCE_DATA_SOURCE_REQUEST, DROPBOX_DATA_SOURCE_REQUEST, GOOGLE_DRIVE_DATA_SOURCE_REQUEST, diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entity_schema_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entity_schema_service.py index 9872969d6..a73b894ec 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entity_schema_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entity_schema_service.py @@ -11,11 +11,12 @@ from httpx import Response +from uipath.platform.constants import HEADER_FOLDER_KEY + from ..common._base_service import BaseService from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext from ..common._models import Endpoint, RequestSpec -from ..common.constants import HEADER_FOLDER_KEY from ..orchestrator._folder_service import FolderService from .entities import ( ENTITY_FIELD_CONSTRAINT_DEFAULTS, diff --git a/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py b/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py index 739791a7a..3546517c7 100644 --- a/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py +++ b/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py @@ -29,13 +29,14 @@ PolicyResponse, ) +from uipath.platform.constants import HEADER_INTERNAL_TENANT_ID + from ..common._base_service import BaseService, resolve_trace_id from ..common._config import UiPathConfig from ..common._service_url_overrides import ( inject_routing_headers, resolve_service_url, ) -from ..common.constants import HEADER_INTERNAL_TENANT_ID # The agenticgovernance_ ingress lives at a separate org-scoped path that # uses the organization UUID (not the slug exposed by ``UIPATH_URL``). diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py index 1d860b8d1..f4c7d832e 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py @@ -8,13 +8,14 @@ ) from uipath.core.tracing import traced +from uipath.platform.constants import HEADER_GUARDRAILS_SOURCE + from ..chat.llm_trace_context import build_trace_context_headers from ..common._base_service import BaseService from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext from ..common._job_context import header_job_key from ..common._models import Endpoint, RequestSpec -from ..common.constants import HEADER_GUARDRAILS_SOURCE from ..errors import EnrichedException from .guardrails import BuiltInValidatorGuardrail diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_attachments_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_attachments_service.py index 73775c994..5d4c192b5 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_attachments_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_attachments_service.py @@ -12,6 +12,8 @@ from httpx._types import RequestContent from uipath.core.tracing import traced +from uipath.platform.constants import TEMP_ATTACHMENTS_FOLDER + from ..attachments import Attachment, AttachmentMode, BlobFileAccessInfo from ..common._base_service import BaseService from ..common._config import UiPathApiConfig @@ -19,7 +21,6 @@ from ..common._folder_context import FolderContext, header_folder from ..common._http_config import get_httpx_client_kwargs from ..common._models import Endpoint, RequestSpec -from ..common.constants import TEMP_ATTACHMENTS_FOLDER def _upload_attachment_input_processor(inputs: dict[str, Any]) -> dict[str, Any]: diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py index a9a132e02..3c1117ab7 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py @@ -7,13 +7,14 @@ from uipath.core.tracing import traced +from uipath.platform.constants import TEMP_ATTACHMENTS_FOLDER + from ..common._base_service import BaseService from ..common._bindings import resource_override from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import FolderContext, header_folder from ..common._models import Endpoint, RequestSpec -from ..common.constants import TEMP_ATTACHMENTS_FOLDER from ..common.paging import PagedResult from ..common.validation import validate_pagination_params from ..errors import EnrichedException diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_processes_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_processes_service.py index e6ecc988a..433793389 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_processes_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_processes_service.py @@ -7,6 +7,8 @@ from opentelemetry.trace import format_span_id from uipath.core.tracing import traced +from uipath.platform.constants import ENV_JOB_KEY, HEADER_JOB_KEY + from ..attachments import Attachment from ..common._base_service import BaseService from ..common._bindings import resource_override @@ -15,7 +17,6 @@ from ..common._folder_context import FolderContext, header_folder from ..common._models import Endpoint, RequestSpec from ..common._span_utils import _SpanUtils -from ..common.constants import ENV_JOB_KEY, HEADER_JOB_KEY from ._attachments_service import AttachmentsService from .job import Job diff --git a/packages/uipath-platform/tests/common/test_config_env_vars.py b/packages/uipath-platform/tests/common/test_config_env_vars.py index 8ff7b636d..1e48ac894 100644 --- a/packages/uipath-platform/tests/common/test_config_env_vars.py +++ b/packages/uipath-platform/tests/common/test_config_env_vars.py @@ -1,22 +1,70 @@ +from pathlib import Path + import pytest from uipath.platform.common._config import UiPathConfig +from uipath.platform.constants import ( + ENTRY_POINTS_FILE, + ENV_BASE_URL, + ENV_FOLDER_KEY, + ENV_FOLDER_PATH, + ENV_JOB_KEY, + ENV_ORGANIZATION_ID, + ENV_PROCESS_KEY, + ENV_PROJECT_KEY, + ENV_TENANT_ID, + ENV_TENANT_NAME, + ENV_TRACING_ENABLED, + ENV_UIPATH_AGENT_ID, + ENV_UIPATH_CLOUD_USER_ID, + ENV_UIPATH_CONFIG_PATH, + ENV_UIPATH_PROCESS_UUID, + ENV_UIPATH_PROCESS_VERSION, + ENV_UIPATH_PROJECT_FILES_SOURCE, + ENV_UIPATH_PROJECT_ID, + ENV_UIPATH_TRACE_ID, + EVALS_FOLDER, + LEGACY_EVAL_FOLDER, + STUDIO_METADATA_FILE, + UIPATH_BINDINGS_FILE, + UIPATH_CONFIG_FILE, + UIPROJ_FILE, +) + +# Every env var read by ConfigurationManager properties. Cleared before each +# test so "returns None when unset" assertions don't pick up the real +# environment. +_ENV_VARS = ( + ENV_UIPATH_PROJECT_ID, + ENV_UIPATH_AGENT_ID, + ENV_UIPATH_CLOUD_USER_ID, + ENV_UIPATH_PROJECT_FILES_SOURCE, + ENV_PROJECT_KEY, + ENV_TENANT_NAME, + ENV_TENANT_ID, + ENV_ORGANIZATION_ID, + ENV_BASE_URL, + ENV_FOLDER_KEY, + ENV_FOLDER_PATH, + ENV_PROCESS_KEY, + ENV_UIPATH_PROCESS_UUID, + ENV_UIPATH_TRACE_ID, + ENV_UIPATH_PROCESS_VERSION, + ENV_JOB_KEY, + ENV_UIPATH_CONFIG_PATH, + ENV_TRACING_ENABLED, +) @pytest.fixture(autouse=True) def _clear_env(monkeypatch): - for var in ( - "UIPATH_PROJECT_ID", - "UIPATH_AGENT_ID", - "UIPATH_CLOUD_USER_ID", - "UIPATH_PROJECT_FILES_SOURCE", - ): + for var in _ENV_VARS: monkeypatch.delenv(var, raising=False) class TestProjectId: def test_reads_env_var(self, monkeypatch): - monkeypatch.setenv("UIPATH_PROJECT_ID", "file-source-id") + monkeypatch.setenv(ENV_UIPATH_PROJECT_ID, "file-source-id") assert UiPathConfig.project_id == "file-source-id" def test_returns_none_when_unset(self): @@ -25,12 +73,12 @@ def test_returns_none_when_unset(self): class TestAgentId: def test_returns_explicit_agent_id_when_set(self, monkeypatch): - monkeypatch.setenv("UIPATH_PROJECT_ID", "debug-project-guid") - monkeypatch.setenv("UIPATH_AGENT_ID", "real-agent-id") + monkeypatch.setenv(ENV_UIPATH_PROJECT_ID, "debug-project-guid") + monkeypatch.setenv(ENV_UIPATH_AGENT_ID, "real-agent-id") assert UiPathConfig.agent_id == "real-agent-id" def test_falls_back_to_project_id_when_agent_id_unset(self, monkeypatch): - monkeypatch.setenv("UIPATH_PROJECT_ID", "cloud-project-id") + monkeypatch.setenv(ENV_UIPATH_PROJECT_ID, "cloud-project-id") assert UiPathConfig.agent_id == "cloud-project-id" def test_returns_none_when_neither_set(self): @@ -39,7 +87,7 @@ def test_returns_none_when_neither_set(self): class TestCloudUserId: def test_returns_value_when_set(self, monkeypatch): - monkeypatch.setenv("UIPATH_CLOUD_USER_ID", "user-guid") + monkeypatch.setenv(ENV_UIPATH_CLOUD_USER_ID, "user-guid") assert UiPathConfig.cloud_user_id == "user-guid" def test_returns_none_when_unset(self): @@ -48,8 +96,97 @@ def test_returns_none_when_unset(self): class TestProjectFilesSource: def test_returns_value_when_set(self, monkeypatch): - monkeypatch.setenv("UIPATH_PROJECT_FILES_SOURCE", "Local") + monkeypatch.setenv(ENV_UIPATH_PROJECT_FILES_SOURCE, "Local") assert UiPathConfig.project_files_source == "Local" def test_returns_none_when_unset(self): assert UiPathConfig.project_files_source is None + + +@pytest.mark.parametrize( + ("prop", "env_var", "value"), + [ + ("project_key", ENV_PROJECT_KEY, "proj-key"), + ("tenant_name", ENV_TENANT_NAME, "my-tenant"), + ("tenant_id", ENV_TENANT_ID, "tenant-guid"), + ("organization_id", ENV_ORGANIZATION_ID, "org-guid"), + ("base_url", ENV_BASE_URL, "https://cloud.uipath.com/org/tenant"), + ("folder_key", ENV_FOLDER_KEY, "folder-guid"), + ("folder_path", ENV_FOLDER_PATH, "Shared/My Folder"), + ("process_key", ENV_PROCESS_KEY, "process-key"), + ("process_uuid", ENV_UIPATH_PROCESS_UUID, "process-uuid"), + ("trace_id", ENV_UIPATH_TRACE_ID, "trace-id"), + ("process_version", ENV_UIPATH_PROCESS_VERSION, "1.2.3"), + ("job_key", ENV_JOB_KEY, "job-guid"), + ], +) +class TestOptionalEnvVarProperties: + def test_returns_value_when_set(self, monkeypatch, prop, env_var, value): + monkeypatch.setenv(env_var, value) + assert getattr(UiPathConfig, prop) == value + + def test_returns_none_when_unset(self, monkeypatch, prop, env_var, value): + assert getattr(UiPathConfig, prop) is None + + +class TestFileNameProperties: + def test_config_file_name(self): + assert UiPathConfig.config_file_name == UIPATH_CONFIG_FILE + + def test_bindings_file_path(self): + assert UiPathConfig.bindings_file_path == Path(UIPATH_BINDINGS_FILE) + + def test_entry_points_file_path(self): + assert UiPathConfig.entry_points_file_path == Path(ENTRY_POINTS_FILE) + + def test_uiproj_file_path(self): + assert UiPathConfig.uiproj_file_path == Path(UIPROJ_FILE) + + def test_studio_metadata_file_path(self): + assert UiPathConfig.studio_metadata_file_path == Path( + ".uipath", STUDIO_METADATA_FILE + ) + + def test_config_file_path_defaults_to_config_file(self): + assert UiPathConfig.config_file_path == Path(UIPATH_CONFIG_FILE) + + def test_config_file_path_honors_override(self, monkeypatch): + monkeypatch.setenv(ENV_UIPATH_CONFIG_PATH, "custom/config.json") + assert UiPathConfig.config_file_path == Path("custom/config.json") + + +class TestIsStudioProject: + def test_true_when_project_id_set(self, monkeypatch): + monkeypatch.setenv(ENV_UIPATH_PROJECT_ID, "some-id") + assert UiPathConfig.is_studio_project is True + + def test_false_when_project_id_unset(self): + assert UiPathConfig.is_studio_project is False + + +class TestIsTracingEnabled: + def test_defaults_to_true_when_unset(self): + assert UiPathConfig.is_tracing_enabled is True + + @pytest.mark.parametrize("value", ["false", "False", "FALSE"]) + def test_false_when_disabled(self, monkeypatch, value): + monkeypatch.setenv(ENV_TRACING_ENABLED, value) + assert UiPathConfig.is_tracing_enabled is False + + def test_true_when_explicitly_enabled(self, monkeypatch): + monkeypatch.setenv(ENV_TRACING_ENABLED, "true") + assert UiPathConfig.is_tracing_enabled is True + + +class TestEvalFolderDetection: + def test_has_legacy_eval_folder(self, monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + assert UiPathConfig.has_legacy_eval_folder is False + (tmp_path / LEGACY_EVAL_FOLDER).mkdir() + assert UiPathConfig.has_legacy_eval_folder is True + + def test_has_eval_folder(self, monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + assert UiPathConfig.has_eval_folder is False + (tmp_path / EVALS_FOLDER).mkdir() + assert UiPathConfig.has_eval_folder is True diff --git a/packages/uipath-platform/tests/common/test_constants_invariants.py b/packages/uipath-platform/tests/common/test_constants_invariants.py new file mode 100644 index 000000000..f85b36b78 --- /dev/null +++ b/packages/uipath-platform/tests/common/test_constants_invariants.py @@ -0,0 +1,25 @@ +"""Structural invariants for the canonical constants module. + +Exercises ``uipath.platform.constants`` (the source of truth) directly. +Asserts structural contracts, not literal values — a literal-mirror test would +just restate the module. +""" + +import uipath.platform.constants as constants + + +def _public_constants() -> dict[str, object]: + return {n: getattr(constants, n) for n in dir(constants) if not n.startswith("_")} + + +def test_all_constants_are_non_empty_strings(): + for name, value in _public_constants().items(): + assert isinstance(value, str) and value, ( + f"{name} must be a non-empty string, got {value!r}" + ) + + +def test_no_duplicate_header_values(): + # Two header constants mapping to the same wire name is almost certainly a bug. + headers = [v for n, v in _public_constants().items() if n.startswith("HEADER_")] + assert len(headers) == len(set(headers)), "duplicate header wire names detected" diff --git a/packages/uipath-platform/tests/common/test_constants_reexport.py b/packages/uipath-platform/tests/common/test_constants_reexport.py new file mode 100644 index 000000000..484c87731 --- /dev/null +++ b/packages/uipath-platform/tests/common/test_constants_reexport.py @@ -0,0 +1,67 @@ +"""Tests for the constants source of truth and its deprecated re-export. + +``uipath.platform.constants`` is the single source of truth. +``uipath.platform.common.constants`` is a deprecated shim that re-exports it +and emits a FutureWarning. These tests pin the parity invariant (so the two can +never drift — same name, different value) and the deprecation behavior. +""" + +import importlib +import sys +import warnings + + +def _public_names(module) -> set[str]: + return {name for name in dir(module) if not name.startswith("_")} + + +def _import_shim(): + """Import the deprecated shim with its FutureWarning suppressed.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + return importlib.import_module("uipath.platform.common.constants") + + +def test_shim_reexports_canonical_with_no_drift(): + canonical = importlib.import_module("uipath.platform.constants") + shim = _import_shim() + + canonical_names = _public_names(canonical) + + # Every canonical constant is re-exported with an identical value. + missing = sorted(n for n in canonical_names if not hasattr(shim, n)) + assert not missing, f"shim missing constants: {missing}" + + drift = sorted( + n for n in canonical_names if getattr(shim, n) != getattr(canonical, n) + ) + assert not drift, f"value drift between shim and canonical for: {drift}" + + +def test_shim_adds_nothing_of_its_own(): + """The shim must not define constants absent from the canonical module, + otherwise it would no longer be the single source of truth.""" + canonical = importlib.import_module("uipath.platform.constants") + shim = _import_shim() + + extra = sorted(_public_names(shim) - _public_names(canonical)) + assert not extra, f"shim defines names not in canonical module: {extra}" + + +def test_shim_emits_future_warning(): + sys.modules.pop("uipath.platform.common.constants", None) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + importlib.import_module("uipath.platform.common.constants") + + shim_warnings = [ + w + for w in caught + if issubclass(w.category, FutureWarning) + and "uipath.platform.common.constants" in str(w.message) + and "uipath.platform.constants" in str(w.message) + ] + assert len(shim_warnings) == 1, ( + f"expected exactly one deprecation FutureWarning, got {len(shim_warnings)}: " + f"{[str(w.message) for w in caught]}" + ) diff --git a/packages/uipath-platform/tests/common/test_job_context.py b/packages/uipath-platform/tests/common/test_job_context.py index 6a4e1a97d..48e603d7b 100644 --- a/packages/uipath-platform/tests/common/test_job_context.py +++ b/packages/uipath-platform/tests/common/test_job_context.py @@ -1,7 +1,7 @@ import pytest from uipath.platform.common._job_context import header_job_key -from uipath.platform.common.constants import ENV_JOB_KEY, HEADER_JOB_KEY +from uipath.platform.constants import ENV_JOB_KEY, HEADER_JOB_KEY def test_returns_header_when_env_var_set(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/packages/uipath-platform/tests/services/test_actions_service.py b/packages/uipath-platform/tests/services/test_actions_service.py index ea5fe2c84..28180dbbb 100644 --- a/packages/uipath-platform/tests/services/test_actions_service.py +++ b/packages/uipath-platform/tests/services/test_actions_service.py @@ -8,7 +8,7 @@ from uipath.platform.action_center import Task from uipath.platform.action_center._tasks_service import TasksService from uipath.platform.action_center.tasks import TaskRecipient, TaskRecipientType -from uipath.platform.common.constants import HEADER_USER_AGENT +from uipath.platform.constants import HEADER_USER_AGENT @pytest.fixture diff --git a/packages/uipath-platform/tests/services/test_api_client.py b/packages/uipath-platform/tests/services/test_api_client.py index eac74fa3c..842b149ff 100644 --- a/packages/uipath-platform/tests/services/test_api_client.py +++ b/packages/uipath-platform/tests/services/test_api_client.py @@ -3,7 +3,7 @@ from uipath.platform import UiPathApiConfig, UiPathExecutionContext from uipath.platform.common._api_client import ApiClient -from uipath.platform.common.constants import HEADER_USER_AGENT +from uipath.platform.constants import HEADER_USER_AGENT @pytest.fixture diff --git a/packages/uipath-platform/tests/services/test_assets_service.py b/packages/uipath-platform/tests/services/test_assets_service.py index 4b01ed210..94f8ab888 100644 --- a/packages/uipath-platform/tests/services/test_assets_service.py +++ b/packages/uipath-platform/tests/services/test_assets_service.py @@ -4,8 +4,8 @@ from pytest_httpx import HTTPXMock from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.common.constants import HEADER_USER_AGENT from uipath.platform.common.paging import PagedResult +from uipath.platform.constants import HEADER_USER_AGENT from uipath.platform.orchestrator import Asset, UserAsset from uipath.platform.orchestrator._assets_service import AssetsService diff --git a/packages/uipath-platform/tests/services/test_attachments_service.py b/packages/uipath-platform/tests/services/test_attachments_service.py index 0623858e2..8e7b6aaa0 100644 --- a/packages/uipath-platform/tests/services/test_attachments_service.py +++ b/packages/uipath-platform/tests/services/test_attachments_service.py @@ -10,7 +10,7 @@ from uipath.platform import UiPathApiConfig, UiPathExecutionContext from uipath.platform.attachments import Attachment from uipath.platform.attachments.attachments import AttachmentMode -from uipath.platform.common.constants import HEADER_USER_AGENT, TEMP_ATTACHMENTS_FOLDER +from uipath.platform.constants import HEADER_USER_AGENT, TEMP_ATTACHMENTS_FOLDER from uipath.platform.orchestrator._attachments_service import AttachmentsService if TYPE_CHECKING: diff --git a/packages/uipath-platform/tests/services/test_base_service.py b/packages/uipath-platform/tests/services/test_base_service.py index bfe78fd18..83e7895c8 100644 --- a/packages/uipath-platform/tests/services/test_base_service.py +++ b/packages/uipath-platform/tests/services/test_base_service.py @@ -3,7 +3,7 @@ from uipath.platform import UiPathApiConfig, UiPathExecutionContext from uipath.platform.common._base_service import BaseService -from uipath.platform.common.constants import HEADER_USER_AGENT +from uipath.platform.constants import HEADER_USER_AGENT from uipath.platform.errors import EnrichedException diff --git a/packages/uipath-platform/tests/services/test_connections_service.py b/packages/uipath-platform/tests/services/test_connections_service.py index 27dec7310..af74984b8 100644 --- a/packages/uipath-platform/tests/services/test_connections_service.py +++ b/packages/uipath-platform/tests/services/test_connections_service.py @@ -8,7 +8,6 @@ from uipath.platform import UiPathApiConfig, UiPathExecutionContext from uipath.platform.common import jsonschema_to_pydantic -from uipath.platform.common.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT from uipath.platform.connections import ( ActivityMetadata, ActivityParameterLocationInfo, @@ -21,6 +20,7 @@ HEADER_ACTIVITY_JOB_ID, ConnectionsService, ) +from uipath.platform.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT from uipath.platform.orchestrator._folder_service import FolderService diff --git a/packages/uipath-platform/tests/services/test_context_grounding_service.py b/packages/uipath-platform/tests/services/test_context_grounding_service.py index 70c529ec6..b98942769 100644 --- a/packages/uipath-platform/tests/services/test_context_grounding_service.py +++ b/packages/uipath-platform/tests/services/test_context_grounding_service.py @@ -6,7 +6,7 @@ from pytest_httpx import HTTPXMock from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( ENV_JOB_KEY, HEADER_JOB_KEY, HEADER_USER_AGENT, diff --git a/packages/uipath-platform/tests/services/test_folder_context.py b/packages/uipath-platform/tests/services/test_folder_context.py index ceaf33f88..e2fcaf6ca 100644 --- a/packages/uipath-platform/tests/services/test_folder_context.py +++ b/packages/uipath-platform/tests/services/test_folder_context.py @@ -15,7 +15,7 @@ folder_path_header, header_folder, ) -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( HEADER_FOLDER_KEY, HEADER_FOLDER_PATH, HEADER_FOLDER_PATH_ENCODED, diff --git a/packages/uipath-platform/tests/services/test_folder_service.py b/packages/uipath-platform/tests/services/test_folder_service.py index ae4b6804f..023d85ae8 100644 --- a/packages/uipath-platform/tests/services/test_folder_service.py +++ b/packages/uipath-platform/tests/services/test_folder_service.py @@ -2,7 +2,7 @@ from pytest_httpx import HTTPXMock from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.common.constants import HEADER_USER_AGENT +from uipath.platform.constants import HEADER_USER_AGENT from uipath.platform.errors import FolderNotFoundException from uipath.platform.orchestrator._folder_service import FolderService diff --git a/packages/uipath-platform/tests/services/test_jobs_service.py b/packages/uipath-platform/tests/services/test_jobs_service.py index e0321717e..28d24a923 100644 --- a/packages/uipath-platform/tests/services/test_jobs_service.py +++ b/packages/uipath-platform/tests/services/test_jobs_service.py @@ -9,7 +9,7 @@ from pytest_mock import MockerFixture from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.common.constants import HEADER_USER_AGENT, TEMP_ATTACHMENTS_FOLDER +from uipath.platform.constants import HEADER_USER_AGENT, TEMP_ATTACHMENTS_FOLDER from uipath.platform.orchestrator import Job from uipath.platform.orchestrator._jobs_service import JobsService diff --git a/packages/uipath-platform/tests/services/test_llm_trace_context.py b/packages/uipath-platform/tests/services/test_llm_trace_context.py index 234bce050..b8aa164dc 100644 --- a/packages/uipath-platform/tests/services/test_llm_trace_context.py +++ b/packages/uipath-platform/tests/services/test_llm_trace_context.py @@ -8,7 +8,7 @@ from uipath.core.feature_flags import FeatureFlags from uipath.platform.chat.llm_trace_context import build_trace_context_headers -from uipath.platform.common.constants import ENV_PROJECT_KEY +from uipath.platform.constants import ENV_PROJECT_KEY FEATURE_FLAG = "EnableTraceContextHeaders" diff --git a/packages/uipath-platform/tests/services/test_mcp_service.py b/packages/uipath-platform/tests/services/test_mcp_service.py index d40082610..d83699f0c 100644 --- a/packages/uipath-platform/tests/services/test_mcp_service.py +++ b/packages/uipath-platform/tests/services/test_mcp_service.py @@ -4,7 +4,7 @@ from pytest_httpx import HTTPXMock from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.common.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT +from uipath.platform.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT from uipath.platform.orchestrator import McpService from uipath.platform.orchestrator._folder_service import FolderService from uipath.platform.orchestrator.mcp import McpServer diff --git a/packages/uipath-platform/tests/services/test_processes_service.py b/packages/uipath-platform/tests/services/test_processes_service.py index 015888c54..054313a1c 100644 --- a/packages/uipath-platform/tests/services/test_processes_service.py +++ b/packages/uipath-platform/tests/services/test_processes_service.py @@ -5,7 +5,7 @@ from pytest_httpx import HTTPXMock from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.common.constants import HEADER_USER_AGENT +from uipath.platform.constants import HEADER_USER_AGENT from uipath.platform.orchestrator import Job from uipath.platform.orchestrator._attachments_service import AttachmentsService from uipath.platform.orchestrator._processes_service import ProcessesService diff --git a/packages/uipath-platform/tests/services/test_queues_service.py b/packages/uipath-platform/tests/services/test_queues_service.py index 2dd81ccc0..da2d7b876 100644 --- a/packages/uipath-platform/tests/services/test_queues_service.py +++ b/packages/uipath-platform/tests/services/test_queues_service.py @@ -5,7 +5,7 @@ from pytest_httpx import HTTPXMock from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( HEADER_FOLDER_KEY, HEADER_FOLDER_PATH, HEADER_USER_AGENT, diff --git a/packages/uipath-platform/tests/services/test_remote_a2a_service.py b/packages/uipath-platform/tests/services/test_remote_a2a_service.py index 1f4239d4d..d5d611958 100644 --- a/packages/uipath-platform/tests/services/test_remote_a2a_service.py +++ b/packages/uipath-platform/tests/services/test_remote_a2a_service.py @@ -2,7 +2,7 @@ from uipath.platform import UiPathApiConfig, UiPathExecutionContext from uipath.platform.agenthub._remote_a2a_service import RemoteA2aService -from uipath.platform.common.constants import HEADER_FOLDER_KEY +from uipath.platform.constants import HEADER_FOLDER_KEY from uipath.platform.orchestrator._folder_service import FolderService diff --git a/packages/uipath-platform/tests/services/test_resource_catalog_service.py b/packages/uipath-platform/tests/services/test_resource_catalog_service.py index 3db6ee60e..fb2d9f4ea 100644 --- a/packages/uipath-platform/tests/services/test_resource_catalog_service.py +++ b/packages/uipath-platform/tests/services/test_resource_catalog_service.py @@ -5,7 +5,7 @@ from pytest_httpx import HTTPXMock from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.common.constants import HEADER_USER_AGENT +from uipath.platform.constants import HEADER_USER_AGENT from uipath.platform.orchestrator._folder_service import FolderService from uipath.platform.resource_catalog import ResourceType from uipath.platform.resource_catalog._resource_catalog_service import ( diff --git a/packages/uipath-platform/tests/services/test_service_url_overrides.py b/packages/uipath-platform/tests/services/test_service_url_overrides.py index ef1c6db23..271a7d183 100644 --- a/packages/uipath-platform/tests/services/test_service_url_overrides.py +++ b/packages/uipath-platform/tests/services/test_service_url_overrides.py @@ -4,7 +4,7 @@ inject_routing_headers, resolve_service_url, ) -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( HEADER_INTERNAL_ACCOUNT_ID, HEADER_INTERNAL_TENANT_ID, ) diff --git a/packages/uipath-platform/tests/services/test_span_utils.py b/packages/uipath-platform/tests/services/test_span_utils.py index 6b5994894..14bff69ad 100644 --- a/packages/uipath-platform/tests/services/test_span_utils.py +++ b/packages/uipath-platform/tests/services/test_span_utils.py @@ -16,7 +16,7 @@ SpanStatus, VerbosityLevel, ) -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( ENV_PROJECT_KEY, ENV_UIPATH_AGENT_ID, ENV_UIPATH_PROJECT_ID, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 39c160fe8..3424b09e9 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.88" +version = "0.1.89" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 6dd4d5091..efcce771c 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.12.3" +version = "2.12.4" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.26, <0.6.0", "uipath-runtime>=0.11.5, <0.12.0", - "uipath-platform>=0.1.87, <0.2.0", + "uipath-platform>=0.1.89, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/src/uipath/_cli/__init__.py b/packages/uipath/src/uipath/_cli/__init__.py index e48b3d4d3..f12d46560 100644 --- a/packages/uipath/src/uipath/_cli/__init__.py +++ b/packages/uipath/src/uipath/_cli/__init__.py @@ -7,7 +7,7 @@ from uipath._cli._utils._context import CliContext from uipath._utils._logs import setup_logging -from uipath.platform.common.constants import DOTENV_FILE +from uipath.platform.constants import DOTENV_FILE # Windows console uses codepages (e.g. cp1252) that can't encode Unicode # characters used by Rich spinners (Braille) and emoji output. diff --git a/packages/uipath/src/uipath/_cli/_auth/_auth_service.py b/packages/uipath/src/uipath/_cli/_auth/_auth_service.py index 88d94d1fb..cdaf430af 100644 --- a/packages/uipath/src/uipath/_cli/_auth/_auth_service.py +++ b/packages/uipath/src/uipath/_cli/_auth/_auth_service.py @@ -9,7 +9,7 @@ from uipath._cli._utils._console import ConsoleLogger from uipath._utils._auth import update_env_file from uipath.platform.common import ExternalApplicationService, TokenData -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( ENV_BASE_URL, ENV_ORGANIZATION_ID, ENV_TENANT_ID, diff --git a/packages/uipath/src/uipath/_cli/_auth/_auth_session.py b/packages/uipath/src/uipath/_cli/_auth/_auth_session.py index 839bc1415..3d50b1a17 100644 --- a/packages/uipath/src/uipath/_cli/_auth/_auth_session.py +++ b/packages/uipath/src/uipath/_cli/_auth/_auth_session.py @@ -3,7 +3,7 @@ import click from uipath.platform.common import TokenData -from uipath.platform.common.constants import ENV_UIPATH_ACCESS_TOKEN +from uipath.platform.constants import ENV_UIPATH_ACCESS_TOKEN from uipath.platform.identity import IdentityService from uipath.platform.portal import ( PortalService as PlatformPortalService, diff --git a/packages/uipath/src/uipath/_cli/_auth/_url_utils.py b/packages/uipath/src/uipath/_cli/_auth/_url_utils.py index e232d6dfb..6bebe7a23 100644 --- a/packages/uipath/src/uipath/_cli/_auth/_url_utils.py +++ b/packages/uipath/src/uipath/_cli/_auth/_url_utils.py @@ -2,7 +2,8 @@ from typing import Tuple from urllib.parse import urlparse -from ...platform.common.constants import ENV_BASE_URL +from uipath.platform.constants import ENV_BASE_URL + from .._utils._console import ConsoleLogger console = ConsoleLogger() diff --git a/packages/uipath/src/uipath/_cli/_chat/_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_bridge.py index ebefd1cff..bdf620caa 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_bridge.py @@ -21,7 +21,7 @@ UiPathConversationToolCallEvent, ) from uipath.core.triggers import UiPathResumeTrigger -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( ENV_BASE_URL, ENV_ORGANIZATION_ID, ENV_TENANT_ID, diff --git a/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py index b575404b3..d962c2bf0 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py @@ -17,7 +17,7 @@ UiPathVoiceToolCallRequest, UiPathVoiceToolCallResult, ) -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( ENV_BASE_URL, ENV_ORGANIZATION_ID, ENV_TENANT_ID, diff --git a/packages/uipath/src/uipath/_cli/_debug/_bridge.py b/packages/uipath/src/uipath/_cli/_debug/_bridge.py index db152057e..0094bc012 100644 --- a/packages/uipath/src/uipath/_cli/_debug/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_debug/_bridge.py @@ -13,7 +13,7 @@ from uipath.core.serialization import serialize_object from uipath.core.triggers import UiPathResumeTriggerType -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( ENV_BASE_URL, ENV_UIPATH_ACCESS_TOKEN, HEADER_FOLDER_KEY, diff --git a/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py b/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py index 325d53f50..b1ac65a65 100644 --- a/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py +++ b/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py @@ -33,7 +33,7 @@ ) from uipath.platform import UiPath from uipath.platform.common import UiPathConfig -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( ENV_EVAL_BACKEND_URL, ENV_TENANT_ID, ENV_UIPATH_AGENT_ID, diff --git a/packages/uipath/src/uipath/_cli/_evals/_telemetry.py b/packages/uipath/src/uipath/_cli/_evals/_telemetry.py index 3bedccb5d..a39123326 100644 --- a/packages/uipath/src/uipath/_cli/_evals/_telemetry.py +++ b/packages/uipath/src/uipath/_cli/_evals/_telemetry.py @@ -19,7 +19,7 @@ EvaluationEvents, ) from uipath.platform.common import UiPathConfig -from uipath.platform.common.constants import ENV_TENANT_ID +from uipath.platform.constants import ENV_TENANT_ID from uipath.telemetry._track import is_telemetry_enabled, track_event logger = logging.getLogger(__name__) diff --git a/packages/uipath/src/uipath/_cli/_push/sw_file_handler.py b/packages/uipath/src/uipath/_cli/_push/sw_file_handler.py index 95bafde9f..19bfa060f 100644 --- a/packages/uipath/src/uipath/_cli/_push/sw_file_handler.py +++ b/packages/uipath/src/uipath/_cli/_push/sw_file_handler.py @@ -10,7 +10,7 @@ from uipath._cli.models.uipath_json_schema import PackOptions from uipath.platform.common import UiPathConfig -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( EVALS_FOLDER, LEGACY_EVAL_FOLDER, PYTHON_CONFIGURATION_FILE, diff --git a/packages/uipath/src/uipath/_cli/_telemetry.py b/packages/uipath/src/uipath/_cli/_telemetry.py index 3668e09ba..03d72a294 100644 --- a/packages/uipath/src/uipath/_cli/_telemetry.py +++ b/packages/uipath/src/uipath/_cli/_telemetry.py @@ -7,7 +7,7 @@ from uipath._cli._utils._common import get_claim_from_token from uipath.platform.common import UiPathConfig -from uipath.platform.common.constants import ENV_UIPATH_AGENT_ID +from uipath.platform.constants import ENV_UIPATH_AGENT_ID from uipath.telemetry._track import ( _get_project_key, is_telemetry_enabled, diff --git a/packages/uipath/src/uipath/_cli/_utils/_common.py b/packages/uipath/src/uipath/_cli/_utils/_common.py index b67d5071c..d346a5560 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_common.py +++ b/packages/uipath/src/uipath/_cli/_utils/_common.py @@ -13,7 +13,7 @@ ResourceOverwriteParser, UiPathConfig, ) -from uipath.platform.common.constants import ENV_BASE_URL, ENV_UIPATH_ACCESS_TOKEN +from uipath.platform.constants import ENV_BASE_URL, ENV_UIPATH_ACCESS_TOKEN from ..models.runtime_schema import EntryPoint from ..spinner import Spinner diff --git a/packages/uipath/src/uipath/_cli/_utils/_project_files.py b/packages/uipath/src/uipath/_cli/_utils/_project_files.py index ccd774123..7121306a5 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_project_files.py +++ b/packages/uipath/src/uipath/_cli/_utils/_project_files.py @@ -13,7 +13,7 @@ from uipath._cli.models.uipath_json_schema import PackOptions, UiPathJsonConfig from uipath.platform.common import UiPathConfig -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( LEGACY_EVAL_FOLDER, PYTHON_CONFIGURATION_FILE, UIPATH_CONFIG_FILE, diff --git a/packages/uipath/src/uipath/_cli/_utils/_service_base.py b/packages/uipath/src/uipath/_cli/_utils/_service_base.py index ba622c1e4..6441b573e 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_service_base.py +++ b/packages/uipath/src/uipath/_cli/_utils/_service_base.py @@ -19,7 +19,8 @@ import click from httpx import HTTPError -from ...platform.common.constants import ENV_BASE_URL, ENV_UIPATH_ACCESS_TOKEN +from uipath.platform.constants import ENV_BASE_URL, ENV_UIPATH_ACCESS_TOKEN + from ...platform.errors import ( BaseUrlMissingError, EnrichedException, diff --git a/packages/uipath/src/uipath/_cli/_utils/_studio_project.py b/packages/uipath/src/uipath/_cli/_utils/_studio_project.py index eb9e06d29..3a60fc3ca 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_studio_project.py +++ b/packages/uipath/src/uipath/_cli/_utils/_studio_project.py @@ -14,7 +14,7 @@ ResourceOverwriteParser, UiPathConfig, ) -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( ENV_TENANT_ID, HEADER_SW_LOCK_KEY, HEADER_TENANT_ID, diff --git a/packages/uipath/src/uipath/_cli/cli_eval.py b/packages/uipath/src/uipath/_cli/cli_eval.py index 2903d45fc..310909e59 100644 --- a/packages/uipath/src/uipath/_cli/cli_eval.py +++ b/packages/uipath/src/uipath/_cli/cli_eval.py @@ -26,7 +26,7 @@ ResourceOverwritesContext, UiPathConfig, ) -from uipath.platform.common.constants import ENV_FOLDER_KEY +from uipath.platform.constants import ENV_FOLDER_KEY from uipath.runtime import ( UiPathRuntimeContext, UiPathRuntimeFactoryRegistry, diff --git a/packages/uipath/src/uipath/_cli/cli_init.py b/packages/uipath/src/uipath/_cli/cli_init.py index 3f113349e..9899df7dd 100644 --- a/packages/uipath/src/uipath/_cli/cli_init.py +++ b/packages/uipath/src/uipath/_cli/cli_init.py @@ -23,7 +23,7 @@ ) from uipath.platform.common import UiPathConfig -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( DOTENV_FILE, ENTRY_POINTS_FILE, PYTHON_CONFIGURATION_FILE, diff --git a/packages/uipath/src/uipath/_cli/cli_invoke.py b/packages/uipath/src/uipath/_cli/cli_invoke.py index 73400b915..62bee75ed 100644 --- a/packages/uipath/src/uipath/_cli/cli_invoke.py +++ b/packages/uipath/src/uipath/_cli/cli_invoke.py @@ -6,8 +6,9 @@ import click import httpx +from uipath.platform.constants import PYTHON_CONFIGURATION_FILE + from .._utils._ssl_context import get_httpx_client_kwargs -from ..platform.common.constants import PYTHON_CONFIGURATION_FILE from ._telemetry import track_command from ._utils._common import get_env_vars from ._utils._console import ConsoleLogger diff --git a/packages/uipath/src/uipath/_cli/cli_new.py b/packages/uipath/src/uipath/_cli/cli_new.py index 7cea4dcc8..0a7723d05 100644 --- a/packages/uipath/src/uipath/_cli/cli_new.py +++ b/packages/uipath/src/uipath/_cli/cli_new.py @@ -4,7 +4,8 @@ import click -from ..platform.common.constants import PYTHON_CONFIGURATION_FILE, UIPATH_CONFIG_FILE +from uipath.platform.constants import PYTHON_CONFIGURATION_FILE, UIPATH_CONFIG_FILE + from ._telemetry import track_command from ._utils._console import ConsoleLogger from .middlewares import Middlewares diff --git a/packages/uipath/src/uipath/_cli/cli_pack.py b/packages/uipath/src/uipath/_cli/cli_pack.py index 3a2dbad5a..19a9bad69 100644 --- a/packages/uipath/src/uipath/_cli/cli_pack.py +++ b/packages/uipath/src/uipath/_cli/cli_pack.py @@ -11,7 +11,7 @@ from uipath._cli.models.uipath_json_schema import UiPathJsonConfig from uipath.eval.constants import EVALS_FOLDER, LEGACY_EVAL_FOLDER from uipath.platform.common import UiPathConfig -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( ENTRY_POINTS_FILE, PYTHON_CONFIGURATION_FILE, UIPATH_BINDINGS_FILE, diff --git a/packages/uipath/src/uipath/_cli/models/runtime_schema.py b/packages/uipath/src/uipath/_cli/models/runtime_schema.py index 7b7c39dc1..baa62740d 100644 --- a/packages/uipath/src/uipath/_cli/models/runtime_schema.py +++ b/packages/uipath/src/uipath/_cli/models/runtime_schema.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, ConfigDict, Field -from ...platform.common.constants import ENTRY_POINTS_FILE +from uipath.platform.constants import ENTRY_POINTS_FILE class BaseModelWithDefaultConfig(BaseModel): diff --git a/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py b/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py index 572c878c5..a47b82522 100644 --- a/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py +++ b/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, ConfigDict, Field -from ...platform.common.constants import UIPATH_CONFIG_FILE +from uipath.platform.constants import UIPATH_CONFIG_FILE class BaseModelWithDefaultConfig(BaseModel): diff --git a/packages/uipath/src/uipath/_utils/_auth.py b/packages/uipath/src/uipath/_utils/_auth.py index 18bd36e8a..b13ab5316 100644 --- a/packages/uipath/src/uipath/_utils/_auth.py +++ b/packages/uipath/src/uipath/_utils/_auth.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Optional -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( DOTENV_FILE, ENV_BASE_URL, ENV_UIPATH_ACCESS_TOKEN, diff --git a/packages/uipath/src/uipath/_utils/_request_override.py b/packages/uipath/src/uipath/_utils/_request_override.py index f2bc3c062..872ce79ed 100644 --- a/packages/uipath/src/uipath/_utils/_request_override.py +++ b/packages/uipath/src/uipath/_utils/_request_override.py @@ -1,7 +1,7 @@ from base64 import b64encode from typing import Optional -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( HEADER_FOLDER_KEY, HEADER_FOLDER_PATH, HEADER_FOLDER_PATH_ENCODED, diff --git a/packages/uipath/src/uipath/_utils/_user_agent.py b/packages/uipath/src/uipath/_utils/_user_agent.py index 0b930d917..1083faa45 100644 --- a/packages/uipath/src/uipath/_utils/_user_agent.py +++ b/packages/uipath/src/uipath/_utils/_user_agent.py @@ -1,6 +1,6 @@ import importlib -from uipath.platform.common.constants import HEADER_USER_AGENT +from uipath.platform.constants import HEADER_USER_AGENT def user_agent_value(specific_component: str) -> str: diff --git a/packages/uipath/src/uipath/_utils/constants.py b/packages/uipath/src/uipath/_utils/constants.py index 186a55352..a50459a4b 100644 --- a/packages/uipath/src/uipath/_utils/constants.py +++ b/packages/uipath/src/uipath/_utils/constants.py @@ -1,16 +1,16 @@ -"""Deprecated alias for ``uipath.platform.common.constants``. +"""Deprecated alias for the canonical constants module. This module is kept as a backward-compatibility shim so existing imports keep -working. New code should import from ``uipath.platform.common.constants``. +working. New code should import from ``uipath.platform.constants``. """ import warnings as _warnings -from uipath.platform.common.constants import * # noqa: F401,F403 +from uipath.platform.constants import * # noqa: F401,F403 _warnings.warn( - "uipath._utils.constants is deprecated and will be removed in a future " - "release; import from uipath.platform.common.constants instead.", + "uipath._utils.constants is deprecated and will be removed in a future release; " + "import from uipath.platform.constants instead.", FutureWarning, stacklevel=2, ) diff --git a/packages/uipath/src/uipath/eval/evaluators/legacy_evaluator_utils.py b/packages/uipath/src/uipath/eval/evaluators/legacy_evaluator_utils.py index 4bd241eaa..76d517cea 100644 --- a/packages/uipath/src/uipath/eval/evaluators/legacy_evaluator_utils.py +++ b/packages/uipath/src/uipath/eval/evaluators/legacy_evaluator_utils.py @@ -3,7 +3,7 @@ import json from typing import Any, Optional -from uipath.platform.common.constants import COMMUNITY_agents_SUFFIX +from uipath.platform.constants import COMMUNITY_agents_SUFFIX def clean_model_name(model: str) -> str: diff --git a/packages/uipath/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py index f37b62dba..280e3ee98 100644 --- a/packages/uipath/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py @@ -8,7 +8,7 @@ from uipath.platform import UiPath from uipath.platform.chat import UiPathLlmChatService from uipath.platform.chat.llm_gateway import RequiredToolChoice -from uipath.platform.common.constants import COMMUNITY_agents_SUFFIX +from uipath.platform.constants import COMMUNITY_agents_SUFFIX from .._execution_context import eval_set_run_id_context from .._helpers.helpers import is_empty_value diff --git a/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py index 9cc516062..680c41be0 100644 --- a/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py @@ -9,7 +9,7 @@ from uipath.platform import UiPath from uipath.platform.chat import UiPathLlmChatService from uipath.platform.chat.llm_gateway import RequiredToolChoice -from uipath.platform.common.constants import COMMUNITY_agents_SUFFIX +from uipath.platform.constants import COMMUNITY_agents_SUFFIX from .._execution_context import eval_set_run_id_context from .._helpers.evaluators_helpers import trace_to_str diff --git a/packages/uipath/src/uipath/eval/evaluators/llm_as_judge_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/llm_as_judge_evaluator.py index ccbace5dc..4285f7965 100644 --- a/packages/uipath/src/uipath/eval/evaluators/llm_as_judge_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/llm_as_judge_evaluator.py @@ -11,7 +11,7 @@ from uipath.platform import UiPath from uipath.platform.chat import UiPathLlmChatService -from uipath.platform.common.constants import COMMUNITY_agents_SUFFIX +from uipath.platform.constants import COMMUNITY_agents_SUFFIX from .._execution_context import eval_set_run_id_context from ..models import ( diff --git a/packages/uipath/src/uipath/eval/mocks/_simulate_component_service.py b/packages/uipath/src/uipath/eval/mocks/_simulate_component_service.py index d01426d99..29f8d3a20 100644 --- a/packages/uipath/src/uipath/eval/mocks/_simulate_component_service.py +++ b/packages/uipath/src/uipath/eval/mocks/_simulate_component_service.py @@ -4,7 +4,7 @@ from uipath._utils import Endpoint from uipath.platform.common import BaseService -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( HEADER_INTERNAL_ACCOUNT_ID, HEADER_INTERNAL_TENANT_ID, ) diff --git a/packages/uipath/src/uipath/functions/__init__.py b/packages/uipath/src/uipath/functions/__init__.py index a79a0473e..43991807f 100644 --- a/packages/uipath/src/uipath/functions/__init__.py +++ b/packages/uipath/src/uipath/functions/__init__.py @@ -1,6 +1,6 @@ """UiPath Functions Runtime - factory and runtime for function-based execution.""" -from uipath.platform.common.constants import UIPATH_CONFIG_FILE +from uipath.platform.constants import UIPATH_CONFIG_FILE from uipath.runtime import UiPathRuntimeFactoryRegistry from .debug import UiPathDebugFunctionsRuntime diff --git a/packages/uipath/src/uipath/functions/factory.py b/packages/uipath/src/uipath/functions/factory.py index 4d60dbaa9..4700ab726 100644 --- a/packages/uipath/src/uipath/functions/factory.py +++ b/packages/uipath/src/uipath/functions/factory.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Any -from uipath.platform.common.constants import UIPATH_CONFIG_FILE +from uipath.platform.constants import UIPATH_CONFIG_FILE from uipath.runtime import ( UiPathRuntimeFactorySettings, UiPathRuntimeProtocol, diff --git a/packages/uipath/src/uipath/telemetry/_track.py b/packages/uipath/src/uipath/telemetry/_track.py index 603796a17..efde079c4 100644 --- a/packages/uipath/src/uipath/telemetry/_track.py +++ b/packages/uipath/src/uipath/telemetry/_track.py @@ -9,7 +9,7 @@ from opentelemetry.sdk._logs import LoggingHandler from opentelemetry.util.types import AnyValue -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( ENV_BASE_URL, ENV_ORGANIZATION_ID, ENV_TELEMETRY_ENABLED, diff --git a/packages/uipath/src/uipath/tracing/_otel_exporters.py b/packages/uipath/src/uipath/tracing/_otel_exporters.py index 6f85a1050..fd7d01d5c 100644 --- a/packages/uipath/src/uipath/tracing/_otel_exporters.py +++ b/packages/uipath/src/uipath/tracing/_otel_exporters.py @@ -14,7 +14,8 @@ from uipath._utils._ssl_context import get_httpx_client_kwargs from uipath.platform.common import _SpanUtils from uipath.platform.common._span_utils import SpanStatus -from uipath.platform.common.constants import ( +from uipath.platform.common.retry import NON_RETRYABLE_STATUS_CODES +from uipath.platform.constants import ( ENV_BASE_URL, ENV_ORGANIZATION_ID, ENV_TENANT_ID, @@ -22,7 +23,6 @@ HEADER_INTERNAL_ACCOUNT_ID, HEADER_INTERNAL_TENANT_ID, ) -from uipath.platform.common.retry import NON_RETRYABLE_STATUS_CODES logger = logging.getLogger(__name__) diff --git a/packages/uipath/tests/cli/chat/test_bridge.py b/packages/uipath/tests/cli/chat/test_bridge.py index 0a8f29e86..6c67bc6a9 100644 --- a/packages/uipath/tests/cli/chat/test_bridge.py +++ b/packages/uipath/tests/cli/chat/test_bridge.py @@ -11,7 +11,7 @@ from uipath._cli._chat._bridge import SocketIOChatBridge, get_chat_bridge from uipath._cli._debug._bridge import SignalRDebugBridge from uipath.core.triggers import UiPathApiTrigger, UiPathResumeTrigger -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( HEADER_INTERNAL_ACCOUNT_ID, HEADER_INTERNAL_TENANT_ID, ) diff --git a/packages/uipath/tests/cli/chat/test_voice_bridge.py b/packages/uipath/tests/cli/chat/test_voice_bridge.py index a9c8c1baf..d3f8c483d 100644 --- a/packages/uipath/tests/cli/chat/test_voice_bridge.py +++ b/packages/uipath/tests/cli/chat/test_voice_bridge.py @@ -14,7 +14,7 @@ UiPathVoiceToolCallRequest, UiPathVoiceToolCallResult, ) -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( HEADER_INTERNAL_ACCOUNT_ID, HEADER_INTERNAL_TENANT_ID, ) diff --git a/packages/uipath/tests/cli/eval/test_eval_telemetry.py b/packages/uipath/tests/cli/eval/test_eval_telemetry.py index 4a9dcf64d..468180bf7 100644 --- a/packages/uipath/tests/cli/eval/test_eval_telemetry.py +++ b/packages/uipath/tests/cli/eval/test_eval_telemetry.py @@ -25,7 +25,7 @@ EvalSetRunCreatedEvent, EvalSetRunUpdatedEvent, ) -from uipath.platform.common.constants import ENV_UIPATH_AGENT_ID +from uipath.platform.constants import ENV_UIPATH_AGENT_ID class TestEventNameConstants: diff --git a/packages/uipath/tests/sdk/test_utils_constants_shim.py b/packages/uipath/tests/sdk/test_utils_constants_shim.py index b5d038aa8..a29334413 100644 --- a/packages/uipath/tests/sdk/test_utils_constants_shim.py +++ b/packages/uipath/tests/sdk/test_utils_constants_shim.py @@ -1,9 +1,9 @@ """Regression tests for the uipath._utils.constants deprecation shim. -The shim re-exports from uipath.platform.common.constants and emits a -FutureWarning so external consumers can migrate. Internal callsites are -already on the canonical path; these tests pin the shim's behavior so it -keeps working for downstream code. +The shim re-exports from uipath.platform.constants (the source of truth) and emits +a FutureWarning so external consumers can migrate. Internal callsites are +already on the canonical path; these tests pin the shim's behavior so it keeps +working for downstream code. """ import importlib @@ -13,11 +13,7 @@ def _reload_shim(): """Force a fresh import of the shim so FutureWarning re-fires.""" - for name in [ - "uipath._utils.constants", - "uipath.platform.common.constants", - ]: - sys.modules.pop(name, None) + sys.modules.pop("uipath._utils.constants", None) return importlib.import_module("uipath._utils.constants") @@ -31,7 +27,7 @@ def test_shim_emits_future_warning(): for w in caught if issubclass(w.category, FutureWarning) and "uipath._utils.constants" in str(w.message) - and "uipath.platform.common.constants" in str(w.message) + and "uipath.platform.constants" in str(w.message) ] assert len(shim_warnings) == 1, ( f"expected exactly one shim FutureWarning, got {len(shim_warnings)}: " @@ -41,7 +37,7 @@ def test_shim_emits_future_warning(): def test_shim_re_exports_canonical_symbols(): shim = _reload_shim() - canonical = importlib.import_module("uipath.platform.common.constants") + canonical = importlib.import_module("uipath.platform.constants") # Sample a representative set: env vars, headers, mixed-case symbols, # file constants, data-source magic strings. diff --git a/packages/uipath/tests/telemetry/test_track.py b/packages/uipath/tests/telemetry/test_track.py index 827b3878d..ed44846b9 100644 --- a/packages/uipath/tests/telemetry/test_track.py +++ b/packages/uipath/tests/telemetry/test_track.py @@ -6,7 +6,7 @@ import pytest -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( ENV_PROJECT_KEY, ENV_UIPATH_AGENT_ID, ENV_UIPATH_PROJECT_ID, diff --git a/packages/uipath/tests/tracing/test_otel_exporters.py b/packages/uipath/tests/tracing/test_otel_exporters.py index 8c2dc93a4..bba826be5 100644 --- a/packages/uipath/tests/tracing/test_otel_exporters.py +++ b/packages/uipath/tests/tracing/test_otel_exporters.py @@ -8,7 +8,7 @@ from opentelemetry.sdk.trace.export import SpanExportResult from uipath.platform.common._span_utils import SpanStatus -from uipath.platform.common.constants import ( +from uipath.platform.constants import ( HEADER_INTERNAL_ACCOUNT_ID, HEADER_INTERNAL_TENANT_ID, ) diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 5898c093d..f40bccd88 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.12.3" +version = "2.12.4" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.88" +version = "0.1.89" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From a5a5090ab5763e5ca238800d0232bdd54e9eb4f5 Mon Sep 17 00:00:00 2001 From: UIPath-Harshit Date: Thu, 2 Jul 2026 15:13:48 +0530 Subject: [PATCH 142/191] feat(platform): add Data Fabric error classification and extractor (#1784) Co-authored-by: Claude Opus 4.6 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../platform/entities/_entities_service.py | 3 + .../src/uipath/platform/errors/__init__.py | 4 + .../platform/errors/_datafabric_error.py | 109 ++++++++++ .../errors/_extractors/_datafabric.py | 21 ++ .../platform/errors/_extractors/_router.py | 2 + .../platform/errors/datafabric_error_codes.py | 69 +++++++ .../tests/errors/test_datafabric_errors.py | 189 ++++++++++++++++++ .../tests/services/test_entities_service.py | 13 ++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 11 files changed, 413 insertions(+), 3 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/errors/_datafabric_error.py create mode 100644 packages/uipath-platform/src/uipath/platform/errors/_extractors/_datafabric.py create mode 100644 packages/uipath-platform/src/uipath/platform/errors/datafabric_error_codes.py create mode 100644 packages/uipath-platform/tests/errors/test_datafabric_errors.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index a304aca15..9dd30533b 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.89" +version = "0.1.90" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py index fa3c0da9e..31e1a2de3 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py @@ -24,6 +24,7 @@ from ..common._bindings import _resource_overwrites from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext +from ..errors._datafabric_error import attach_datafabric_error_mapping from ..orchestrator._folder_service import FolderService from ._entity_data_service import EntityDataService, FileContent from ._entity_resolution import ( @@ -1788,6 +1789,7 @@ async def retrieve_records_async( limit=limit, ) + @attach_datafabric_error_mapping("query_entity_records") @traced(name="entity_query_records", run_type="uipath") def query_entity_records(self, sql_query: str) -> List[Dict[str, Any]]: """Query entity records using a validated SQL query. @@ -1812,6 +1814,7 @@ def query_entity_records(self, sql_query: str) -> List[Dict[str, Any]]: """ return self._data.query_entity_records(sql_query) + @attach_datafabric_error_mapping("query_entity_records_async") @traced(name="entity_query_records", run_type="uipath") async def query_entity_records_async(self, sql_query: str) -> List[Dict[str, Any]]: """Asynchronously query entity records using a validated SQL query. diff --git a/packages/uipath-platform/src/uipath/platform/errors/__init__.py b/packages/uipath-platform/src/uipath/platform/errors/__init__.py index 97f7e6d98..5ac51d326 100644 --- a/packages/uipath-platform/src/uipath/platform/errors/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/errors/__init__.py @@ -22,6 +22,7 @@ from ._context_grounding_index_not_found_exception import ( ContextGroundingIndexNotFoundError, ) +from ._datafabric_error import DataFabricError from ._enriched_exception import EnrichedException, ExtractedErrorInfo from ._folder_not_found_exception import FolderNotFoundException from ._ingestion_in_progress_exception import IngestionInProgressException @@ -29,9 +30,12 @@ from ._operation_not_complete_exception import OperationNotCompleteException from ._secret_missing_error import SecretMissingError from ._unsupported_data_source_exception import UnsupportedDataSourceException +from .datafabric_error_codes import DataFabricErrorCategory __all__ = [ "BaseUrlMissingError", + "DataFabricError", + "DataFabricErrorCategory", "BatchTransformFailedException", "BatchTransformNotCompleteException", "ContextGroundingIndexNotFoundError", diff --git a/packages/uipath-platform/src/uipath/platform/errors/_datafabric_error.py b/packages/uipath-platform/src/uipath/platform/errors/_datafabric_error.py new file mode 100644 index 000000000..e697ac164 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/_datafabric_error.py @@ -0,0 +1,109 @@ +"""Data Fabric query engine error classification. + +Maps error codes from the DF query engine invoking the "query_execute" endpoint to actionable +categories so that callers (e.g. the agent SQL sub-graph) can decide +whether to retry, ask the LLM to fix the SQL, or surface an infra error. + +The server error response JSON has the shape: + {"error": "", "code": "", "traceId": ""} +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Callable, TypeVar + +from ._extractors._helpers import extract_service_prefix +from .datafabric_error_codes import ( + _QUERY_ENTITY_RECORDS_ERROR_CODES, + DataFabricErrorCategory, + classify_error_code, +) + +if TYPE_CHECKING: + from ._enriched_exception import EnrichedException + + +TCallable = TypeVar("TCallable", bound=Callable[..., Any]) + + +def attach_datafabric_error_mapping( + method_name: str, +) -> Callable[[TCallable], TCallable]: + """Attach Data Fabric error metadata to a query method.""" + + def decorator(func: TCallable) -> TCallable: + func.__uipath_datafabric_method__ = method_name # type: ignore[attr-defined] + func.__uipath_datafabric_error_codes__ = ( # type: ignore[attr-defined] + _QUERY_ENTITY_RECORDS_ERROR_CODES + ) + return func + + return decorator + + +@dataclass(frozen=True) +class DataFabricError: + """Structured error parsed from a DF query engine response.""" + + code: str | None + message: str | None + trace_id: str | None + category: DataFabricErrorCategory + + @property + def is_retryable(self) -> bool: + return self.category == DataFabricErrorCategory.RETRYABLE + + @property + def is_bad_sql(self) -> bool: + return self.category == DataFabricErrorCategory.BAD_SQL + + @staticmethod + def from_enriched_exception(exc: EnrichedException) -> DataFabricError | None: + """Extract a DataFabricError from an EnrichedException, if applicable. + + Returns None if the exception is not from a Data Fabric endpoint. + """ + if extract_service_prefix(exc.url) != "datafabric_": + return None + + info = exc.error_info + code = info.error_code if info else None + message = info.message if info else None + trace_id = info.trace_id if info else None + + return DataFabricError( + code=code, + message=message, + trace_id=trace_id, + category=classify_error_code(code), + ) + + @staticmethod + def from_response_body(body: dict[str, Any]) -> DataFabricError: + """Parse a DataFabricError directly from a response body dict.""" + raw_code = body.get("code") + code = ( + str(raw_code) + if raw_code is not None and not isinstance(raw_code, (dict, list)) + else None + ) + message = body.get("error") + if not isinstance(message, str): + message = ( + body.get("message") if isinstance(body.get("message"), str) else None + ) + trace_id = body.get("traceId") + if not isinstance(trace_id, str): + trace_id = ( + body.get("requestId") + if isinstance(body.get("requestId"), str) + else None + ) + return DataFabricError( + code=code, + message=message, + trace_id=trace_id, + category=classify_error_code(code), + ) diff --git a/packages/uipath-platform/src/uipath/platform/errors/_extractors/_datafabric.py b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_datafabric.py new file mode 100644 index 000000000..57682a24f --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_datafabric.py @@ -0,0 +1,21 @@ +"""Data Fabric query engine error payload extractor. + +DF returns: {"error": "", "code": "", "traceId": ""} +""" + +from typing import Any + +from .._enriched_exception import ExtractedErrorInfo +from ._helpers import get_str_field, get_typed_field + + +def extract_datafabric(body: dict[str, Any]) -> ExtractedErrorInfo: + message = get_typed_field(body, str, "error", "message") + error_code = get_str_field(body, "code", "errorCode") + trace_id = get_typed_field(body, str, "traceId", "requestId") + + return ExtractedErrorInfo( + message=message, + error_code=error_code, + trace_id=trace_id, + ) diff --git a/packages/uipath-platform/src/uipath/platform/errors/_extractors/_router.py b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_router.py index 37369113b..901dbc089 100644 --- a/packages/uipath-platform/src/uipath/platform/errors/_extractors/_router.py +++ b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_router.py @@ -14,6 +14,7 @@ from .._enriched_exception import ExtractedErrorInfo from ._agenthub import extract_agenthub from ._apps import extract_apps +from ._datafabric import extract_datafabric from ._elements import extract_elements from ._generic import extract_generic from ._helpers import extract_service_prefix, is_llm_path @@ -29,6 +30,7 @@ "agenthub_": extract_agenthub, "agentsruntime_": extract_agenthub, "apps_": extract_apps, + "datafabric_": extract_datafabric, "elements_": extract_elements, "llmopstenant_": extract_llmops, } diff --git a/packages/uipath-platform/src/uipath/platform/errors/datafabric_error_codes.py b/packages/uipath-platform/src/uipath/platform/errors/datafabric_error_codes.py new file mode 100644 index 000000000..81130db95 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/datafabric_error_codes.py @@ -0,0 +1,69 @@ +"""Data Fabric query-engine error code constants.""" + +from __future__ import annotations + +from enum import Enum + +_RETRYABLE_CODES: frozenset[str] = frozenset( + { + "EXECUTION_TIMEOUT", + "SQLITE_BUSY", + "EXECUTION_INTERRUPTED", + } +) + +_BAD_SQL_CODES: frozenset[str] = frozenset( + { + "SQL_PARSING", + "SQL_VALIDATION", + } +) + +_INFRASTRUCTURE_CODES: frozenset[str] = frozenset( + { + "SQLITE_MEMORY_FULL", + "EPHEMERAL_STORAGE_ERROR", + "INTERNAL_ERROR", + "FQS_ERROR", + } +) + +_DATA_ISSUE_CODES: frozenset[str] = frozenset( + { + "FRAGMENT_EXECUTION_FAILURE", + "CONTEXT_CREATION", + "UNKNOWN_ENTITY", + "EXECUTION_ERROR", + "RESULT_TOO_LARGE", + } +) + +_QUERY_ENTITY_RECORDS_ERROR_CODES: frozenset[str] = frozenset( + {*_RETRYABLE_CODES, *_BAD_SQL_CODES, *_INFRASTRUCTURE_CODES, *_DATA_ISSUE_CODES} +) + + +class DataFabricErrorCategory(str, Enum): + """Actionable error category for Data Fabric query failures.""" + + RETRYABLE = "retryable" + BAD_SQL = "bad_sql" + INFRASTRUCTURE = "infrastructure" + DATA_ISSUE = "data_issue" + UNKNOWN = "unknown" + + +def classify_error_code(code: str | None) -> DataFabricErrorCategory: + """Classify a DF error code string into an actionable category.""" + if not code: + return DataFabricErrorCategory.UNKNOWN + upper = code.upper() + if upper in _RETRYABLE_CODES: + return DataFabricErrorCategory.RETRYABLE + if upper in _BAD_SQL_CODES: + return DataFabricErrorCategory.BAD_SQL + if upper in _INFRASTRUCTURE_CODES: + return DataFabricErrorCategory.INFRASTRUCTURE + if upper in _DATA_ISSUE_CODES: + return DataFabricErrorCategory.DATA_ISSUE + return DataFabricErrorCategory.UNKNOWN diff --git a/packages/uipath-platform/tests/errors/test_datafabric_errors.py b/packages/uipath-platform/tests/errors/test_datafabric_errors.py new file mode 100644 index 000000000..39c477ab5 --- /dev/null +++ b/packages/uipath-platform/tests/errors/test_datafabric_errors.py @@ -0,0 +1,189 @@ +"""Tests for Data Fabric error classification, extraction, and routing.""" + +import json + +import httpx + +from uipath.platform.errors import ( + DataFabricError, + DataFabricErrorCategory, + EnrichedException, +) +from uipath.platform.errors._extractors._datafabric import extract_datafabric +from uipath.platform.errors._extractors._router import extract_error_info +from uipath.platform.errors.datafabric_error_codes import classify_error_code + +_DATAFABRIC_URL = "https://cloud.uipath.com/org/tenant/datafabric_/api/v1" +_NON_DF_URL = "https://cloud.uipath.com/org/tenant/orchestrator_/api/v1" + + +def _make_enriched( + url: str = _DATAFABRIC_URL, + body: str = "{}", + status_code: int = 400, +) -> EnrichedException: + raw = httpx.HTTPStatusError( + message=f"Server error {status_code}", + request=httpx.Request("POST", url), + response=httpx.Response( + status_code, + content=body.encode(), + headers={"content-type": "application/json"}, + ), + ) + return EnrichedException(raw) + + +# ---------- classify_error_code ---------- + + +class TestClassifyErrorCode: + def test_retryable_codes(self) -> None: + for code in ("EXECUTION_TIMEOUT", "SQLITE_BUSY", "EXECUTION_INTERRUPTED"): + assert classify_error_code(code) == DataFabricErrorCategory.RETRYABLE + + def test_bad_sql_codes(self) -> None: + for code in ("SQL_PARSING", "SQL_VALIDATION"): + assert classify_error_code(code) == DataFabricErrorCategory.BAD_SQL + + def test_infrastructure_codes(self) -> None: + for code in ( + "SQLITE_MEMORY_FULL", + "EPHEMERAL_STORAGE_ERROR", + "INTERNAL_ERROR", + "FQS_ERROR", + ): + assert classify_error_code(code) == DataFabricErrorCategory.INFRASTRUCTURE + + def test_data_issue_codes(self) -> None: + for code in ( + "FRAGMENT_EXECUTION_FAILURE", + "CONTEXT_CREATION", + "UNKNOWN_ENTITY", + "EXECUTION_ERROR", + "RESULT_TOO_LARGE", + ): + assert classify_error_code(code) == DataFabricErrorCategory.DATA_ISSUE + + def test_unknown_code(self) -> None: + assert classify_error_code("NEVER_HEARD_OF") == DataFabricErrorCategory.UNKNOWN + + def test_none_code(self) -> None: + assert classify_error_code(None) == DataFabricErrorCategory.UNKNOWN + + def test_empty_string(self) -> None: + assert classify_error_code("") == DataFabricErrorCategory.UNKNOWN + + def test_case_insensitive(self) -> None: + assert classify_error_code("sql_parsing") == DataFabricErrorCategory.BAD_SQL + assert ( + classify_error_code("Execution_Timeout") + == DataFabricErrorCategory.RETRYABLE + ) + + +# ---------- DataFabricError ---------- + + +class TestDataFabricError: + def test_is_retryable(self) -> None: + err = DataFabricError( + code="EXECUTION_TIMEOUT", + message="timed out", + trace_id="abc", + category=DataFabricErrorCategory.RETRYABLE, + ) + assert err.is_retryable is True + assert err.is_bad_sql is False + + def test_is_bad_sql(self) -> None: + err = DataFabricError( + code="SQL_PARSING", + message="bad sql", + trace_id="abc", + category=DataFabricErrorCategory.BAD_SQL, + ) + assert err.is_bad_sql is True + assert err.is_retryable is False + + def test_from_response_body(self) -> None: + body = { + "error": "something went wrong", + "code": "INTERNAL_ERROR", + "traceId": "trace-123", + } + err = DataFabricError.from_response_body(body) + assert err.code == "INTERNAL_ERROR" + assert err.message == "something went wrong" + assert err.trace_id == "trace-123" + assert err.category == DataFabricErrorCategory.INFRASTRUCTURE + + def test_from_response_body_missing_fields(self) -> None: + err = DataFabricError.from_response_body({}) + assert err.code is None + assert err.message is None + assert err.trace_id is None + assert err.category == DataFabricErrorCategory.UNKNOWN + + def test_from_enriched_exception_datafabric_url(self) -> None: + body = json.dumps({"error": "bad sql", "code": "SQL_PARSING", "traceId": "t-1"}) + exc = _make_enriched(url=_DATAFABRIC_URL, body=body) + err = DataFabricError.from_enriched_exception(exc) + assert err is not None + assert err.code == "SQL_PARSING" + assert err.message == "bad sql" + assert err.trace_id == "t-1" + assert err.category == DataFabricErrorCategory.BAD_SQL + + def test_from_enriched_exception_non_datafabric_url_returns_none(self) -> None: + body = json.dumps({"error": "oops", "code": "SQL_PARSING"}) + exc = _make_enriched(url=_NON_DF_URL, body=body) + assert DataFabricError.from_enriched_exception(exc) is None + + def test_from_enriched_exception_no_error_info(self) -> None: + exc = _make_enriched(url=_DATAFABRIC_URL, body="not json at all {{{") + err = DataFabricError.from_enriched_exception(exc) + assert err is not None + assert err.code is None + assert err.message is None + assert err.category == DataFabricErrorCategory.UNKNOWN + + +# ---------- extract_datafabric ---------- + + +class TestExtractDatafabric: + def test_extracts_all_fields(self) -> None: + body = {"error": "msg", "code": "SQL_PARSING", "traceId": "t-1"} + info = extract_datafabric(body) + assert info.message == "msg" + assert info.error_code == "SQL_PARSING" + assert info.trace_id == "t-1" + + def test_falls_back_to_message_key(self) -> None: + body = {"message": "fallback msg", "code": "X"} + info = extract_datafabric(body) + assert info.message == "fallback msg" + + def test_missing_fields(self) -> None: + info = extract_datafabric({}) + assert info.message is None + assert info.error_code is None + assert info.trace_id is None + + +# ---------- Router: datafabric prefix ---------- + + +class TestRouterDatafabric: + def test_routes_to_datafabric_extractor(self) -> None: + body = json.dumps( + {"error": "timeout", "code": "EXECUTION_TIMEOUT", "traceId": "t-2"} + ) + info = extract_error_info(_DATAFABRIC_URL, body) + assert info is not None + assert info.error_code == "EXECUTION_TIMEOUT" + assert info.trace_id == "t-2" + + def test_non_json_returns_none(self) -> None: + assert extract_error_info(_DATAFABRIC_URL, "not json") is None diff --git a/packages/uipath-platform/tests/services/test_entities_service.py b/packages/uipath-platform/tests/services/test_entities_service.py index 258c3a9d8..2bef45193 100644 --- a/packages/uipath-platform/tests/services/test_entities_service.py +++ b/packages/uipath-platform/tests/services/test_entities_service.py @@ -54,6 +54,19 @@ def record_schema_optional(request): class TestEntitiesService: + def test_query_entity_records_has_datafabric_error_mapping(self) -> None: + assert ( + EntitiesService.query_entity_records.__uipath_datafabric_method__ # type: ignore[attr-defined] + == "query_entity_records" + ) + assert ( + EntitiesService.query_entity_records.__uipath_datafabric_error_codes__ # type: ignore[attr-defined] + == EntitiesService.query_entity_records_async.__uipath_datafabric_error_codes__ # type: ignore[attr-defined] + ) + assert "SQL_PARSING" in ( + EntitiesService.query_entity_records.__uipath_datafabric_error_codes__ # type: ignore[attr-defined] + ) + def test_retrieve( self, httpx_mock: HTTPXMock, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 3424b09e9..5ae4cb5d6 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.89" +version = "0.1.90" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index f40bccd88..87ca9e781 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.89" +version = "0.1.90" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 16522419d2b366628bcf63faa5d6fe6ccd0dd4ce Mon Sep 17 00:00:00 2001 From: saksharthakkar <162214066+saksharthakkar@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:03:09 -0700 Subject: [PATCH 143/191] feat(tracing): derive LLM Ops URL source param from span Source field (#1783) Co-authored-by: Claude Opus 4.8 (1M context) --- packages/uipath/pyproject.toml | 2 +- .../src/uipath/tracing/_otel_exporters.py | 14 ++-- .../tests/tracing/test_otel_exporters.py | 73 ++++++++++++++++++- packages/uipath/uv.lock | 2 +- 4 files changed, 83 insertions(+), 8 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index efcce771c..20bc31c4f 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.12.4" +version = "2.12.5" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/tracing/_otel_exporters.py b/packages/uipath/src/uipath/tracing/_otel_exporters.py index fd7d01d5c..f8ec3aace 100644 --- a/packages/uipath/src/uipath/tracing/_otel_exporters.py +++ b/packages/uipath/src/uipath/tracing/_otel_exporters.py @@ -13,7 +13,7 @@ from uipath._utils._ssl_context import get_httpx_client_kwargs from uipath.platform.common import _SpanUtils -from uipath.platform.common._span_utils import SpanStatus +from uipath.platform.common._span_utils import SpanSource, SpanStatus from uipath.platform.common.retry import NON_RETRYABLE_STATUS_CODES from uipath.platform.constants import ( ENV_BASE_URL, @@ -381,11 +381,15 @@ def _process_span_attributes(self, span_data: Dict[str, Any]) -> None: span_data["Status"] = status def _build_url(self, span_list: list[Dict[str, Any]]) -> str: - """Construct the URL for the API request.""" + """Construct the URL for the API request. + + The `source` query param is what the server persists as Trace.Source + (the span-body Source is ignored on ingest), so derive it from the + span's resolved SpanSource. Falls back to CodedAgents when absent. + """ trace_id = str(span_list[0]["TraceId"]) - return ( - f"{self.base_url}/api/Traces/v3/spans?traceId={trace_id}&source=CodedAgents" - ) + source = str(span_list[0].get("Source") or SpanSource.CODED_AGENTS) + return f"{self.base_url}/api/Traces/v3/spans?traceId={trace_id}&source={source}" def _send_with_retries( self, url: str, payload: list[Dict[str, Any]], max_retries: int = 4 diff --git a/packages/uipath/tests/tracing/test_otel_exporters.py b/packages/uipath/tests/tracing/test_otel_exporters.py index bba826be5..939dbb8cd 100644 --- a/packages/uipath/tests/tracing/test_otel_exporters.py +++ b/packages/uipath/tests/tracing/test_otel_exporters.py @@ -7,7 +7,7 @@ from opentelemetry.sdk.trace import ReadableSpan from opentelemetry.sdk.trace.export import SpanExportResult -from uipath.platform.common._span_utils import SpanStatus +from uipath.platform.common._span_utils import SpanSource, SpanStatus from uipath.platform.constants import ( HEADER_INTERNAL_ACCOUNT_ID, HEADER_INTERNAL_TENANT_ID, @@ -290,6 +290,77 @@ def test_build_url_uses_v3_endpoint(mock_env_vars): assert "/api/Traces/spans" not in url.replace("/api/Traces/v3/spans", "") +def test_build_url_uses_span_source_agents(mock_env_vars): + """_build_url must render the span's Source (Agents), not the hardcoded CodedAgents.""" + with patch("uipath.tracing._otel_exporters.httpx.Client"): + exporter = LlmOpsHttpExporter() + span_list = [{"TraceId": "ab" * 16, "Source": SpanSource.AGENTS}] + url = exporter._build_url(span_list) + assert "&source=Agents" in url + assert "&source=CodedAgents" not in url + assert "/api/Traces/v3/spans" in url + + +def test_build_url_uses_span_source_coded_agents(mock_env_vars): + """An explicit CodedAgents Source still renders CodedAgents.""" + with patch("uipath.tracing._otel_exporters.httpx.Client"): + exporter = LlmOpsHttpExporter() + span_list = [{"TraceId": "ab" * 16, "Source": SpanSource.CODED_AGENTS}] + url = exporter._build_url(span_list) + assert "&source=CodedAgents" in url + + +def test_build_url_defaults_to_coded_agents_when_source_missing(mock_env_vars): + """When the span dict has no Source key, default to CodedAgents (back-compat).""" + with patch("uipath.tracing._otel_exporters.httpx.Client"): + exporter = LlmOpsHttpExporter() + span_list = [{"TraceId": "ab" * 16}] + url = exporter._build_url(span_list) + assert "&source=CodedAgents" in url + + +def test_agent_builder_span_yields_source_agents(mock_env_vars): + """A span with uipath.source=1 must flow through to &source=Agents in the URL. + + Drives a real span dict through otel_span_to_uipath_span().to_dict() rather + than hand-building it, guarding the whole attribute->Source->URL path. + """ + from opentelemetry.trace import SpanContext, StatusCode + + from uipath.platform.common import _SpanUtils + + # otel_span_to_uipath_span reads the context via get_span_context() and + # formats trace_id/span_id as hex, so provide a real SpanContext. + span = MagicMock(spec=ReadableSpan) + span.get_span_context.return_value = SpanContext( + trace_id=0xABCDEF1234567890ABCDEF1234567890, + span_id=0x1234567890ABCDEF, + is_remote=False, + ) + span.parent = None + span.name = "agent-span" + span.status.status_code = StatusCode.OK + span.status.description = None + span.attributes = { + "uipath.custom_instrumentation": True, + "uipath.source": 1, # SourceEnum.Agents + } + span.events = [] + span.links = [] + span.start_time = 0 + span.end_time = 1 + + span_dict = _SpanUtils.otel_span_to_uipath_span( + span, serialize_attributes=False + ).to_dict(serialize_attributes=False) + assert str(span_dict["Source"]) == "Agents" + + with patch("uipath.tracing._otel_exporters.httpx.Client"): + exporter = LlmOpsHttpExporter() + url = exporter._build_url([span_dict]) + assert "&source=Agents" in url + + def test_determine_status_ok_returns_string(mock_env_vars): with patch("uipath.tracing._otel_exporters.httpx.Client"): exporter = LlmOpsHttpExporter() diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 87ca9e781..8156765aa 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.12.4" +version = "2.12.5" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 53c894beab91afebdd3ee11d2ee453c9f774fb04 Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan <84002867+viswa-uipath@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:51:38 +0530 Subject: [PATCH 144/191] feat(cli): shared host-side governance bootstrap for run and debug (#1778) Co-authored-by: Claude Opus 4.7 (1M context) --- packages/uipath/pyproject.toml | 6 +- .../src/uipath/_cli/_evals/_telemetry.py | 41 +- .../src/uipath/_cli/_governance/__init__.py | 16 + .../src/uipath/_cli/_governance/yaml_index.py | 532 +++++++++++ .../src/uipath/_cli/_governance_bootstrap.py | 174 ++++ packages/uipath/src/uipath/_cli/cli_debug.py | 52 +- packages/uipath/src/uipath/_cli/cli_run.py | 52 +- .../uipath/src/uipath/eval/runtime/events.py | 5 + .../uipath/src/uipath/eval/runtime/runtime.py | 4 + .../uipath/src/uipath/functions/factory.py | 20 +- .../uipath/tests/cli/_governance/__init__.py | 0 .../tests/cli/_governance/test_yaml_index.py | 775 ++++++++++++++++ .../tests/cli/eval/test_eval_telemetry.py | 52 ++ .../tests/cli/test_governance_bootstrap.py | 858 ++++++++++++++++++ packages/uipath/uv.lock | 74 +- 15 files changed, 2629 insertions(+), 32 deletions(-) create mode 100644 packages/uipath/src/uipath/_cli/_governance/__init__.py create mode 100644 packages/uipath/src/uipath/_cli/_governance/yaml_index.py create mode 100644 packages/uipath/src/uipath/_cli/_governance_bootstrap.py create mode 100644 packages/uipath/tests/cli/_governance/__init__.py create mode 100644 packages/uipath/tests/cli/_governance/test_yaml_index.py create mode 100644 packages/uipath/tests/cli/test_governance_bootstrap.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 20bc31c4f..15791f4d5 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "uipath" -version = "2.12.5" +version = "2.12.6" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.26, <0.6.0", - "uipath-runtime>=0.11.5, <0.12.0", + "uipath-runtime>=0.11.7, <0.12.0", "uipath-platform>=0.1.89, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", @@ -24,6 +24,7 @@ dependencies = [ "mermaid-builder==0.0.3", "graphtty==0.1.8", "applicationinsights>=0.11.10", + "pyyaml>=6.0, <7.0", ] classifiers = [ "Intended Audience :: Developers", @@ -75,6 +76,7 @@ dev = [ "mkdocs-llmstxt>=0.5.0", "inflection>=0.5.1", "types-toml>=0.10.8", + "types-PyYAML>=6.0", "pytest-timeout>=2.4.0", ] diff --git a/packages/uipath/src/uipath/_cli/_evals/_telemetry.py b/packages/uipath/src/uipath/_cli/_evals/_telemetry.py index a39123326..b3dfb160e 100644 --- a/packages/uipath/src/uipath/_cli/_evals/_telemetry.py +++ b/packages/uipath/src/uipath/_cli/_evals/_telemetry.py @@ -57,17 +57,23 @@ def __init__(self) -> None: self._eval_run_info: dict[str, dict[str, Any]] = {} self._current_eval_set_run_id: str | None = None self._current_entrypoint: str | None = None + self._current_agent_type: str | None = None @staticmethod - def _get_agent_type(entrypoint: str) -> str: - """Determine agent type from entrypoint. - - Args: - entrypoint: The entrypoint path. - - Returns: - "LowCode" if entrypoint is "agent.json", "Coded" otherwise. + def _resolve_agent_type(agent_type: str | None, entrypoint: str | None) -> str: + """Emit the historical ``"LowCode"`` / ``"Coded"`` wire values. + + Application Insights dashboards filter on those two exact strings. + When the factory supplies a modern label (e.g. ``"uipath_lowcode"``, + ``"uipath_coded"``) we normalize; when it supplies nothing we fall + back to the pre-refactor entrypoint check (``agent.json`` ⇒ + low-code) so no in-flight consumer breaks. """ + if agent_type is not None: + if "lowcode" in agent_type.lower() or "low_code" in agent_type.lower(): + return "LowCode" + return "Coded" + # Fallback: pre-refactor entrypoint-based derivation. if entrypoint == "agent.json": return "LowCode" return "Coded" @@ -105,6 +111,7 @@ async def _on_eval_set_run_created(self, event: EvalSetRunCreatedEvent) -> None: "eval_set_id": event.eval_set_id, "eval_set_run_id": eval_set_run_id, "entrypoint": event.entrypoint, + "agent_type": event.agent_type, "no_of_evals": event.no_of_evals, "evaluator_count": len(event.evaluators), } @@ -112,6 +119,7 @@ async def _on_eval_set_run_created(self, event: EvalSetRunCreatedEvent) -> None: # Store for child events self._current_eval_set_run_id = eval_set_run_id self._current_entrypoint = event.entrypoint + self._current_agent_type = event.agent_type properties: dict[str, Any] = { "EvalSetId": event.eval_set_id, @@ -119,7 +127,9 @@ async def _on_eval_set_run_created(self, event: EvalSetRunCreatedEvent) -> None: "Entrypoint": event.entrypoint, "EvalCount": event.no_of_evals, "EvaluatorCount": len(event.evaluators), - "AgentType": self._get_agent_type(event.entrypoint), + "AgentType": self._resolve_agent_type( + event.agent_type, event.entrypoint + ), "Runtime": "URT", } @@ -156,7 +166,9 @@ async def _on_eval_run_created(self, event: EvalRunCreatedEvent) -> None: # Add entrypoint and agent type if self._current_entrypoint: properties["Entrypoint"] = self._current_entrypoint - properties["AgentType"] = self._get_agent_type(self._current_entrypoint) + properties["AgentType"] = self._resolve_agent_type( + self._current_agent_type, self._current_entrypoint + ) self._enrich_properties(properties) @@ -207,7 +219,9 @@ async def _on_eval_run_updated(self, event: EvalRunUpdatedEvent) -> None: if self._current_entrypoint: properties["Entrypoint"] = self._current_entrypoint - properties["AgentType"] = self._get_agent_type(self._current_entrypoint) + properties["AgentType"] = self._resolve_agent_type( + self._current_agent_type, self._current_entrypoint + ) if trace_id: properties["TraceId"] = trace_id @@ -271,7 +285,9 @@ async def _on_eval_set_run_updated(self, event: EvalSetRunUpdatedEvent) -> None: if set_info.get("entrypoint"): properties["Entrypoint"] = set_info["entrypoint"] - properties["AgentType"] = self._get_agent_type(set_info["entrypoint"]) + properties["AgentType"] = self._resolve_agent_type( + set_info.get("agent_type"), set_info["entrypoint"] + ) properties["Runtime"] = "URT" @@ -299,6 +315,7 @@ async def _on_eval_set_run_updated(self, event: EvalSetRunUpdatedEvent) -> None: self._current_eval_set_run_id = None self._current_entrypoint = None + self._current_agent_type = None except Exception as e: logger.debug(f"Error tracking eval set run updated: {e}") diff --git a/packages/uipath/src/uipath/_cli/_governance/__init__.py b/packages/uipath/src/uipath/_cli/_governance/__init__.py new file mode 100644 index 000000000..fc49b92a1 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_governance/__init__.py @@ -0,0 +1,16 @@ +"""CLI-side governance helpers. + +Host-only glue that turns provider responses into inputs the runtime +consumes. Owns the YAML → :class:`PolicyIndex` compiler (the runtime +layer stays format-agnostic and only accepts a compiled index). + +Public helpers: + +- :func:`build_policy_index_from_yaml` — parse a YAML policy pack (as + returned by :meth:`GovernancePolicyProvider.get_policy_async`) into + a :class:`uipath.runtime.governance.native.PolicyIndex`. +""" + +from .yaml_index import build_policy_index_from_yaml + +__all__ = ["build_policy_index_from_yaml"] diff --git a/packages/uipath/src/uipath/_cli/_governance/yaml_index.py b/packages/uipath/src/uipath/_cli/_governance/yaml_index.py new file mode 100644 index 000000000..4da02a276 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_governance/yaml_index.py @@ -0,0 +1,532 @@ +"""YAML → :class:`PolicyIndex` compiler. + +Lives CLI-side so the runtime layer never has to depend on ``pyyaml`` +or know about the wire policy format — the runtime consumes compiled +indexes only. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from typing import Any + +import yaml + +from uipath.core.governance.models import Action, LifecycleHook +from uipath.runtime.governance.native.models import ( + Check, + Condition, + Logic, + PolicyIndex, + PolicyPack, + Rule, + Severity, +) + +logger = logging.getLogger(__name__) + + +_HOOK_MAP: dict[str, LifecycleHook] = { + "before_agent": LifecycleHook.BEFORE_AGENT, + "after_agent": LifecycleHook.AFTER_AGENT, + "before_model": LifecycleHook.BEFORE_MODEL, + "after_model": LifecycleHook.AFTER_MODEL, + "wrap_tool_call": LifecycleHook.TOOL_CALL, + "tool_call": LifecycleHook.TOOL_CALL, + "after_tool": LifecycleHook.AFTER_TOOL, +} + +_ACTION_MAP: dict[str, Action] = { + "block": Action.DENY, + "deny": Action.DENY, + "log": Action.AUDIT, + "audit": Action.AUDIT, + "allow": Action.ALLOW, + "require_approval": Action.ESCALATE, + "escalate": Action.ESCALATE, +} + +_SEVERITY_MAP: dict[str, Severity] = { + "low": Severity.LOW, + "medium": Severity.MEDIUM, + "high": Severity.HIGH, + "critical": Severity.CRITICAL, +} + + +def build_policy_index_from_yaml(yaml_text: str) -> PolicyIndex: + """Parse YAML policy packs into a :class:`PolicyIndex`. + + Unknown check types and malformed rules are skipped with a debug log + (partial packs preferred over failing the whole load); malformed + YAML at the document level raises :class:`yaml.YAMLError`. + """ + index = PolicyIndex() + documents = list(yaml.safe_load_all(yaml_text)) + + for doc in documents: + if not isinstance(doc, dict): + continue + pack = _build_pack(doc) + if pack is not None and pack.rules: + index.add_pack(pack) + + logger.debug( + "Built PolicyIndex from YAML: packs=%s, rules=%d", + index.pack_names, + index.total_rules, + ) + return index + + +def _build_pack(data: dict[str, Any]) -> PolicyPack | None: + """Build a PolicyPack from one YAML document.""" + name = data.get("standard") or data.get("name") + if not name: + logger.warning("Skipping pack: missing 'standard'/'name' field") + return None + + default_action_str = data.get("default_action", "block") + default_action = _ACTION_MAP.get(default_action_str, Action.DENY) + + rules: list[Rule] = [] + for i, rule_data in enumerate(data.get("rules", []) or []): + if not isinstance(rule_data, dict): + continue + rule = _build_rule(rule_data, default_action, i) + if rule is not None: + rules.append(rule) + + return PolicyPack( + name=str(name), + version=str(data.get("version", "1.0.0")), + description=str(data.get("description", "")), + rules=rules, + ) + + +def _build_rule( + data: dict[str, Any], default_action: Action, index: int +) -> Rule | None: + """Build a single Rule from a YAML rule entry.""" + hook = _HOOK_MAP.get(data.get("hook", "before_model")) + if hook is None: + logger.warning( + "Skipping rule %s: unknown hook %r", data.get("id"), data.get("hook") + ) + return None + + action_str = data.get("action") + action = ( + _ACTION_MAP.get(action_str, default_action) if action_str else default_action + ) + + default_sev = "high" if action == Action.DENY else "medium" + severity = _SEVERITY_MAP.get(data.get("severity", default_sev), Severity.HIGH) + + checks = _build_checks( + data.get("checks", []) or [], + action, + mapped_to_uipath=bool(data.get("mapped_to_uipath", False)), + policy_enabled=bool(data.get("policy_enabled", True)), + ) + + # If checks were declared but none could be parsed (e.g. all unknown + # types), skip the rule. A rule with zero checks "always matches" in + # the evaluator, so keeping it would make it fire on every request. + declared = data.get("checks", []) or [] + if declared and not checks: + logger.warning( + "Skipping rule %s: none of its %d declared check(s) could be parsed", + data.get("id"), + len(declared), + ) + return None + + return Rule( + rule_id=str(data.get("id", f"RULE-{index}")), + name=str(data.get("name", data.get("id", f"RULE-{index}"))), + clause=str(data.get("clause", data.get("owasp_ref", ""))), + hook=hook, + action=action, + severity=severity, + checks=checks, + enabled=bool(data.get("enabled", True)), + description=str(data.get("description", "")), + ) + + +def _build_checks( + checks_data: list[dict[str, Any]], + default_action: Action, + *, + mapped_to_uipath: bool = False, + policy_enabled: bool = True, +) -> list[Check]: + """Build the checks list for a rule. + + ``mapped_to_uipath`` / ``policy_enabled`` are rule-level flags read + by ``guardrail_fallback`` checks so the per-check condition can + decide whether to fire the compensating governance call. + """ + checks: list[Check] = [] + for check_data in checks_data: + if not isinstance(check_data, dict): + continue + check = _build_check( + check_data, + default_action, + mapped_to_uipath=mapped_to_uipath, + policy_enabled=policy_enabled, + ) + if check is not None: + checks.append(check) + return checks + + +# --------------------------------------------------------------------------- +# Per-check-type condition builders +# +# Each returns ``(conditions, default_message)`` given the YAML entry for +# one check. The main :func:`_build_check` picks the right builder from +# :data:`_CHECK_BUILDERS` and layers action / logic / message resolution +# on top — keeping the dispatch flat instead of one giant if/elif chain. +# --------------------------------------------------------------------------- + + +def _build_regex_conditions(data: dict[str, Any]) -> tuple[list[Condition], str]: + scope = data.get("scope", ["human", "ai"]) + field = _field_for_scope(scope) + conditions = [ + Condition(operator="regex", field=field, value=pattern) + for pattern in (data.get("patterns", []) or []) + ] + return conditions, f"Pattern matched in {scope}" + + +def _build_budget_conditions(data: dict[str, Any]) -> tuple[list[Condition], str]: + return ( + _gt_conditions_from_keys( + data, + ( + ("max_tool_calls_per_session", "session_state.tool_calls"), + ("max_tool_calls_per_minute", "session_state.tool_calls_per_minute"), + ( + "max_consecutive_tool_calls", + "session_state.consecutive_tool_calls", + ), + ), + ), + "Tool budget exceeded", + ) + + +def _build_tool_allowlist_conditions( + data: dict[str, Any], +) -> tuple[list[Condition], str]: + blocked_tools = data.get("blocked_tools", []) or [] + conditions = ( + [Condition(operator="in_list", field="tool_name", value=blocked_tools)] + if blocked_tools + else [] + ) + return conditions, "Tool not allowed" + + +def _build_parameter_validation_conditions( + data: dict[str, Any], +) -> tuple[list[Condition], str]: + conditions = [ + Condition(operator="regex", field="tool_args", value=pattern) + for pattern in (data.get("additional_patterns", []) or []) + ] + return conditions, "Suspicious pattern in tool parameters" + + +def _build_rate_limit_conditions( + data: dict[str, Any], +) -> tuple[list[Condition], str]: + return ( + _gt_conditions_from_keys( + data, + ( + ("max_llm_calls_per_session", "session_state.llm_calls"), + ("max_llm_calls_per_minute", "session_state.llm_calls_per_minute"), + ), + ), + "Rate limit exceeded", + ) + + +def _build_field_regex_conditions( + data: dict[str, Any], +) -> tuple[list[Condition], str]: + conditions = _make_conditions(data.get("conditions", []) or []) + return conditions, str(data.get("message", "Field regex check failed")) + + +def _build_data_quality_score_conditions( + data: dict[str, Any], +) -> tuple[list[Condition], str]: + field = data.get("field", "tool_result") + conditions: list[Condition] = [] + if data.get("check_encoding", True): + conditions.append( + Condition( + operator="encoding_concern", + field=field, + value={ + "min_confidence": float(data.get("min_confidence", 0.5)), + "max_replacement_ratio": float( + data.get("max_replacement_ratio", 0.05) + ), + "min_corruption_events": int(data.get("min_corruption_events", 2)), + }, + ) + ) + if data.get("check_entropy", True): + conditions.append( + Condition( + operator="entropy_concern", + field=field, + value={ + "min": float(data.get("entropy_min", 1.5)), + "max": float(data.get("entropy_max", 7.5)), + }, + ) + ) + return conditions, str(data.get("message", "")) + + +def _build_incident_taxonomy_conditions( + data: dict[str, Any], +) -> tuple[list[Condition], str]: + field = data.get("field", "model_output") + categories = data.get("categories") + value: dict[str, Any] = {} + if categories: + value["categories"] = list(categories) + conditions = [Condition(operator="incident_concern", field=field, value=value)] + return conditions, str(data.get("message", "")) + + +def _build_commitment_extractor_conditions( + data: dict[str, Any], +) -> tuple[list[Condition], str]: + field = data.get("field", "model_output") + conditions = [ + Condition( + operator="commitment_concern", + field=field, + value={ + "require_amount": bool(data.get("require_amount", True)), + "require_deadline": bool(data.get("require_deadline", False)), + }, + ) + ] + return conditions, str(data.get("message", "")) + + +def _build_sentiment_concern_conditions( + data: dict[str, Any], +) -> tuple[list[Condition], str]: + field = data.get("field", "model_input") + threshold = float(data.get("threshold", -0.3)) + conditions = [ + Condition( + operator="vader_concern", + field=field, + value={"threshold": threshold}, + ) + ] + default_msg = f"Negative sentiment detected (VADER compound <= {threshold})" + return conditions, default_msg + + +def _gt_conditions_from_keys( + data: dict[str, Any], + keys_to_fields: tuple[tuple[str, str], ...], +) -> list[Condition]: + """Emit ``gt`` conditions for each YAML key present in ``data``. + + Shared by budget/rate_limit builders — they only differ in which + (YAML key → CheckContext field) pairs they scan. + """ + return [ + Condition(operator="gt", field=field, value=data[key]) + for key, field in keys_to_fields + if key in data + ] + + +# check_type → builder. ``guardrail_fallback`` is handled inline in +# :func:`_build_check` because it needs the rule-level flags. +_CHECK_BUILDERS: dict[str, Callable[[dict[str, Any]], tuple[list[Condition], str]]] = { + "regex": _build_regex_conditions, + "budget": _build_budget_conditions, + "tool_allowlist": _build_tool_allowlist_conditions, + "parameter_validation": _build_parameter_validation_conditions, + "rate_limit": _build_rate_limit_conditions, + "field_regex": _build_field_regex_conditions, + "data_quality_score": _build_data_quality_score_conditions, + "incident_taxonomy": _build_incident_taxonomy_conditions, + "commitment_extractor": _build_commitment_extractor_conditions, + "sentiment_concern": _build_sentiment_concern_conditions, +} + + +def _build_guardrail_fallback_conditions( + data: dict[str, Any], + *, + mapped_to_uipath: bool, + policy_enabled: bool, +) -> tuple[list[Condition], str]: + """Compensating-control condition. Depends on rule-level flags. + + ``validator`` names which guardrail check the compensating call + should run. The runtime's ``guardrail_fallback`` operator fires + only when the guardrail is mapped to UiPath but disabled. + """ + conditions = [ + Condition( + operator="guardrail_fallback", + field="", + value={ + "validator": str(data.get("validator", "")), + "mapped_to_uipath": mapped_to_uipath, + "policy_enabled": policy_enabled, + }, + ) + ] + default_msg = "Guardrail disabled — compensating check needed." + return conditions, default_msg + + +def _resolve_action(data: dict[str, Any], default_action: Action) -> Action: + """Resolve the check's action against ``_ACTION_MAP`` with a default fallback.""" + action_str = data.get("action") + if not action_str: + return default_action + return _ACTION_MAP.get(action_str, default_action) + + +def _resolve_logic( + data: dict[str, Any], + *, + has_explicit_conditions: bool, + check_type: str, + n_conditions: int, +) -> Logic: + """Resolve the check's ``logic`` field with the right default. + + Multi-pattern shorthand (``regex`` / ``parameter_validation`` + expanded from several patterns for one concept) defaults to ``any`` + — any pattern hitting is a match. An explicit ``conditions:`` list + defaults to ``all`` (all must hold) and must NOT inherit the + pattern-shorthand OR even though ``check_type`` falls back to + ``"regex"``. Explicit ``logic`` in the YAML always wins. + """ + if ( + not has_explicit_conditions + and check_type in ("parameter_validation", "regex") + and n_conditions > 1 + ): + default_logic = "any" + else: + default_logic = "all" + logic_str = str(data.get("logic", default_logic)).lower() + try: + return Logic(logic_str) + except ValueError: + return Logic.ALL + + +def _has_explicit_conditions(raw_conditions: Any) -> bool: + """A ``conditions:`` list is explicit when it holds dicts with ``operator:``.""" + return ( + isinstance(raw_conditions, list) + and bool(raw_conditions) + and isinstance(raw_conditions[0], dict) + and "operator" in raw_conditions[0] + ) + + +def _build_check( + data: dict[str, Any], + default_action: Action, + *, + mapped_to_uipath: bool = False, + policy_enabled: bool = True, +) -> Check | None: + """Build one Check from a YAML check entry. + + Delegates per-check-type condition-building to the small helpers + above (dispatched via :data:`_CHECK_BUILDERS`); the ``guardrail_fallback`` + branch is inline because it needs the rule-level + ``mapped_to_uipath`` / ``policy_enabled`` flags threaded in from + :func:`_build_rule`. Unknown check types are skipped. + """ + raw_conditions = data.get("conditions") + has_explicit_conditions = _has_explicit_conditions(raw_conditions) + check_type = data.get("type", "regex") + + if has_explicit_conditions: + assert isinstance(raw_conditions, list) # narrowed by _has_explicit_conditions + conditions = list(_make_conditions(raw_conditions)) + message = str(data.get("message", "")) + elif check_type == "guardrail_fallback": + conditions, message = _build_guardrail_fallback_conditions( + data, + mapped_to_uipath=mapped_to_uipath, + policy_enabled=policy_enabled, + ) + else: + builder = _CHECK_BUILDERS.get(check_type) + if builder is None: + logger.debug("Skipping check: unknown type %r", check_type) + return None + conditions, message = builder(data) + + if not conditions: + return None + + action = _resolve_action(data, default_action) + message = str(data.get("message", message)) + logic = _resolve_logic( + data, + has_explicit_conditions=has_explicit_conditions, + check_type=check_type, + n_conditions=len(conditions), + ) + return Check(conditions=conditions, action=action, message=message, logic=logic) + + +def _make_conditions(raw: list[dict[str, Any]]) -> list[Condition]: + """Translate a list of YAML condition dicts into Condition objects.""" + out: list[Condition] = [] + for cond in raw: + if not isinstance(cond, dict): + continue + out.append( + Condition( + operator=str(cond.get("operator", "regex")), + field=str(cond.get("field", "model_input")), + value=cond.get("value", ""), + negate=bool(cond.get("negate", False)), + ) + ) + return out + + +def _field_for_scope(scope: list[str] | str) -> str: + """Map a YAML `scope` value to the CheckContext field it targets.""" + if isinstance(scope, str): + scope = [scope] + if "system" in scope or "human" in scope: + return "model_input" + if "ai" in scope: + return "model_output" + if "tool_result" in scope: + return "tool_result" + return "model_input" diff --git a/packages/uipath/src/uipath/_cli/_governance_bootstrap.py b/packages/uipath/src/uipath/_cli/_governance_bootstrap.py new file mode 100644 index 000000000..675b42721 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_governance_bootstrap.py @@ -0,0 +1,174 @@ +"""Shared host-side governance bootstrap for ``uipath run`` / ``uipath debug``. + +Framework and agent-type labels are forwarded from +:class:`UiPathRuntimeFactorySettings` — each factory advertises its +own; the CLI never classifies the runtime. +""" + +from __future__ import annotations + +import atexit +import logging +from collections.abc import Callable +from dataclasses import dataclass + +from uipath.core.governance import EnforcementMode, PolicyContext +from uipath.core.governance.config import is_governance_enabled +from uipath.platform import UiPath +from uipath.platform.governance import UiPathPlatformGovernanceProvider +from uipath.platform.governance._live_track_event_dispatcher import ( + LiveTrackEventDispatcher, +) +from uipath.runtime import UiPathRuntimeProtocol +from uipath.runtime.governance._audit.base import AuditManager +from uipath.runtime.governance._audit.metadata import GovernanceRuntimeMetadata +from uipath.runtime.governance.native import GovernanceEvaluator +from uipath.runtime.governance.native.guardrail_compensation import ( + GuardrailCompensator, +) +from uipath.runtime.governance.native.models import PolicyIndex +from uipath.runtime.governance.runtime import UiPathGovernedRuntime + +from ._governance import build_policy_index_from_yaml +from ._utils._console import ConsoleLogger + +console = ConsoleLogger() +logger = logging.getLogger(__name__) + +__all__ = [ + "GovernanceBootstrap", + "resolve_governance", +] + + +@dataclass(frozen=True, slots=True) +class GovernanceBootstrap: + """Governance wiring for one CLI run. + + ``dispose`` is idempotent, never raises, and drains the track-event + dispatcher; call it from a ``finally``. An :mod:`atexit` fallback + covers the case where the caller misses it. + """ + + evaluator: GovernanceEvaluator + policy_index: PolicyIndex + enforcement_mode: EnforcementMode + dispose: Callable[[], None] + + def wrap_runtime( + self, + delegate: UiPathRuntimeProtocol, + *, + agent_name: str, + runtime_id: str, + ) -> UiPathGovernedRuntime: + """Wrap a delegate runtime with governance evaluation.""" + return UiPathGovernedRuntime( + delegate, + policy_index=self.policy_index, + enforcement_mode=self.enforcement_mode, + evaluator=self.evaluator, + agent_name=agent_name, + runtime_id=runtime_id, + ) + + +async def resolve_governance( + *, + agent_framework: str | None, + agent_type: str | None, + is_conversational: bool, +) -> GovernanceBootstrap | None: + """Fetch policy + build the governance stack, or ``None`` when disabled. + + ``agent_framework`` and ``agent_type`` are forwarded from + :class:`UiPathRuntimeFactorySettings` and stamped on every audit + event; ``None`` becomes ``"unknown"``. + + ``is_conversational`` is derived by the caller from runtime context + (``bool(ctx.conversation_id)``): ``True`` for a run inside a CAS + conversation, ``False`` otherwise. The value is forwarded verbatim + to :class:`PolicyContext` so the backend can select the + conversational or autonomous policy view. + """ + if not is_governance_enabled(): + return None + + context = PolicyContext(is_conversational=is_conversational) + + try: + sdk = UiPath() + provider = UiPathPlatformGovernanceProvider(service=sdk.governance) + response = await provider.get_policy_async(context) + except Exception as exc: + console.warning( + f"Governance policy fetch failed - continuing without governance: {exc}" + ) + return None + + if response.mode is None or response.mode == EnforcementMode.DISABLED: + return None + if not response.policies: + return None + + try: + policy_index = build_policy_index_from_yaml(response.policies) + except Exception as exc: + console.warning( + f"Governance policy compilation failed - continuing without governance: {exc}" + ) + return None + + # The dispatcher below owns a background thread + atexit hook, so + # every failure path from here on must run ``dispose``. + track_event_dispatcher: LiveTrackEventDispatcher | None = None + + def dispose() -> None: + # Called from CLI ``finally`` — must never raise. + dispatcher = track_event_dispatcher + if dispatcher is None: + return + try: + atexit.unregister(dispatcher.shutdown) + except Exception: + logger.debug("atexit.unregister failed", exc_info=True) + try: + dispatcher.shutdown() + except Exception: + logger.debug("dispatcher shutdown failed", exc_info=True) + + try: + track_event_dispatcher = LiveTrackEventDispatcher(provider) + atexit.register(track_event_dispatcher.shutdown) + + compensator = GuardrailCompensator(provider) + audit_manager = AuditManager( + track_event=track_event_dispatcher.dispatch, + runtime_metadata=GovernanceRuntimeMetadata( + agent_type=agent_type or "unknown", + agent_framework=agent_framework or "unknown", + ), + ) + evaluator = GovernanceEvaluator( + policy_index, + enforcement_mode=response.mode, + audit_manager=audit_manager, + compensator=compensator, + ) + console.info( + f"Governance enabled (mode={response.mode.value}, " + f"packs={list(policy_index.pack_names)})" + ) + except Exception as exc: + dispose() + console.warning( + f"Governance setup failed - continuing without governance: {exc}" + ) + return None + + return GovernanceBootstrap( + evaluator=evaluator, + policy_index=policy_index, + enforcement_mode=response.mode, + dispose=dispose, + ) diff --git a/packages/uipath/src/uipath/_cli/cli_debug.py b/packages/uipath/src/uipath/_cli/cli_debug.py index 1e2df4770..92fc3ffea 100644 --- a/packages/uipath/src/uipath/_cli/cli_debug.py +++ b/packages/uipath/src/uipath/_cli/cli_debug.py @@ -1,6 +1,6 @@ import asyncio import logging -from typing import cast, get_args +from typing import Any, cast, get_args import click @@ -27,6 +27,7 @@ from uipath.runtime.debug import UiPathDebugProtocol, UiPathDebugRuntime from uipath.tracing import LiveTrackingSpanProcessor, LlmOpsHttpExporter +from ._governance_bootstrap import GovernanceBootstrap, resolve_governance from ._telemetry import track_command from ._utils._console import ConsoleLogger from .middlewares import Middlewares @@ -136,6 +137,8 @@ async def execute_debug_runtime(): ) with ExecutionSourceContext(ctx.execution_source), ctx: factory: UiPathRuntimeFactoryProtocol | None = None + governance_bootstrap: GovernanceBootstrap | None = None + live_tracking_processor: LiveTrackingSpanProcessor | None = None try: trigger_poll_interval: float = 5.0 @@ -147,14 +150,31 @@ async def execute_debug_runtime(): if factory_settings else None ) + agent_type = ( + factory_settings.agent_type if factory_settings else None + ) + agent_framework = ( + factory_settings.agent_framework + if factory_settings + else None + ) + governance_bootstrap = await resolve_governance( + agent_framework=agent_framework, + agent_type=agent_type, + is_conversational=ctx.conversation_id is not None, + ) + governance_runtime_id = ( + ctx.conversation_id or ctx.job_id or "default" + ) if ctx.job_id: if UiPathConfig.is_tracing_enabled: + live_tracking_processor = LiveTrackingSpanProcessor( + LlmOpsHttpExporter(), + settings=trace_settings, + ) trace_manager.add_span_processor( - LiveTrackingSpanProcessor( - LlmOpsHttpExporter(), - settings=trace_settings, - ) + live_tracking_processor ) trigger_poll_interval = ( 0.0 # Polling disabled for production jobs @@ -165,11 +185,24 @@ async def execute_debug_runtime(): debug_bridge: UiPathDebugProtocol = get_debug_bridge( ctx, attach=attach_mode ) + new_runtime_kwargs: dict[str, Any] = {} + if governance_bootstrap is not None: + new_runtime_kwargs["evaluator"] = ( + governance_bootstrap.evaluator + ) runtime = await factory.new_runtime( entrypoint, - ctx.conversation_id or ctx.job_id or "default", + governance_runtime_id, + **new_runtime_kwargs, ) + if governance_bootstrap is not None: + runtime = governance_bootstrap.wrap_runtime( + runtime, + agent_name=entrypoint, + runtime_id=governance_runtime_id, + ) + delegate = runtime if ctx.conversation_id and ctx.exchange_id: chat_bridge: UiPathChatProtocol = get_chat_bridge( @@ -227,6 +260,13 @@ async def execute_debug_runtime(): await execute_debug_runtime() finally: + # Drain runtime-scoped sinks before the factory + # shuts down — the factory may own transports they + # use. (The inner runtime already disposed above.) + if live_tracking_processor is not None: + live_tracking_processor.shutdown() + if governance_bootstrap is not None: + governance_bootstrap.dispose() if factory: await factory.dispose() diff --git a/packages/uipath/src/uipath/_cli/cli_run.py b/packages/uipath/src/uipath/_cli/cli_run.py index 9a8571d03..32b245b6f 100644 --- a/packages/uipath/src/uipath/_cli/cli_run.py +++ b/packages/uipath/src/uipath/_cli/cli_run.py @@ -1,4 +1,5 @@ import asyncio +from typing import Any import click from pydantic import ValidationError @@ -34,6 +35,7 @@ ) from ._errors import EntrypointDiscoveryException +from ._governance_bootstrap import GovernanceBootstrap, resolve_governance from ._telemetry import track_command from ._utils._console import ConsoleLogger from .middlewares import Middlewares @@ -218,6 +220,8 @@ async def execute() -> None: runtime: UiPathRuntimeProtocol | None = None chat_runtime: UiPathRuntimeProtocol | None = None factory: UiPathRuntimeFactoryProtocol | None = None + governance_bootstrap: GovernanceBootstrap | None = None + live_tracking_processor: LiveTrackingSpanProcessor | None = None try: factory = UiPathRuntimeFactoryRegistry.get(context=ctx) @@ -235,10 +239,43 @@ async def execute() -> None: if factory_settings else None ) + agent_type = ( + factory_settings.agent_type + if factory_settings + else None + ) + agent_framework = ( + factory_settings.agent_framework + if factory_settings + else None + ) + governance_bootstrap = await resolve_governance( + agent_framework=agent_framework, + agent_type=agent_type, + is_conversational=ctx.conversation_id is not None, + ) + governance_runtime_id = ( + ctx.conversation_id or ctx.job_id or "default" + ) + new_runtime_kwargs: dict[str, Any] = {} + if governance_bootstrap is not None: + new_runtime_kwargs["evaluator"] = ( + governance_bootstrap.evaluator + ) + base_runtime = await factory.new_runtime( resolved_entrypoint, - ctx.conversation_id or ctx.job_id or "default", + governance_runtime_id, + **new_runtime_kwargs, ) + + if governance_bootstrap is not None: + base_runtime = governance_bootstrap.wrap_runtime( + base_runtime, + agent_name=resolved_entrypoint, + runtime_id=governance_runtime_id, + ) + runtime = base_runtime if simulation_config: @@ -259,11 +296,12 @@ async def execute() -> None: if ctx.job_id: if UiPathConfig.is_tracing_enabled: + live_tracking_processor = LiveTrackingSpanProcessor( + LlmOpsHttpExporter(), + settings=trace_settings, + ) trace_manager.add_span_processor( - LiveTrackingSpanProcessor( - LlmOpsHttpExporter(), - settings=trace_settings, - ) + live_tracking_processor ) if ctx.conversation_id and ctx.exchange_id: @@ -286,6 +324,10 @@ async def execute() -> None: await runtime.dispose() if base_runtime is not None: await base_runtime.dispose() + if live_tracking_processor is not None: + live_tracking_processor.shutdown() + if governance_bootstrap is not None: + governance_bootstrap.dispose() if factory: await factory.dispose() diff --git a/packages/uipath/src/uipath/eval/runtime/events.py b/packages/uipath/src/uipath/eval/runtime/events.py index 589f82ba7..2a46469e4 100644 --- a/packages/uipath/src/uipath/eval/runtime/events.py +++ b/packages/uipath/src/uipath/eval/runtime/events.py @@ -29,6 +29,11 @@ class EvalSetRunCreatedEvent(BaseModel): eval_set_id: str eval_set_run_id: str | None = None no_of_evals: int + # Coarse agent-type label the runtime factory declared via + # ``UiPathRuntimeFactorySettings.agent_type``. Consumers stamp this + # onto telemetry verbatim -- the CLI does not classify entrypoints. + # ``None`` when the factory has no opinion. + agent_type: str | None = None # skip validation to avoid abstract class instantiation evaluators: SkipValidation[list[GenericBaseEvaluator[Any, Any, Any]]] diff --git a/packages/uipath/src/uipath/eval/runtime/runtime.py b/packages/uipath/src/uipath/eval/runtime/runtime.py index 0c4653023..124eedf47 100644 --- a/packages/uipath/src/uipath/eval/runtime/runtime.py +++ b/packages/uipath/src/uipath/eval/runtime/runtime.py @@ -286,6 +286,9 @@ async def initiate_evaluation( f"Please run with a single evaluation using --eval-ids to specify one evaluation." ) + factory_settings = await self.factory.get_settings() + agent_type = factory_settings.agent_type if factory_settings else None + await self.event_bus.publish( EvaluationEvents.CREATE_EVAL_SET_RUN, EvalSetRunCreatedEvent( @@ -294,6 +297,7 @@ async def initiate_evaluation( eval_set_run_id=self.context.eval_set_run_id, eval_set_id=self.context.evaluation_set.id, no_of_evals=len(self.context.evaluation_set.evaluations), + agent_type=agent_type, evaluators=self.context.evaluators, ), ) diff --git a/packages/uipath/src/uipath/functions/factory.py b/packages/uipath/src/uipath/functions/factory.py index 4700ab726..3ef7f5b37 100644 --- a/packages/uipath/src/uipath/functions/factory.py +++ b/packages/uipath/src/uipath/functions/factory.py @@ -17,6 +17,14 @@ logger = logging.getLogger(__name__) +# Wire labels this factory advertises via +# :class:`UiPathRuntimeFactorySettings`. The runtime does not enumerate +# valid values -- each factory owns its own vocabulary and hosts +# forward them verbatim to telemetry / audit consumers. +_AGENT_TYPE_CODED = "uipath_coded" +# Functions runtime is plain Python — no third-party agent framework. +_AGENT_FRAMEWORK = "python" + class UiPathFunctionsRuntimeFactory: """Factory for discovering and creating function-based runtimes.""" @@ -59,8 +67,16 @@ async def get_storage(self) -> UiPathRuntimeStorageProtocol | None: return None async def get_settings(self) -> UiPathRuntimeFactorySettings | None: - """Get factory settings for coded functions.""" - return None + """Get factory settings for coded functions. + + Advertises this factory's ``agent_type`` and ``agent_framework`` + wire labels so hosts (governance audit, App Insights telemetry) + can stamp them onto events without any host-side classification. + """ + return UiPathRuntimeFactorySettings( + agent_type=_AGENT_TYPE_CODED, + agent_framework=_AGENT_FRAMEWORK, + ) async def new_runtime( self, entrypoint: str, runtime_id: str, **kwargs diff --git a/packages/uipath/tests/cli/_governance/__init__.py b/packages/uipath/tests/cli/_governance/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/uipath/tests/cli/_governance/test_yaml_index.py b/packages/uipath/tests/cli/_governance/test_yaml_index.py new file mode 100644 index 000000000..6785a15a8 --- /dev/null +++ b/packages/uipath/tests/cli/_governance/test_yaml_index.py @@ -0,0 +1,775 @@ +"""Tests for ``build_policy_index_from_yaml``.""" + +from __future__ import annotations + +import pytest + +from uipath._cli._governance.yaml_index import ( + build_policy_index_from_yaml, +) +from uipath.core.governance.models import Action, LifecycleHook +from uipath.runtime.governance.native.models import Severity + + +def _single_rule(yaml_text: str): + """Compile YAML and return the single rule; fail if not exactly one.""" + idx = build_policy_index_from_yaml(yaml_text) + rules = idx.all_rules + assert len(rules) == 1, f"expected 1 rule, got {len(rules)}" + return rules[0] + + +def test_empty_yaml_returns_empty_index() -> None: + idx = build_policy_index_from_yaml("") + assert idx.total_rules == 0 + assert idx.pack_names == [] + + +def test_pack_without_rules_is_omitted() -> None: + """Packs with no parseable rules are dropped — never registered.""" + idx = build_policy_index_from_yaml( + """ + standard: empty-pack + version: "1.0" + rules: [] + """ + ) + assert idx.total_rules == 0 + assert "empty-pack" not in idx.pack_names + + +def test_pack_missing_name_is_skipped() -> None: + idx = build_policy_index_from_yaml( + """ + version: "1.0" + rules: + - id: r1 + hook: before_model + checks: + - type: regex + patterns: ["foo"] + """ + ) + assert idx.total_rules == 0 + + +def test_pack_uses_standard_or_name_field() -> None: + """Either ``standard:`` or ``name:`` works as the pack identifier.""" + a = build_policy_index_from_yaml( + """ + standard: iso42001 + rules: + - id: r + hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + b = build_policy_index_from_yaml( + """ + name: iso42001 + rules: + - id: r + hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert "iso42001" in a.pack_names + assert "iso42001" in b.pack_names + + +def test_multi_document_yaml_concatenates_packs() -> None: + # YAML doc separators must be at column 0; dedent inline. + yaml_text = ( + "standard: pack-a\n" + "rules:\n" + " - id: a-r1\n" + " hook: before_model\n" + ' checks: [{type: regex, patterns: ["a"]}]\n' + "---\n" + "standard: pack-b\n" + "rules:\n" + " - id: b-r1\n" + " hook: after_model\n" + ' checks: [{type: regex, patterns: ["b"]}]\n' + ) + idx = build_policy_index_from_yaml(yaml_text) + assert set(idx.pack_names) == {"pack-a", "pack-b"} + assert idx.total_rules == 2 + + +def test_non_dict_top_level_documents_are_ignored() -> None: + """A YAML doc that's a string / list at top level is skipped silently.""" + yaml_text = ( + "just_a_string\n" + "---\n" + "standard: real-pack\n" + "rules:\n" + " - id: r\n" + " hook: before_model\n" + ' checks: [{type: regex, patterns: ["x"]}]\n' + ) + idx = build_policy_index_from_yaml(yaml_text) + assert idx.pack_names == ["real-pack"] + + +def test_unknown_hook_skips_rule() -> None: + """A rule referencing an unknown hook is dropped, the rest survive.""" + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - id: bad + hook: invented_hook + checks: [{type: regex, patterns: ["x"]}] + - id: good + hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + rule_ids = [r.rule_id for r in idx.all_rules] + assert "bad" not in rule_ids + assert "good" in rule_ids + + +def test_non_dict_rule_entry_ignored() -> None: + """Rules entries that aren't dicts (lists, scalars) are skipped.""" + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - "this is a string, not a rule" + - id: good + hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert [r.rule_id for r in idx.all_rules] == ["good"] + + +def test_action_resolution_inherits_pack_default() -> None: + """When the rule omits action, the pack's default_action is used.""" + rule = _single_rule( + """ + standard: p + default_action: log + rules: + - id: r + hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.action == Action.AUDIT # log -> AUDIT per _ACTION_MAP + + +def test_action_resolution_unknown_falls_back_to_default() -> None: + """Unknown action string falls back to the pack default.""" + rule = _single_rule( + """ + standard: p + default_action: deny + rules: + - id: r + hook: before_model + action: bogus + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.action == Action.DENY + + +def test_severity_resolution_explicit() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + severity: critical + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.severity == Severity.CRITICAL + + +def test_severity_default_high_for_deny_action() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + action: deny + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.severity == Severity.HIGH + + +def test_severity_default_medium_for_non_deny_action() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + action: log + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.severity == Severity.MEDIUM + + +def test_unknown_severity_falls_back_to_high() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + severity: ridiculous + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.severity == Severity.HIGH + + +def test_disabled_flag_propagates() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + enabled: false + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.enabled is False + + +def test_rule_without_id_gets_index_based_id() -> None: + """When ``id:`` is missing, a positional fallback ``RULE-N`` is used.""" + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert idx.all_rules[0].rule_id == "RULE-0" + + +def test_rule_with_zero_parsed_checks_is_skipped() -> None: + """A rule whose declared checks all fail to parse is dropped. + + Without this guard, a rule with no checks ``always matches`` in the + evaluator and would fire on every request. + """ + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - id: junk + hook: before_model + checks: + - type: totally_unknown_check_type + """ + ) + assert idx.total_rules == 0 + + +@pytest.mark.parametrize( + "hook_name,expected", + [ + ("before_agent", LifecycleHook.BEFORE_AGENT), + ("after_agent", LifecycleHook.AFTER_AGENT), + ("before_model", LifecycleHook.BEFORE_MODEL), + ("after_model", LifecycleHook.AFTER_MODEL), + ("tool_call", LifecycleHook.TOOL_CALL), + ("wrap_tool_call", LifecycleHook.TOOL_CALL), # alias + ("after_tool", LifecycleHook.AFTER_TOOL), + ], +) +def test_hook_resolution(hook_name: str, expected: LifecycleHook) -> None: + rule = _single_rule( + f""" + standard: p + rules: + - id: r + hook: {hook_name} + checks: [{{type: regex, patterns: ["x"]}}] + """ + ) + assert rule.hook == expected + + +def test_regex_check_multi_pattern_defaults_to_any_logic() -> None: + """Multiple regex patterns default to OR (any) — common case for ASI rules.""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: regex + patterns: ["pwn", "ignore_previous"] + """ + ) + assert rule.checks[0].logic == "any" + assert len(rule.checks[0].conditions) == 2 + + +def test_regex_check_single_pattern_defaults_to_all_logic() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: regex + patterns: ["pwn"] + """ + ) + assert rule.checks[0].logic == "all" + + +def test_regex_check_explicit_logic_wins() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: regex + patterns: ["a", "b"] + logic: all + """ + ) + assert rule.checks[0].logic == "all" + + +@pytest.mark.parametrize( + "scope,expected_field", + [ + (["human"], "model_input"), + (["system"], "model_input"), + (["ai"], "model_output"), + ("ai", "model_output"), # string form + (["tool_result"], "tool_result"), + (["unknown_thing"], "model_input"), # fallback + ], +) +def test_regex_scope_maps_to_field(scope, expected_field: str) -> None: + rule = _single_rule( + f""" + standard: p + rules: + - id: r + hook: before_model + checks: + - type: regex + patterns: ["x"] + scope: {scope!r} + """ + ) + assert rule.checks[0].conditions[0].field == expected_field + + +def test_budget_check_max_per_session() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: tool_call + checks: + - type: budget + max_tool_calls_per_session: 5 + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "gt" + assert cond.field == "session_state.tool_calls" + assert cond.value == 5 + + +def test_budget_check_multiple_thresholds() -> None: + """All three budget knobs become independent conditions.""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: tool_call + checks: + - type: budget + max_tool_calls_per_session: 10 + max_tool_calls_per_minute: 5 + max_consecutive_tool_calls: 3 + """ + ) + assert len(rule.checks[0].conditions) == 3 + + +def test_tool_allowlist_check() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: tool_call + checks: + - type: tool_allowlist + blocked_tools: ["delete_file", "shell"] + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "in_list" + assert cond.field == "tool_name" + assert cond.value == ["delete_file", "shell"] + + +def test_tool_allowlist_empty_blocked_list_skipped() -> None: + """Empty ``blocked_tools`` means there's nothing to enforce — drop the rule.""" + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - id: r + hook: tool_call + checks: + - type: tool_allowlist + blocked_tools: [] + """ + ) + assert idx.total_rules == 0 + + +def test_parameter_validation_check() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: tool_call + checks: + - type: parameter_validation + additional_patterns: ["rm -rf", "/etc/passwd"] + """ + ) + check = rule.checks[0] + assert len(check.conditions) == 2 + assert all(c.field == "tool_args" for c in check.conditions) + # Multi-pattern parameter_validation defaults to OR logic + assert check.logic == "any" + + +def test_rate_limit_check_session_and_minute() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: rate_limit + max_llm_calls_per_session: 20 + max_llm_calls_per_minute: 5 + """ + ) + fields = {c.field for c in rule.checks[0].conditions} + assert fields == { + "session_state.llm_calls", + "session_state.llm_calls_per_minute", + } + + +def test_field_regex_check_threads_through_conditions() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_model + checks: + - type: field_regex + conditions: + - operator: regex + field: model_output + value: "(?i)password" + message: "leaked password" + """ + ) + check = rule.checks[0] + assert check.message == "leaked password" + assert check.conditions[0].operator == "regex" + + +def test_data_quality_score_both_encoding_and_entropy() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_tool + checks: + - type: data_quality_score + field: tool_result + min_confidence: 0.8 + entropy_min: 2.0 + entropy_max: 6.0 + """ + ) + ops = {c.operator for c in rule.checks[0].conditions} + assert ops == {"encoding_concern", "entropy_concern"} + + +def test_data_quality_score_check_encoding_disabled() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_tool + checks: + - type: data_quality_score + check_encoding: false + check_entropy: true + """ + ) + ops = [c.operator for c in rule.checks[0].conditions] + assert "encoding_concern" not in ops + assert "entropy_concern" in ops + + +def test_incident_taxonomy_with_categories() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_model + checks: + - type: incident_taxonomy + field: model_output + categories: [safety_refusal, tool_failure] + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "incident_concern" + assert cond.value == {"categories": ["safety_refusal", "tool_failure"]} + + +def test_incident_taxonomy_without_categories_uses_empty_dict() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_model + checks: + - type: incident_taxonomy + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.value == {} + + +def test_commitment_extractor_default_flags() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_model + checks: + - type: commitment_extractor + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "commitment_concern" + assert cond.value == {"require_amount": True, "require_deadline": False} + + +def test_commitment_extractor_custom_flags() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_model + checks: + - type: commitment_extractor + require_amount: false + require_deadline: true + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.value == {"require_amount": False, "require_deadline": True} + + +def test_sentiment_concern_check() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: sentiment_concern + threshold: -0.5 + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "vader_concern" + assert cond.value == {"threshold": -0.5} + + +def test_guardrail_fallback_inherits_rule_flags() -> None: + """Rule-level ``mapped_to_uipath`` / ``policy_enabled`` thread into the condition.""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + mapped_to_uipath: true + policy_enabled: false + checks: + - type: guardrail_fallback + validator: pii_detection + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "guardrail_fallback" + assert cond.value == { + "validator": "pii_detection", + "mapped_to_uipath": True, + "policy_enabled": False, + } + + +def test_guardrail_fallback_default_flags_are_unmapped_and_enabled() -> None: + """When the rule omits the flags, the fallback never fires (disabled-only contract).""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: guardrail_fallback + validator: pii_detection + """ + ) + cond = rule.checks[0].conditions[0] + # ``guardrail_fallback`` operator fires only when mapped=True AND + # enabled=False; defaults of False / True ensure it stays silent. + assert cond.value["mapped_to_uipath"] is False + assert cond.value["policy_enabled"] is True + + +def test_explicit_conditions_win_over_check_type() -> None: + """Explicit ``conditions:`` short-circuits the per-type templating.""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: regex # ignored, conditions wins + conditions: + - operator: contains + field: model_input + value: "secret" + message: "no secrets" + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "contains" # not "regex" + assert cond.value == "secret" + assert rule.checks[0].message == "no secrets" + + +def test_explicit_conditions_negate_flag_propagates() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - conditions: + - operator: contains + field: model_input + value: "allowed" + negate: true + """ + ) + assert rule.checks[0].conditions[0].negate is True + + +def test_non_dict_condition_in_explicit_list_is_skipped() -> None: + """A condition entry that isn't a dict is silently dropped. + + The first dict-with-``operator`` entry is what trips the + "explicit conditions" branch in ``_build_check``; out-of-order + scalar entries appear after the leading dict. + """ + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - conditions: + - operator: contains + field: model_input + value: "x" + - "not a dict" + """ + ) + assert len(rule.checks[0].conditions) == 1 + + +def test_unknown_check_type_skipped() -> None: + """Unknown check types are dropped without taking down sibling checks.""" + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: future_check_type + - type: regex + patterns: ["x"] + """ + ) + rule = idx.all_rules[0] + # Only the regex check survived. + assert len(rule.checks) == 1 + assert rule.checks[0].conditions[0].operator == "regex" + + +def test_non_dict_check_entry_skipped() -> None: + """Checks list entries that aren't dicts are silently ignored.""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - "scalar instead of mapping" + - type: regex + patterns: ["x"] + """ + ) + assert len(rule.checks) == 1 diff --git a/packages/uipath/tests/cli/eval/test_eval_telemetry.py b/packages/uipath/tests/cli/eval/test_eval_telemetry.py index 468180bf7..c0e57490c 100644 --- a/packages/uipath/tests/cli/eval/test_eval_telemetry.py +++ b/packages/uipath/tests/cli/eval/test_eval_telemetry.py @@ -96,6 +96,7 @@ def _create_eval_set_run_created_event( entrypoint: str = "agent.py", no_of_evals: int = 5, evaluators: list[Any] | None = None, + agent_type: str | None = None, ) -> EvalSetRunCreatedEvent: """Helper to create EvalSetRunCreatedEvent.""" return EvalSetRunCreatedEvent( @@ -104,9 +105,60 @@ def _create_eval_set_run_created_event( eval_set_run_id=eval_set_run_id, entrypoint=entrypoint, no_of_evals=no_of_evals, + agent_type=agent_type, evaluators=evaluators or [], ) + @pytest.mark.asyncio + @patch("uipath._cli._evals._telemetry.track_event") + async def test_agent_type_normalized_to_wire_lowcode(self, mock_track_event): + """Modern factory labels containing ``lowcode`` map to ``"LowCode"`` + so Application Insights dashboards that filter on the historical + wire value keep working after the factory-supplied refactor. + """ + subscriber = EvalTelemetrySubscriber() + event = self._create_eval_set_run_created_event(agent_type="uipath_lowcode") + + await subscriber._on_eval_set_run_created(event) + + properties = mock_track_event.call_args[0][1] + assert properties["AgentType"] == "LowCode" + + @pytest.mark.asyncio + @patch("uipath._cli._evals._telemetry.track_event") + async def test_agent_type_normalized_to_wire_coded(self, mock_track_event): + """Modern factory labels that aren't low-code map to ``"Coded"``.""" + subscriber = EvalTelemetrySubscriber() + event = self._create_eval_set_run_created_event(agent_type="uipath_coded") + + await subscriber._on_eval_set_run_created(event) + + properties = mock_track_event.call_args[0][1] + assert properties["AgentType"] == "Coded" + + @pytest.mark.asyncio + @patch("uipath._cli._evals._telemetry.track_event") + async def test_agent_type_falls_back_to_entrypoint_when_missing( + self, mock_track_event + ): + """Pre-refactor callers didn't set ``agent_type``; keep the + ``agent.json`` ⇒ ``"LowCode"`` derivation so no in-flight consumer + breaks. Anything else falls back to ``"Coded"``. + """ + subscriber = EvalTelemetrySubscriber() + + low_code_event = self._create_eval_set_run_created_event( + agent_type=None, entrypoint="agent.json" + ) + await subscriber._on_eval_set_run_created(low_code_event) + assert mock_track_event.call_args[0][1]["AgentType"] == "LowCode" + + coded_event = self._create_eval_set_run_created_event( + agent_type=None, entrypoint="agent.py" + ) + await subscriber._on_eval_set_run_created(coded_event) + assert mock_track_event.call_args[0][1]["AgentType"] == "Coded" + @pytest.mark.asyncio @patch("uipath._cli._evals._telemetry.track_event") async def test_on_eval_set_run_created_tracks_event(self, mock_track_event): diff --git a/packages/uipath/tests/cli/test_governance_bootstrap.py b/packages/uipath/tests/cli/test_governance_bootstrap.py new file mode 100644 index 000000000..fabe3650c --- /dev/null +++ b/packages/uipath/tests/cli/test_governance_bootstrap.py @@ -0,0 +1,858 @@ +"""Tests replace runtime-governance types via ``monkeypatch.setattr`` on +the bootstrap module's namespace (not via ``sys.modules``) — the +bootstrap imports them at top level. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from uipath._cli._governance_bootstrap import ( + GovernanceBootstrap, + resolve_governance, +) +from uipath.core.governance import EnforcementMode, PolicyContext +from uipath.runtime.governance.native.models import PolicyIndex +from uipath.runtime.governance.runtime import UiPathGovernedRuntime + + +@pytest.fixture +def cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Change into an isolated cwd for each test. + + Kept even though ``resolve_governance`` no longer touches project + files — several tests still want an isolated working directory so + incidental file access (SDK config discovery, logging) doesn't leak + across cases. + """ + monkeypatch.chdir(tmp_path) + return tmp_path + + +def _install_fake_runtime_governance( + monkeypatch: pytest.MonkeyPatch, + *, + audit_manager_cls: type, + metadata_cls: type, + evaluator_cls: type, + compensator_cls: type, +) -> None: + """Replace the runtime-governance names bound in the bootstrap module.""" + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.AuditManager", + audit_manager_cls, + ) + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.GovernanceRuntimeMetadata", + metadata_cls, + ) + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.GovernanceEvaluator", + evaluator_cls, + ) + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.GuardrailCompensator", + compensator_cls, + ) + + +class _FakeAuditManager: + """Records the constructor kwargs so tests can inspect them.""" + + def __init__(self, *, track_event: Any, runtime_metadata: Any) -> None: + self.track_event = track_event + self.runtime_metadata = runtime_metadata + + +class _FakeMetadata: + def __init__(self, *, agent_type: str | None, agent_framework: str) -> None: + self.agent_type = agent_type + self.agent_framework = agent_framework + + +class _FakeCompensator: + def __init__(self, provider: Any) -> None: + self.provider = provider + + +class _FakeEvaluator: + def __init__( + self, + policy_index: Any, + *, + enforcement_mode: Any, + audit_manager: Any, + compensator: Any, + ) -> None: + self.policy_index = policy_index + self.enforcement_mode = enforcement_mode + self.audit_manager = audit_manager + self.compensator = compensator + + +def _fake_policy_response(*, mode: EnforcementMode | None, policies: str) -> MagicMock: + resp = MagicMock() + resp.mode = mode + resp.policies = policies + return resp + + +def _stub_provider( + monkeypatch: pytest.MonkeyPatch, *, response_or_exc: Any +) -> MagicMock: + """Replace the ``UiPath()`` + provider construction with a stub whose + ``get_policy_async`` returns ``response_or_exc`` (or raises if it's + an exception instance). + """ + provider = MagicMock() + if isinstance(response_or_exc, BaseException): + provider.get_policy_async = AsyncMock(side_effect=response_or_exc) + else: + provider.get_policy_async = AsyncMock(return_value=response_or_exc) + provider.track_event_async = AsyncMock() + + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.UiPath", + lambda: MagicMock(governance=MagicMock()), + ) + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.UiPathPlatformGovernanceProvider", + lambda service: provider, + ) + return provider + + +class TestResolveGovernance: + async def test_returns_none_when_feature_flag_disabled( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: False, + ) + assert ( + await resolve_governance( + agent_framework="langgraph", + agent_type="uipath_coded", + is_conversational=False, + ) + is None + ) + + @pytest.mark.parametrize("is_conversational", [True, False]) + async def test_is_conversational_forwarded_verbatim_to_policy_context( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + is_conversational: bool, + ) -> None: + """The caller derives ``is_conversational`` from + ``bool(ctx.conversation_id)`` and passes it through. The + bootstrap must forward the exact value to :class:`PolicyContext` + so the backend can select the conversational or autonomous + policy view.""" + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + provider = _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.ENFORCE, policies="rules: []" + ), + ) + + result = await resolve_governance( + agent_framework="langgraph", + agent_type="uipath_coded", + is_conversational=is_conversational, + ) + assert result is not None + try: + provider.get_policy_async.assert_awaited_once() + context_arg = provider.get_policy_async.await_args.args[0] + assert isinstance(context_arg, PolicyContext) + assert context_arg.is_conversational is is_conversational + finally: + result.dispose() + + async def test_returns_none_when_policy_fetch_fails( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + ) -> None: + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider(monkeypatch, response_or_exc=RuntimeError("backend unreachable")) + assert ( + await resolve_governance( + agent_framework="langgraph", + agent_type="uipath_coded", + is_conversational=False, + ) + is None + ) + + async def test_returns_none_when_mode_is_disabled( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + ) -> None: + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.DISABLED, policies="rules:" + ), + ) + assert ( + await resolve_governance( + agent_framework="langgraph", + agent_type="uipath_coded", + is_conversational=False, + ) + is None + ) + + async def test_returns_none_when_mode_is_none( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + ) -> None: + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response(mode=None, policies="rules:"), + ) + assert ( + await resolve_governance( + agent_framework="langgraph", + agent_type="uipath_coded", + is_conversational=False, + ) + is None + ) + + async def test_returns_none_when_policies_empty( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + ) -> None: + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.ENFORCE, policies="" + ), + ) + assert ( + await resolve_governance( + agent_framework="langgraph", + agent_type="uipath_coded", + is_conversational=False, + ) + is None + ) + + async def test_returns_none_when_policy_compilation_fails( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + ) -> None: + """Malformed YAML must be caught by the compile-time try/except so + governance skips cleanly rather than propagating a ``YAMLError`` + out of ``resolve_governance``. + """ + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + # Unclosed flow mapping — ``yaml.safe_load_all`` raises + # ``ScannerError`` (a subclass of ``YAMLError``), which + # ``resolve_governance``'s try/except converts to + # ``None``. + mode=EnforcementMode.ENFORCE, + policies="foo: {unclosed", + ), + ) + assert ( + await resolve_governance( + agent_framework="langgraph", + agent_type="uipath_coded", + is_conversational=False, + ) + is None + ) + + async def test_success_returns_bootstrap_with_dispose_contract( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + ) -> None: + """Happy path -- named fields populated and dispose unregisters + atexit + shuts the dispatcher down. + """ + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.ENFORCE, policies="rules: [a, b]" + ), + ) + + # Capture what the code hands to atexit so we can verify + # register/unregister without relying on atexit internals. + registered: list[Any] = [] + + def _fake_register(func: Any, *_a: Any, **_kw: Any) -> Any: + registered.append(func) + return func + + def _fake_unregister(func: Any) -> None: + if func in registered: + registered.remove(func) + + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.atexit.register", _fake_register + ) + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.atexit.unregister", _fake_unregister + ) + + result = await resolve_governance( + agent_framework="langgraph", + agent_type="uipath_coded", + is_conversational=False, + ) + assert result is not None + + assert isinstance(result.evaluator, _FakeEvaluator) + assert isinstance(result.policy_index, PolicyIndex) + assert result.policy_index.total_rules == 0 + assert result.enforcement_mode == EnforcementMode.ENFORCE + assert isinstance(result.evaluator.audit_manager, _FakeAuditManager) + assert isinstance(result.evaluator.compensator, _FakeCompensator) + assert callable(result.evaluator.audit_manager.track_event) + + assert ( + result.evaluator.audit_manager.runtime_metadata.agent_type == "uipath_coded" + ) + assert ( + result.evaluator.audit_manager.runtime_metadata.agent_framework + == "langgraph" + ) + + assert len(registered) == 1 + result.dispose() + assert not registered, "atexit hook was not unregistered on dispose" + result.dispose() # idempotent + + async def test_agent_type_forwarded_verbatim_to_metadata( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + ) -> None: + """The ``agent_type`` argument is forwarded verbatim to + :class:`GovernanceRuntimeMetadata` -- the CLI does not classify + the project; the factory does via + :attr:`UiPathRuntimeFactorySettings.agent_type`. + """ + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.ENFORCE, policies="rules: [a, b]" + ), + ) + + result = await resolve_governance( + agent_framework="lowcode", + agent_type="uipath_lowcode", + is_conversational=False, + ) + assert result is not None + try: + assert isinstance(result.evaluator, _FakeEvaluator) + assert ( + result.evaluator.audit_manager.runtime_metadata.agent_type + == "uipath_lowcode" + ) + assert ( + result.evaluator.audit_manager.runtime_metadata.agent_framework + == "lowcode" + ) + finally: + result.dispose() + + async def test_agent_framework_none_passes_through_as_unknown( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + ) -> None: + """A factory with no ``agent_framework`` opinion emits + ``"unknown"`` -- symmetric with the ``agent_type`` fallback.""" + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.ENFORCE, policies="rules: []" + ), + ) + + result = await resolve_governance( + agent_framework=None, + agent_type="uipath_coded", + is_conversational=False, + ) + assert result is not None + try: + assert isinstance(result.evaluator, _FakeEvaluator) + assert ( + result.evaluator.audit_manager.runtime_metadata.agent_framework + == "unknown" + ) + finally: + result.dispose() + + async def test_agent_type_none_passes_through_to_metadata( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + ) -> None: + """A factory with no ``agent_type`` opinion yields ``None`` on + the metadata -- the backend decides how to interpret the gap.""" + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.ENFORCE, policies="rules: []" + ), + ) + + result = await resolve_governance( + agent_framework="langgraph", + agent_type=None, + is_conversational=False, + ) + assert result is not None + try: + assert isinstance(result.evaluator, _FakeEvaluator) + # Metadata field is strict ``str`` with default ``"unknown"`` + # -- the bootstrap forwards that when the factory has no + # opinion, so the CLI never has to invent a value. + assert ( + result.evaluator.audit_manager.runtime_metadata.agent_type == "unknown" + ) + finally: + result.dispose() + + async def test_wrap_runtime_produces_governed_runtime_with_bootstrap_fields( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + ) -> None: + """``GovernanceBootstrap.wrap_runtime`` must construct a + :class:`UiPathGovernedRuntime` populated from the bootstrap's + own ``evaluator`` / ``policy_index`` / ``enforcement_mode`` plus + the caller's ``agent_name`` / ``runtime_id``. This is the code + path CLI callers replaced their manual construction with. + """ + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.ENFORCE, policies="rules: []" + ), + ) + + result = await resolve_governance( + agent_framework="langgraph", + agent_type="uipath_coded", + is_conversational=False, + ) + assert isinstance(result, GovernanceBootstrap) + try: + delegate = MagicMock() # stand-in for the real runtime + wrapped = result.wrap_runtime( + delegate, + agent_name="my-agent", + runtime_id="run-123", + ) + assert isinstance(wrapped, UiPathGovernedRuntime) + assert wrapped._delegate is delegate + assert wrapped._policy_index is result.policy_index + assert wrapped._enforcement_mode is result.enforcement_mode + assert wrapped._evaluator is result.evaluator + assert wrapped._agent_name == "my-agent" + assert wrapped._runtime_id == "run-123" + finally: + result.dispose() + + async def test_returns_none_when_dispatcher_init_fails( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + ) -> None: + """A dispatcher constructor blow-up must be swallowed — governance + is optional and a failing bootstrap must not crash the CLI. No + ``atexit`` hook should leak. + """ + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.ENFORCE, policies="rules: []" + ), + ) + + def _boom(_provider: Any) -> Any: + raise RuntimeError("dispatcher init exploded") + + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.LiveTrackEventDispatcher", + _boom, + ) + + registered: list[Any] = [] + + def _fake_register(func: Any, *_a: Any, **_kw: Any) -> Any: + registered.append(func) + return func + + def _fake_unregister(func: Any) -> None: + if func in registered: + registered.remove(func) + + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.atexit.register", _fake_register + ) + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.atexit.unregister", _fake_unregister + ) + + result = await resolve_governance( + agent_framework="langgraph", + agent_type="uipath_coded", + is_conversational=False, + ) + assert result is None + assert not registered, "atexit hook leaked when dispatcher init failed" + + async def test_returns_none_and_cleans_up_when_evaluator_setup_fails( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + ) -> None: + """If a component built AFTER the dispatcher (e.g., the evaluator) + raises, ``resolve_governance`` must unregister the ``atexit`` hook + AND shut the dispatcher down before returning ``None``. + """ + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + + class _ExplodingEvaluator: + def __init__(self, *_a: Any, **_kw: Any) -> None: + raise RuntimeError("evaluator init exploded") + + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_ExplodingEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.ENFORCE, policies="rules: []" + ), + ) + + shutdown_calls: list[int] = [] + + class _FakeDispatcher: + def __init__(self, _provider: Any) -> None: + self.dispatch = MagicMock() + + def shutdown(self) -> None: + shutdown_calls.append(1) + + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.LiveTrackEventDispatcher", + _FakeDispatcher, + ) + + registered: list[Any] = [] + + def _fake_register(func: Any, *_a: Any, **_kw: Any) -> Any: + registered.append(func) + return func + + def _fake_unregister(func: Any) -> None: + if func in registered: + registered.remove(func) + + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.atexit.register", _fake_register + ) + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.atexit.unregister", _fake_unregister + ) + + result = await resolve_governance( + agent_framework="langgraph", + agent_type="uipath_coded", + is_conversational=False, + ) + assert result is None + assert not registered, "atexit hook not unregistered after evaluator failure" + assert shutdown_calls == [1], "dispatcher not shut down after evaluator failure" + + async def test_dispose_swallows_dispatcher_shutdown_errors( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + ) -> None: + """``dispose`` runs from CLI ``finally`` blocks — it must never + raise, or it will mask the primary exception. A shutdown that + blows up should be logged at debug and swallowed. + """ + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.ENFORCE, policies="rules: []" + ), + ) + + class _ExplodingDispatcher: + def __init__(self, _provider: Any) -> None: + self.dispatch = MagicMock() + + def shutdown(self) -> None: + raise RuntimeError("shutdown exploded") + + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.LiveTrackEventDispatcher", + _ExplodingDispatcher, + ) + # atexit stubs so the exploding shutdown is never left registered + # against the real atexit — otherwise it would fire at pytest exit. + registered: list[Any] = [] + + def _fake_register(func: Any, *_a: Any, **_kw: Any) -> Any: + registered.append(func) + return func + + def _fake_unregister(func: Any) -> None: + if func in registered: + registered.remove(func) + + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.atexit.register", _fake_register + ) + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.atexit.unregister", _fake_unregister + ) + + result = await resolve_governance( + agent_framework="langgraph", + agent_type="uipath_coded", + is_conversational=False, + ) + assert result is not None + # Must not raise even though the dispatcher's shutdown does. + result.dispose() + # And must remain idempotent-safe. + result.dispose() + + async def test_dispose_atexit_hook_matches_dispatcher_shutdown( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + ) -> None: + """The atexit hook that ``dispose`` unregisters must be the same + callable that ``atexit.register`` received — otherwise unregister + is a silent no-op and the dispatcher lingers. + """ + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.ENFORCE, policies="rules: []" + ), + ) + + registered_arg: list[Any] = [] + unregistered_arg: list[Any] = [] + + def _capture_register(func: Any) -> Any: + registered_arg.append(func) + return func + + def _capture_unregister(func: Any) -> None: + unregistered_arg.append(func) + + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.atexit.register", _capture_register + ) + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.atexit.unregister", + _capture_unregister, + ) + + result = await resolve_governance( + agent_framework="langgraph", + agent_type="uipath_coded", + is_conversational=False, + ) + assert result is not None + + result.dispose() + + assert len(registered_arg) == 1 + assert len(unregistered_arg) == 1 + # Same bound method → same underlying dispatcher shutdown. + assert registered_arg[0] == unregistered_arg[0] diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 8156765aa..e0829963a 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -351,6 +351,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] +[[package]] +name = "chardet" +version = "7.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/b6/9df434a8eeba2e6628c465a1dfa31034228ef79b26f76f46278f4ef7e49d/chardet-7.4.3.tar.gz", hash = "sha256:cc1d4eb92a4ec1c2df3b490836ffa46922e599d34ce0bb75cf41fd2bf6303d56", size = 784800, upload-time = "2026-04-13T21:33:39.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/52/505c207f334d51e937cbaa27ff95776e16e2d120e13cbe491cd7b3a70b50/chardet-7.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:25a862cddc6a9ac07023e808aedd297115345fbaabc2690479481ddc0f980e09", size = 870747, upload-time = "2026-04-13T21:32:56.916Z" }, + { url = "https://files.pythonhosted.org/packages/14/4b/d3c79495dee4831b8bebca2790e72cb90f0c5849c940570a7c7e5b70b952/chardet-7.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7005c88da26fd95d8abb8acbe6281d833e9a9181b03cf49b4546c4555389bd97", size = 853210, upload-time = "2026-04-13T21:32:58.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/99/f6a822ad1bde25a4c38dc3e770485e78e0893dfd871cd6e18ed3ea3a795e/chardet-7.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc50f28bad067393cce0af9091052c3b8df7a23115afd8ba7b2e0947f0cef1f8", size = 873625, upload-time = "2026-04-13T21:32:59.606Z" }, + { url = "https://files.pythonhosted.org/packages/b1/10/31932775c94a86814f76b41c4a772b52abfb0e6125324f32c6da1196c297/chardet-7.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3da294de1a681097848ab58bd3f2771a674f8039d2d87a5538b28856b815e9", size = 883436, upload-time = "2026-04-13T21:33:01.351Z" }, + { url = "https://files.pythonhosted.org/packages/6c/63/0f43e3acf2c436fdb32a0f904aeb03a2904d2126eed34a042a194d235926/chardet-7.4.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c45e116dd51b66226a53ade3f9f635e870de5399b90e00ce45dcc311093bf4", size = 876589, upload-time = "2026-04-13T21:33:02.636Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a6/e9b8f8a3e99602792b01fa7d0a731737615ab56d8bfd0b52935a0ef88b85/chardet-7.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:ccc1f83ab4bcfb901cf39e0c4ba6bc6e726fc6264735f10e24ceb5cb47387578", size = 941866, upload-time = "2026-04-13T21:33:04.282Z" }, + { url = "https://files.pythonhosted.org/packages/61/33/29de185079e6675c3f375546e30a559b7ddc75ce972f18d6e566cd9ea4eb/chardet-7.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:75d3c65cc16bddf40b8da1fd25ba84fca5f8070f2b14e86083653c1c85aee971", size = 874870, upload-time = "2026-04-13T21:33:05.977Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2f/4c5af01fd1a7506a1d5375403d68925eac70289229492db5aa68b58103d8/chardet-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:29af5999f654e8729d251f1724a62b538b1262d9292cccaefddf8a02aae1ef6a", size = 854859, upload-time = "2026-04-13T21:33:07.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/21/edb36ad5dfa48d7f8eed97ab43931ecdaa8c15166c21b1d614967e49d681/chardet-7.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:626f00299ad62dfe937058a09572beed442ccc7b58f87aa667949b20fd3db235", size = 875032, upload-time = "2026-04-13T21:33:08.741Z" }, + { url = "https://files.pythonhosted.org/packages/e5/59/a32a241d861cf180853a11c8e5a67641cb1b2af13c3a5ccce83ec07e2c9f/chardet-7.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a4904dd5f071b7a7d7f50b4a67a86db3c902d243bf31708f1d5cde2f68239cb", size = 888283, upload-time = "2026-04-13T21:33:10.213Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/e1ee6a77abf3782c00e05b89c4d4328c8353bf9500661c4348df1dd68614/chardet-7.4.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d2879598bc220689e8ce509fe9c3f37ad2fca53a36be9c9bd91abdd91dd364f", size = 879974, upload-time = "2026-04-13T21:33:11.448Z" }, + { url = "https://files.pythonhosted.org/packages/32/60/fca69c534602a7ced04280c952a246ad1edde2a6ca3a164f65d32ac41fe7/chardet-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:4b2799bd58e7245cfa8d4ab2e8ad1d76a5c3a5b1f32318eb6acca4c69a3e7101", size = 943973, upload-time = "2026-04-13T21:33:12.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/79ac9b4db5bc87020c9dbc419125371d80882d1d197e9c4765ba8682b605/chardet-7.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e4486df251b8962e86ea9f139ca235aa6e0542a00f7844c9a04160afb99aa9", size = 873769, upload-time = "2026-04-13T21:33:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/55/5f/25bdec773905bff0ff6cf35ca73b17bd05593b4f87bd8c5fa43705f7167d/chardet-7.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fbff1907925b0c5a1064cffb5e040cd5e338585c9c552625f30de6bc2f3107a", size = 853991, upload-time = "2026-04-13T21:33:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/a29380ee0b215d23d77733b5ad60c5c0c7969650e080c667acdf9462040d/chardet-7.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:365135eaf37ba65a828f8e668eb0a8c38c479dcbec724dc25f4dfd781049c357", size = 874024, upload-time = "2026-04-13T21:33:16.915Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b1/3338e121cbd4c8a126b8ccb1061170c2ce51a53f678c502793ea49c6fd6d/chardet-7.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfc134b70c846c21ead8e43ada3ae1a805fff732f6922f8abcf2ff27b8f6493d", size = 887410, upload-time = "2026-04-13T21:33:18.368Z" }, + { url = "https://files.pythonhosted.org/packages/63/1c/44a9a9e0c59c185a5d307ceaeee8768afa1558f0a24f7a4b5fa11b67586b/chardet-7.4.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9acd9988a93e09390f3cd231201ea7166c415eb8da1b735928990ffc05cb9fbb", size = 879269, upload-time = "2026-04-13T21:33:20.377Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b3/5d0e77ea774bd3224321c248880ea0c0379000ac5c2bb6d77609549de247/chardet-7.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:e1b98790c284ff813f18f7cf7de5f05ea2435a080030c7f1a8318f3a4f80b131", size = 944155, upload-time = "2026-04-13T21:33:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/70/a8/bf0811d859e13801279a2ae64f37a408027b282f2047bc0001c75dd356ad/chardet-7.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d892d3dcd652fdef53e3d6327d39b17c0df40a899dfc919abaeb64c974497531", size = 872887, upload-time = "2026-04-13T21:33:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/51/ac/b9d68ebddfe1b02c77af5bf81120e12b036b4432dc6af7a303d90e2bc38b/chardet-7.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:acc46d1b8b7d5783216afe15db56d1c179b9a40e5a1558bc13164c4fd20674c4", size = 853964, upload-time = "2026-04-13T21:33:24.724Z" }, + { url = "https://files.pythonhosted.org/packages/2a/81/17fa103ea9caf5d325a5e4051ab2ba65996fd66baa60b81ee41af1f54e10/chardet-7.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ac3bf11c645734a1701a3804e43eabd98851838192267d08c353a834ab79fea", size = 876006, upload-time = "2026-04-13T21:33:26.098Z" }, + { url = "https://files.pythonhosted.org/packages/c2/20/193faab46a68ea550587331a698c3dca8099f8901d10937c4443135c7ed9/chardet-7.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e3bd9f936e04bae89c254262af08d9e5b98f805175ba1e29d454e6cba3107b7", size = 887680, upload-time = "2026-04-13T21:33:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/40/c6/94a3c673327392652ee8bdea9a45bc8a5f5365197a7387d68f0eed007115/chardet-7.4.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:27cc23da03630cdecc9aa81a895aa86629c211f995cd57651f0fbc280717bf93", size = 879865, upload-time = "2026-04-13T21:33:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2c/cad8b5e3623a987f3c930b68e2bdd06cfc388cd91cd42ed05f1227701b73/chardet-7.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:b95c934b9ad59e2ba8abb9be49df70d3ad1b0d95d864b9fdb7588d4fa8bd921c", size = 939594, upload-time = "2026-04-13T21:33:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/33/e0/d06e42fd6f02a58e5e227e5106587751cb38adcff0aaf949add744b78b6e/chardet-7.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c77867f0c1cb8bd819502249fcdc500364aedb07881e11b743726fa2148e7b6e", size = 889714, upload-time = "2026-04-13T21:33:32.772Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ed/40d091954d48abea037baae6be8fb79905e5f78d34d12ea955132c7d8011/chardet-7.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf1efeaf65a6ef2f5b9cc3a1df6f08ba2831b369ccaa4c7018eaf90aa757bb11", size = 872319, upload-time = "2026-04-13T21:33:34.427Z" }, + { url = "https://files.pythonhosted.org/packages/bb/77/82a46821dbfbdfe062710d2bf2ede13426304e3567a23c57d919c0c31630/chardet-7.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f3504c139a2ad544077dd2d9e412cd08b01786843d76997cd43bb6de311723c", size = 892021, upload-time = "2026-04-13T21:33:35.766Z" }, + { url = "https://files.pythonhosted.org/packages/49/57/42d30c562bda5b4a839766c1aad8d5856b798ad2a1c3247b72a679afec94/chardet-7.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457f619882ba66327d4d8d14c6c342269bdb1e4e1c38e8117df941d14d351b04", size = 902509, upload-time = "2026-04-13T21:33:37.096Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6c/0a40afdb50a0fe041ab95553b835a8160b6cf0e81edf2ae2fe9f5224cbf9/chardet-7.4.3-py3-none-any.whl", hash = "sha256:1173b74051570cf08099d7429d92e4882d375ad4217f92a6e5240ccfb26f231e", size = 626562, upload-time = "2026-04-13T21:33:38.559Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -2520,6 +2557,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, +] + [[package]] name = "types-toml" version = "0.10.8.20240310" @@ -2552,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.12.5" +version = "2.12.6" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2568,6 +2614,7 @@ dependencies = [ { name = "pysignalr" }, { name = "python-dotenv" }, { name = "python-socketio" }, + { name = "pyyaml" }, { name = "rich" }, { name = "tenacity" }, { name = "truststore" }, @@ -2601,6 +2648,7 @@ dev = [ { name = "rust-just" }, { name = "termynal" }, { name = "tomli-w" }, + { name = "types-pyyaml" }, { name = "types-toml" }, { name = "virtualenv" }, ] @@ -2620,12 +2668,13 @@ requires-dist = [ { name = "pysignalr", specifier = "==1.3.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "python-socketio", specifier = ">=5.15.0,<6.0.0" }, + { name = "pyyaml", specifier = ">=6.0,<7.0" }, { name = "rich", specifier = ">=14.2.0" }, { name = "tenacity", specifier = ">=9.0.0" }, { name = "truststore", specifier = ">=0.10.1" }, { name = "uipath-core", editable = "../uipath-core" }, { name = "uipath-platform", editable = "../uipath-platform" }, - { name = "uipath-runtime", specifier = ">=0.11.5,<0.12.0" }, + { name = "uipath-runtime", specifier = ">=0.11.7,<0.12.0" }, ] [package.metadata.requires-dev] @@ -2653,6 +2702,7 @@ dev = [ { name = "rust-just", specifier = ">=1.39.0" }, { name = "termynal", specifier = ">=0.13.1" }, { name = "tomli-w", specifier = ">=1.2.0" }, + { name = "types-pyyaml", specifier = ">=6.0" }, { name = "types-toml", specifier = ">=0.10.8" }, { name = "virtualenv", specifier = ">=20.36.1" }, ] @@ -2729,14 +2779,16 @@ dev = [ [[package]] name = "uipath-runtime" -version = "0.11.5" +version = "0.11.7" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "chardet" }, { name = "uipath-core" }, + { name = "vadersentiment" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/f1/28ca53eee0176a5f2a4985f82a1d184fe01c21d84043557723e2c22e242f/uipath_runtime-0.11.5.tar.gz", hash = "sha256:a1f04c4199875ab72055082edd30c3546fd3ed80d1d70ed2c042b4b4047f8935", size = 152819, upload-time = "2026-06-29T16:08:00.037Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/59/a491091f7e9ee3de8741b7658745214706e2adb01b89bf114f0f2dabff53/uipath_runtime-0.11.7.tar.gz", hash = "sha256:8f4b58c444aef31465e66a470cd58300eb9d440bd67e6f6f1ba204bee704680f", size = 231824, upload-time = "2026-07-02T12:53:29.28Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/58/bd6c42a11b4eb7fd98f8a5b1b7e19e3b15d301c6894154320312086ff5c9/uipath_runtime-0.11.5-py3-none-any.whl", hash = "sha256:e665e10e3beaeba3dae48e354927604fba460568f73694c74e76d018a18d44af", size = 49864, upload-time = "2026-06-29T16:07:58.773Z" }, + { url = "https://files.pythonhosted.org/packages/58/e5/4bcb8902fc945e1abd560b2adae06d3ac336c58dd036a0ca23bf9cb67ea4/uipath_runtime-0.11.7-py3-none-any.whl", hash = "sha256:18a4632a481eef101cd6f026f2938854c80a8fb24fd749dfe02fe62015eb84e2", size = 91126, upload-time = "2026-07-02T12:53:27.549Z" }, ] [[package]] @@ -2748,6 +2800,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "vadersentiment" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/8c/4a48c10a50f750ae565e341e697d74a38075a3e43ff0df6f1ab72e186902/vaderSentiment-3.3.2.tar.gz", hash = "sha256:5d7c06e027fc8b99238edb0d53d970cf97066ef97654009890b83703849632f9", size = 2466783, upload-time = "2020-05-22T15:06:32.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/fc/310e16254683c1ed35eeb97386986d6c00bc29df17ce280aed64d55537e9/vaderSentiment-3.3.2-py2.py3-none-any.whl", hash = "sha256:3bf1d243b98b1afad575b9f22bc2cb1e212b94ff89ca74f8a23a588d024ea311", size = 125950, upload-time = "2020-05-22T15:07:00.052Z" }, +] + [[package]] name = "virtualenv" version = "20.36.1" From f5c00ccb10694dca518fc042d3e6faecaeba08b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrei=20Ancu=C8=9Ba?= Date: Fri, 3 Jul 2026 13:37:02 +0300 Subject: [PATCH 145/191] =?UTF-8?q?fix(platform):=20move=20ExternalApplica?= =?UTF-8?q?tionService=20out=20of=20common=20to=20break=20identity?= =?UTF-8?q?=E2=86=94common=20import=20cycle=20(#1787)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/_uipath.py | 2 +- .../src/uipath/platform/common/__init__.py | 2 - .../external_applications/__init__.py | 5 + .../_external_application_service.py | 93 ++++++------------- .../test_external_application_service.py | 4 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 4 +- .../src/uipath/_cli/_auth/_auth_service.py | 3 +- packages/uipath/uv.lock | 4 +- 10 files changed, 45 insertions(+), 76 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/external_applications/__init__.py rename packages/uipath-platform/src/uipath/platform/{common => external_applications}/_external_application_service.py (63%) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 9dd30533b..d1601847e 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.90" +version = "0.1.91" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index 0083388db..98af7b8b6 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -12,7 +12,6 @@ from .chat import ConversationsService, UiPathLlmChatService, UiPathOpenAIService from .common import ( ApiClient, - ExternalApplicationService, UiPathApiConfig, UiPathExecutionContext, ) @@ -22,6 +21,7 @@ from .documents import DocumentsService from .entities import EntitiesService from .errors import BaseUrlMissingError, SecretMissingError +from .external_applications import ExternalApplicationService from .governance import GovernanceService from .guardrails import GuardrailsService from .memory import MemoryService diff --git a/packages/uipath-platform/src/uipath/platform/common/__init__.py b/packages/uipath-platform/src/uipath/platform/common/__init__.py index 5f8fbbf75..8f4b23d57 100644 --- a/packages/uipath-platform/src/uipath/platform/common/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/common/__init__.py @@ -17,7 +17,6 @@ from ._config import UiPathApiConfig, UiPathConfig from ._endpoints_manager import EndpointManager from ._execution_context import ExecutionSourceContext, UiPathExecutionContext -from ._external_application_service import ExternalApplicationService from ._folder_context import FolderContext, header_folder from ._http_config import get_ca_bundle_path, get_httpx_client_kwargs from ._models import Endpoint, RequestSpec @@ -70,7 +69,6 @@ "UiPathApiConfig", "UiPathExecutionContext", "ExecutionSourceContext", - "ExternalApplicationService", "FolderContext", "TokenData", "UiPathConfig", diff --git a/packages/uipath-platform/src/uipath/platform/external_applications/__init__.py b/packages/uipath-platform/src/uipath/platform/external_applications/__init__.py new file mode 100644 index 000000000..612d131c0 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/external_applications/__init__.py @@ -0,0 +1,5 @@ +"""UiPath External Applications.""" + +from ._external_application_service import ExternalApplicationService + +__all__ = ["ExternalApplicationService"] diff --git a/packages/uipath-platform/src/uipath/platform/common/_external_application_service.py b/packages/uipath-platform/src/uipath/platform/external_applications/_external_application_service.py similarity index 63% rename from packages/uipath-platform/src/uipath/platform/common/_external_application_service.py rename to packages/uipath-platform/src/uipath/platform/external_applications/_external_application_service.py index 8652e7ede..6fac449d4 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_external_application_service.py +++ b/packages/uipath-platform/src/uipath/platform/external_applications/_external_application_service.py @@ -1,3 +1,5 @@ +from collections.abc import Iterator +from contextlib import contextmanager from os import environ as env from typing import Optional from urllib.parse import urlparse @@ -7,9 +9,9 @@ from uipath.platform.constants import ENV_BASE_URL +from ..common.auth import TokenData from ..errors import EnrichedException from ..identity import IdentityService -from .auth import TokenData class ExternalApplicationService: @@ -72,6 +74,31 @@ def _extract_environment_from_base_url(self, base_url: str) -> str: # Default to cloud if parsing fails return "cloud" + @contextmanager + def _translate_auth_errors(self) -> Iterator[None]: + """Translate token acquisition failures into authentication errors.""" + try: + yield + except HTTPStatusError as e: + match e.response.status_code: + case 400: + message = "Invalid client credentials or request parameters." + case 401: + message = "Unauthorized: Invalid client credentials." + case _: + message = f"Authentication failed with unexpected status: {e.response.status_code}" + raise EnrichedException( + HTTPStatusError( + message=message, + request=e.request, + response=e.response, + ) + ) from e + except httpx.RequestError as e: + raise Exception(f"Network error during authentication: {e}") from e + except Exception as e: + raise Exception(f"Unexpected error during authentication: {e}") from e + def get_token_data( self, client_id: str, client_secret: str, scope: Optional[str] = "OR.Execution" ) -> TokenData: @@ -85,42 +112,12 @@ def get_token_data( Returns: Token data if successful """ - try: + with self._translate_auth_errors(): return self._identity_service.get_client_credentials_token( client_id=client_id, client_secret=client_secret, scope=scope, ) - except HTTPStatusError as e: - match e.response.status_code: - case 400: - raise EnrichedException( - HTTPStatusError( - message="Invalid client credentials or request parameters.", - request=e.request, - response=e.response, - ) - ) from e - case 401: - raise EnrichedException( - HTTPStatusError( - message="Unauthorized: Invalid client credentials.", - request=e.request, - response=e.response, - ) - ) from e - case _: - raise EnrichedException( - HTTPStatusError( - message=f"Authentication failed with unexpected status: {e.response.status_code}", - request=e.request, - response=e.response, - ) - ) from e - except httpx.RequestError as e: - raise Exception(f"Network error during authentication: {e}") from e - except Exception as e: - raise Exception(f"Unexpected error during authentication: {e}") from e async def get_token_data_async( self, client_id: str, client_secret: str, scope: Optional[str] = "OR.Execution" @@ -135,39 +132,9 @@ async def get_token_data_async( Returns: Token data if successful """ - try: + with self._translate_auth_errors(): return await self._identity_service.get_client_credentials_token_async( client_id=client_id, client_secret=client_secret, scope=scope, ) - except HTTPStatusError as e: - match e.response.status_code: - case 400: - raise EnrichedException( - HTTPStatusError( - message="Invalid client credentials or request parameters.", - request=e.request, - response=e.response, - ) - ) from e - case 401: - raise EnrichedException( - HTTPStatusError( - message="Unauthorized: Invalid client credentials.", - request=e.request, - response=e.response, - ) - ) from e - case _: - raise EnrichedException( - HTTPStatusError( - message=f"Authentication failed with unexpected status: {e.response.status_code}", - request=e.request, - response=e.response, - ) - ) from e - except httpx.RequestError as e: - raise Exception(f"Network error during authentication: {e}") from e - except Exception as e: - raise Exception(f"Unexpected error during authentication: {e}") from e diff --git a/packages/uipath-platform/tests/services/test_external_application_service.py b/packages/uipath-platform/tests/services/test_external_application_service.py index 15c267888..1fca91b32 100644 --- a/packages/uipath-platform/tests/services/test_external_application_service.py +++ b/packages/uipath-platform/tests/services/test_external_application_service.py @@ -3,11 +3,9 @@ import httpx import pytest -from uipath.platform.common._external_application_service import ( - ExternalApplicationService, -) from uipath.platform.common.auth import TokenData from uipath.platform.errors import EnrichedException +from uipath.platform.external_applications import ExternalApplicationService IDENTITY_SERVICE_SYNC = ( "uipath.platform.identity.IdentityService.get_client_credentials_token" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 5ae4cb5d6..c369fe90e 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.90" +version = "0.1.91" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 15791f4d5..561499d12 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.12.6" +version = "2.12.7" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.26, <0.6.0", "uipath-runtime>=0.11.7, <0.12.0", - "uipath-platform>=0.1.89, <0.2.0", + "uipath-platform>=0.1.91, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/src/uipath/_cli/_auth/_auth_service.py b/packages/uipath/src/uipath/_cli/_auth/_auth_service.py index cdaf430af..b594e8fe1 100644 --- a/packages/uipath/src/uipath/_cli/_auth/_auth_service.py +++ b/packages/uipath/src/uipath/_cli/_auth/_auth_service.py @@ -8,13 +8,14 @@ from uipath._cli._auth._utils import get_parsed_token_data from uipath._cli._utils._console import ConsoleLogger from uipath._utils._auth import update_env_file -from uipath.platform.common import ExternalApplicationService, TokenData +from uipath.platform.common import TokenData from uipath.platform.constants import ( ENV_BASE_URL, ENV_ORGANIZATION_ID, ENV_TENANT_ID, ENV_UIPATH_ACCESS_TOKEN, ) +from uipath.platform.external_applications import ExternalApplicationService from ._utils import update_auth_file diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index e0829963a..d441e74f0 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2598,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.12.6" +version = "2.12.7" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2741,7 +2741,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.90" +version = "0.1.91" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 0c3eeb9c6f98bfbe7d28c1c5119d5bdb5ac2a56e Mon Sep 17 00:00:00 2001 From: Josh Park <50765702+JoshParkSJ@users.noreply.github.com> Date: Mon, 6 Jul 2026 07:34:20 -0400 Subject: [PATCH 146/191] feat(voice): preserve voice session end detail (#1764) --- packages/uipath/pyproject.toml | 4 +- .../src/uipath/_cli/_chat/_voice_bridge.py | 22 +++++++++- .../tests/cli/chat/test_voice_bridge.py | 41 +++++++++++++++++++ packages/uipath/uv.lock | 10 ++--- 4 files changed, 68 insertions(+), 9 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 561499d12..a0a6d9e43 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "uipath" -version = "2.12.7" +version = "2.12.8" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.26, <0.6.0", - "uipath-runtime>=0.11.7, <0.12.0", + "uipath-runtime>=0.12.1, <0.13.0", "uipath-platform>=0.1.91, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", diff --git a/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py index d962c2bf0..4240f28c0 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py @@ -6,6 +6,7 @@ import logging import os from collections.abc import Awaitable, Callable +from copy import deepcopy from enum import Enum from typing import Any from urllib.parse import urlparse @@ -82,6 +83,12 @@ def __init__( self._done = asyncio.Event() self._in_flight: set[asyncio.Task[None]] = set() self._end_reason: VoiceSessionEndReason | None = None + self._end_detail: dict[str, Any] = {} + + @property + def end_detail(self) -> dict[str, Any]: + """CAS payload from voice_session_ended, preserved for the job runtime.""" + return deepcopy(self._end_detail) async def run(self) -> VoiceSessionEndReason: """Connect, dispatch tool calls until session ends, then disconnect. @@ -214,8 +221,19 @@ async def _execute_tool_call(self, call: UiPathVoiceToolCallRequest) -> None: tool_result.is_error, ) - async def _handle_session_ended(self, _data: Any, *_: Any) -> None: - logger.info("[Voice] voice_session_ended received") + async def _handle_session_ended(self, data: Any = None, *_: Any) -> None: + if self._done.is_set(): + return + + detail = deepcopy(data) if isinstance(data, dict) else {} + self._end_detail = detail + logger.info( + "[Voice] voice_session_ended received " + "(endedBy=%s, callEnded=%s, reason=%s)", + detail.get("endedBy"), + detail.get("callEnded"), + detail.get("reason"), + ) self._end_session(VoiceSessionEndReason.COMPLETED) diff --git a/packages/uipath/tests/cli/chat/test_voice_bridge.py b/packages/uipath/tests/cli/chat/test_voice_bridge.py index d3f8c483d..663175199 100644 --- a/packages/uipath/tests/cli/chat/test_voice_bridge.py +++ b/packages/uipath/tests/cli/chat/test_voice_bridge.py @@ -46,6 +46,47 @@ async def test_session_ended_sets_completed(self) -> None: await session._handle_session_ended(None) assert session._end_reason == VoiceSessionEndReason.COMPLETED + async def test_session_ended_preserves_payload_opaquely(self) -> None: + session = _make_session() + payload = { + "callContext": { + "type": "phone", + "id": "CA123", + "conversationId": "conv-1", + }, + "endedBy": "agent", + "callEnded": False, + "reason": "agent_completed", + "someFutureKey": {"nested": True}, + } + + await session._handle_session_ended(payload) + + assert session.end_detail == payload + assert session._end_reason == VoiceSessionEndReason.COMPLETED + returned_detail = session.end_detail + returned_detail["reason"] = "mutated" + returned_detail["callContext"]["id"] = "CA999" + payload["endedBy"] = "system" + assert session.end_detail["reason"] == "agent_completed" + assert session.end_detail["callContext"]["id"] == "CA123" + assert session.end_detail["endedBy"] == "agent" + + async def test_session_ended_non_dict_payload_is_empty_detail(self) -> None: + session = _make_session() + await session._handle_session_ended("not-a-dict") + assert session.end_detail == {} + assert session._end_reason == VoiceSessionEndReason.COMPLETED + + async def test_late_session_ended_does_not_overwrite_terminal_state(self) -> None: + session = _make_session() + await session._handle_session_ended({"endedBy": "agent", "callEnded": False}) + + await session._handle_session_ended({"endedBy": "system", "callEnded": True}) + + assert session.end_detail == {"endedBy": "agent", "callEnded": False} + assert session._end_reason == VoiceSessionEndReason.COMPLETED + async def test_disconnect_sets_disconnected(self) -> None: session = _make_session() await session._handle_disconnect() diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index d441e74f0..50d89c69e 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2598,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.12.7" +version = "2.12.8" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2674,7 +2674,7 @@ requires-dist = [ { name = "truststore", specifier = ">=0.10.1" }, { name = "uipath-core", editable = "../uipath-core" }, { name = "uipath-platform", editable = "../uipath-platform" }, - { name = "uipath-runtime", specifier = ">=0.11.7,<0.12.0" }, + { name = "uipath-runtime", specifier = ">=0.12.1,<0.13.0" }, ] [package.metadata.requires-dev] @@ -2779,16 +2779,16 @@ dev = [ [[package]] name = "uipath-runtime" -version = "0.11.7" +version = "0.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "chardet" }, { name = "uipath-core" }, { name = "vadersentiment" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/59/a491091f7e9ee3de8741b7658745214706e2adb01b89bf114f0f2dabff53/uipath_runtime-0.11.7.tar.gz", hash = "sha256:8f4b58c444aef31465e66a470cd58300eb9d440bd67e6f6f1ba204bee704680f", size = 231824, upload-time = "2026-07-02T12:53:29.28Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/0b/6d6bfd912c2ad6978f58646becf3647430ccb087008a8c78dbc33b0b101e/uipath_runtime-0.12.1.tar.gz", hash = "sha256:a63ce739ea53da8479bb0314477f89d42724045ecbd7db3f2a3f0a09ab17a755", size = 235372, upload-time = "2026-07-06T11:14:51.947Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/e5/4bcb8902fc945e1abd560b2adae06d3ac336c58dd036a0ca23bf9cb67ea4/uipath_runtime-0.11.7-py3-none-any.whl", hash = "sha256:18a4632a481eef101cd6f026f2938854c80a8fb24fd749dfe02fe62015eb84e2", size = 91126, upload-time = "2026-07-02T12:53:27.549Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4c/433de72c4c5869ee26a63f68361b5c62d04bb03e9c7db9a69c0d3db7b458/uipath_runtime-0.12.1-py3-none-any.whl", hash = "sha256:88b014e0c5a50a5b41b2e532bd628eebc91d70ad42b6d3389fdc61b7ae78e0a6", size = 92004, upload-time = "2026-07-06T11:14:50.363Z" }, ] [[package]] From 7252b2c6e7180d5f1f46b9ce5e3147976ae5bd6c Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Mon, 6 Jul 2026 16:41:41 +0300 Subject: [PATCH 147/191] feat: support composite interrupt resume triggers (#1769) --- .github/labeler.yml | 2 + packages/uipath-platform/pyproject.toml | 2 +- .../platform/resume_triggers/_protocol.py | 21 ++++++++++ .../tests/services/test_hitl.py | 39 +++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 4 +- packages/uipath/uv.lock | 4 +- 7 files changed, 68 insertions(+), 6 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index d83420502..32d610bd5 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -17,3 +17,5 @@ test:uipath-integrations: test:uipath-runtime: - changed-files: - any-glob-to-any-file: ['packages/uipath-core/src/**/*.py'] + - changed-files: + - any-glob-to-any-file: ['packages/uipath/pyproject.toml'] diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index d1601847e..7a4d93b11 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.91" +version = "0.2.0" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py b/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py index e27f18025..784e9f2ae 100644 --- a/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py +++ b/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py @@ -453,6 +453,23 @@ class UiPathResumeTriggerCreator: Implements UiPathResumeTriggerCreatorProtocol. """ + async def create_triggers(self, suspend_value: Any) -> list[UiPathResumeTrigger]: + """Create resume triggers from a suspend value. + + Most values create a single trigger. A list or tuple creates sibling + triggers for the same interrupt; whichever one fires first resumes it. + """ + if isinstance(suspend_value, (list, tuple)): + if not suspend_value: + raise ValueError("At least one interrupt model is required.") + return [ + await self.create_trigger(child_suspend_value) + for child_suspend_value in suspend_value + ] + + resume_trigger = await self.create_trigger(suspend_value) + return [resume_trigger] + async def create_trigger(self, suspend_value: Any) -> UiPathResumeTrigger: """Create a resume trigger from a suspend value. @@ -1040,6 +1057,10 @@ async def create_trigger(self, suspend_value: Any) -> UiPathResumeTrigger: """ return await self._creator.create_trigger(suspend_value) + async def create_triggers(self, suspend_value: Any) -> list[UiPathResumeTrigger]: + """Create resume triggers from a suspend value.""" + return await self._creator.create_triggers(suspend_value) + async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: """Read a resume trigger and convert it to runtime-compatible input. diff --git a/packages/uipath-platform/tests/services/test_hitl.py b/packages/uipath-platform/tests/services/test_hitl.py index 5151d7a7a..134d75fc0 100644 --- a/packages/uipath-platform/tests/services/test_hitl.py +++ b/packages/uipath-platform/tests/services/test_hitl.py @@ -2109,6 +2109,45 @@ def test_wait_until_requires_timezone_aware_resume_time(self) -> None: with pytest.raises(ValueError, match="resume_time must include timezone"): WaitUntil(resume_time=datetime(2026, 6, 27, 20, 14, 49)) + @pytest.mark.anyio + async def test_create_resume_triggers_for_interrupt_list( + self, + ) -> None: + """Test an interrupt list creates sibling triggers for the same interrupt.""" + job_key = "test-job-key" + wait_job = WaitJob( + job=Job( + id=1234, + key=job_key, + folder_key="d0e09040-5997-44e1-93b7-4087689521b7", + ), + process_folder_path="/test/path", + ) + wait_until = WaitUntil( + resume_time=datetime(2026, 6, 27, 23, 14, 49, tzinfo=timezone.utc) + ) + + processor = UiPathResumeTriggerCreator() + triggers = await processor.create_triggers([wait_job, wait_until]) + + assert len(triggers) == 2 + job_trigger, timer_trigger = triggers + assert job_trigger.trigger_type == UiPathResumeTriggerType.JOB + assert job_trigger.item_key == job_key + assert timer_trigger.trigger_type == UiPathResumeTriggerType.TIMER + assert timer_trigger.trigger_name == UiPathResumeTriggerName.TIMER + assert timer_trigger.resume_time == datetime( + 2026, 6, 27, 23, 14, 49, tzinfo=timezone.utc + ) + + @pytest.mark.anyio + async def test_create_resume_triggers_rejects_empty_interrupt_list(self) -> None: + """Test an interrupt list must include at least one model.""" + processor = UiPathResumeTriggerCreator() + + with pytest.raises(ValueError, match="At least one interrupt model"): + await processor.create_triggers([]) + class TestDocumentExtractionModels: """Tests for document extraction models.""" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index c369fe90e..dceb4dbda 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.91" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index a0a6d9e43..f6d962ab2 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.12.8" +version = "2.13.0" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.26, <0.6.0", "uipath-runtime>=0.12.1, <0.13.0", - "uipath-platform>=0.1.91, <0.2.0", + "uipath-platform>=0.2.0, <0.3.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 50d89c69e..e56bdafcf 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2598,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.12.8" +version = "2.13.0" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2741,7 +2741,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.91" +version = "0.2.0" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 854076af9fe9769e5067c39ce2d3c99849d1c7f5 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Mon, 6 Jul 2026 16:55:56 +0300 Subject: [PATCH 148/191] fix(cli): guard find_spec in module preload against missing parents (#1791) --- packages/uipath/pyproject.toml | 2 +- packages/uipath/src/uipath/_cli/cli_server.py | 5 +- packages/uipath/tests/cli/test_server.py | 56 +++++++++++++++++++ packages/uipath/uv.lock | 2 +- 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index f6d962ab2..ed676e5f6 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.13.0" +version = "2.13.1" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/cli_server.py b/packages/uipath/src/uipath/_cli/cli_server.py index c32c6de11..06450c401 100644 --- a/packages/uipath/src/uipath/_cli/cli_server.py +++ b/packages/uipath/src/uipath/_cli/cli_server.py @@ -83,9 +83,10 @@ def preload_modules() -> None: for module_name in modules_to_load: if module_name in sys.modules: continue - if find_spec(module_name) is None: - continue try: + # find_spec raises ModuleNotFoundError when a parent package is missing + if find_spec(module_name) is None: + continue importlib.import_module(module_name) console.success(f"Pre-loaded module: {module_name}") except ImportError as e: diff --git a/packages/uipath/tests/cli/test_server.py b/packages/uipath/tests/cli/test_server.py index 185979924..1d342fe03 100644 --- a/packages/uipath/tests/cli/test_server.py +++ b/packages/uipath/tests/cli/test_server.py @@ -369,3 +369,59 @@ def test_env_restored_after_request(self, server_with_spy): assert "SHOULD_NOT_PERSIST" not in os.environ for key in baseline: assert os.environ.get(key) == baseline[key] + + +class TestPreloadModules: + """Tests for preload_modules and its find_spec guard.""" + + def _run_with_modules(self, monkeypatch, modules): + from uipath._cli import cli_server + + class _FakeEntryPoint: + name = "fake" + + def load(self): + return lambda: modules + + monkeypatch.setattr( + cli_server, "entry_points", lambda group: [_FakeEntryPoint()] + ) + monkeypatch.setattr(cli_server, "DEFAULT_PRELOAD_MODULES", []) + cli_server.preload_modules() + + def test_missing_parent_package_does_not_crash(self, monkeypatch): + # find_spec raises ModuleNotFoundError when a parent package is absent; + # a stale entry like this must be skipped, not take down the server. + self._run_with_modules(monkeypatch, ["definitely_missing_pkg._private.types"]) + + def test_missing_leaf_module_is_skipped(self, monkeypatch): + # parent imports fine, leaf is absent -> find_spec returns None + self._run_with_modules(monkeypatch, ["json.does_not_exist"]) + + def test_existing_module_is_imported(self, monkeypatch): + import sys + + sys.modules.pop("difflib", None) + self._run_with_modules(monkeypatch, ["difflib"]) + assert "difflib" in sys.modules + + def test_already_loaded_module_is_skipped(self, monkeypatch): + import sys + + assert "json" in sys.modules + self._run_with_modules(monkeypatch, ["json"]) + + def test_failing_entry_point_does_not_crash(self, monkeypatch): + from uipath._cli import cli_server + + class _BrokenEntryPoint: + name = "broken" + + def load(self): + raise RuntimeError("boom") + + monkeypatch.setattr( + cli_server, "entry_points", lambda group: [_BrokenEntryPoint()] + ) + monkeypatch.setattr(cli_server, "DEFAULT_PRELOAD_MODULES", []) + cli_server.preload_modules() diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index e56bdafcf..c236f7feb 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2598,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.0" +version = "2.13.1" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 8ae652bd5fb27eaaac02af8c86f3103375339022 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Mon, 6 Jul 2026 17:28:54 +0300 Subject: [PATCH 149/191] test: cover runtime platform protocol compatibility (#1792) --- .../cli/unit/test_runtime_protocol_compat.py | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 packages/uipath/tests/cli/unit/test_runtime_protocol_compat.py diff --git a/packages/uipath/tests/cli/unit/test_runtime_protocol_compat.py b/packages/uipath/tests/cli/unit/test_runtime_protocol_compat.py new file mode 100644 index 000000000..812ddaa11 --- /dev/null +++ b/packages/uipath/tests/cli/unit/test_runtime_protocol_compat.py @@ -0,0 +1,139 @@ +from collections.abc import AsyncGenerator +from datetime import datetime, timezone +from typing import Any + +import pytest + +from uipath.core.triggers import ( + UiPathResumeTrigger, + UiPathResumeTriggerName, + UiPathResumeTriggerType, +) +from uipath.platform.common import WaitUntil +from uipath.platform.resume_triggers import UiPathResumeTriggerHandler +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathResumableRuntime, + UiPathResumableStorageProtocol, + UiPathResumeTriggerProtocol, + UiPathRuntimeEvent, + UiPathRuntimeProtocol, + UiPathRuntimeResult, + UiPathRuntimeStatus, + UiPathStreamOptions, +) +from uipath.runtime.schema import UiPathRuntimeSchema + + +class SuspendedRuntime: + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + del input, options + return UiPathRuntimeResult( + status=UiPathRuntimeStatus.SUSPENDED, + output={ + "interrupt-1": WaitUntil( + resume_time=datetime(2026, 7, 6, 12, tzinfo=timezone.utc) + ) + }, + ) + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + yield await self.execute(input, options) + + async def get_schema(self) -> UiPathRuntimeSchema: + return UiPathRuntimeSchema( + filePath="agent.py", + uniqueId="agent", + type="test", + input={}, + output={}, + ) + + async def dispose(self) -> None: + pass + + +class MemoryResumableStorage: + def __init__(self) -> None: + self.triggers: list[UiPathResumeTrigger] | None = None + self.saved_runtime_id: str | None = None + + async def set_value( + self, runtime_id: str, namespace: str, key: str, value: Any + ) -> None: + del runtime_id, namespace, key, value + + async def get_value(self, runtime_id: str, namespace: str, key: str) -> Any: + del runtime_id, namespace, key + return None + + async def save_triggers( + self, runtime_id: str, triggers: list[UiPathResumeTrigger] + ) -> None: + self.saved_runtime_id = runtime_id + self.triggers = triggers + + async def get_triggers(self, runtime_id: str) -> list[UiPathResumeTrigger] | None: + del runtime_id + return self.triggers + + async def delete_triggers( + self, runtime_id: str, triggers: list[UiPathResumeTrigger] + ) -> None: + del runtime_id + if self.triggers is None: + return + self.triggers = [ + trigger for trigger in self.triggers if trigger not in triggers + ] + + async def delete_trigger( + self, runtime_id: str, trigger: UiPathResumeTrigger + ) -> None: + await self.delete_triggers(runtime_id, [trigger]) + + +@pytest.mark.anyio +async def test_platform_handler_satisfies_runtime_trigger_protocol() -> None: + handler: UiPathResumeTriggerProtocol = UiPathResumeTriggerHandler() + + triggers = await handler.create_triggers( + WaitUntil(resume_time=datetime(2026, 7, 6, 12, tzinfo=timezone.utc)) + ) + + assert len(triggers) == 1 + assert triggers[0].trigger_type == UiPathResumeTriggerType.TIMER + assert triggers[0].trigger_name == UiPathResumeTriggerName.TIMER + + +@pytest.mark.anyio +async def test_resumable_runtime_uses_platform_trigger_protocol() -> None: + delegate: UiPathRuntimeProtocol = SuspendedRuntime() + memory_storage = MemoryResumableStorage() + storage: UiPathResumableStorageProtocol = memory_storage + handler: UiPathResumeTriggerProtocol = UiPathResumeTriggerHandler() + + runtime = UiPathResumableRuntime( + delegate=delegate, + storage=storage, + trigger_manager=handler, + runtime_id="runtime-1", + ) + + result = await runtime.execute() + + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert memory_storage.saved_runtime_id == "runtime-1" + assert result.triggers is not None + assert len(result.triggers) == 1 + assert result.triggers[0].interrupt_id == "interrupt-1" + assert result.triggers[0].trigger_type == UiPathResumeTriggerType.TIMER + assert result.triggers[0].trigger_name == UiPathResumeTriggerName.TIMER From db7856900e0165cb5bb529aa8e7b0d7d3a4e7068 Mon Sep 17 00:00:00 2001 From: sankalp-uipath Date: Tue, 7 Jul 2026 12:21:16 +0530 Subject: [PATCH 150/191] feat(agent): add ontology binding to Data Fabric context config (#1728) Co-authored-by: Claude Opus 4.8 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/entities/__init__.py | 2 + .../platform/entities/_entities_service.py | 29 +++++ .../entities/_entity_ontology_service.py | 80 ++++++++++++ .../tests/services/test_entities_service.py | 91 +++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 6 +- .../uipath/src/uipath/agent/models/agent.py | 11 +- .../uipath/tests/agent/models/test_agent.py | 121 ++++++++++++++++++ packages/uipath/uv.lock | 6 +- 10 files changed, 341 insertions(+), 9 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/entities/_entity_ontology_service.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 7a4d93b11..90de5f8cc 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.0" +version = "0.2.1" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/entities/__init__.py b/packages/uipath-platform/src/uipath/platform/entities/__init__.py index 67572ec87..8c323eca3 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/entities/__init__.py @@ -4,6 +4,7 @@ """ from ._entities_service import EntitiesService +from ._entity_ontology_service import DataFabricOntologyItem from .entities import ( AggregateRow, ChoiceSetValue, @@ -46,6 +47,7 @@ "AggregateRow", "ChoiceSetValue", "DataFabricEntityItem", + "DataFabricOntologyItem", "EntitiesService", "Entity", "EntityAggregate", diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py index 31e1a2de3..8ab08f2f1 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py @@ -27,6 +27,7 @@ from ..errors._datafabric_error import attach_datafabric_error_mapping from ..orchestrator._folder_service import FolderService from ._entity_data_service import EntityDataService, FileContent +from ._entity_ontology_service import EntityOntologyService from ._entity_resolution import ( build_resolution_service, create_resolution_plan, @@ -105,6 +106,11 @@ def __init__( folders_service=folders_service, routing_strategy=self._routing_strategy, ) + self._ontology = EntityOntologyService( + config=config, + execution_context=execution_context, + folders_service=folders_service, + ) # ------------------------------------------------------------------ # Schema operations — delegate to EntitySchemaService @@ -1101,6 +1107,29 @@ async def delete_record_async(self, entity_key: str, record_id: str) -> None: """ await self._data.delete_record_async(entity_key, record_id) + async def get_ontology_file_async( + self, + ontology_name: str, + file_type: str = "owl", + folder_key: Optional[str] = None, + ) -> Dict[str, Any]: + """Fetch one file of an ontology from Data Fabric. + + !!! warning "Preview Feature" + This method is currently experimental. Behavior and parameters are + subject to change in future versions. + + Args: + ontology_name (str): Name of the ontology. + file_type (str): The ontology file to fetch — one of owl, r2rml, + shacl, summary, context. + folder_key (Optional[str]): Key of the folder the ontology lives in. + + Returns: + Dict[str, Any]: The file record (e.g. ``content``, ``mediaType``). + """ + return await self._ontology.get_file_async(ontology_name, file_type, folder_key) + @traced(name="entity_record_insert_batch", run_type="uipath") def insert_records( self, diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entity_ontology_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entity_ontology_service.py new file mode 100644 index 000000000..a92589f60 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/entities/_entity_ontology_service.py @@ -0,0 +1,80 @@ +"""Ontology-side operations for the Data Fabric entities surface. + +Handles retrieval of ontology component files (OWL schema, R2RML mapping, and +other typed files). Entity schema and record operations are managed by +:class:`EntitySchemaService` / :class:`EntityDataService` and exposed alongside +ontology operations through :class:`EntitiesService`. +""" + +from typing import Any, Dict, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._folder_context import header_folder +from ..common._models import Endpoint, RequestSpec +from ..orchestrator._folder_service import FolderService + + +class DataFabricOntologyItem(BaseModel): + """A single Data Fabric ontology reference from agent configuration.""" + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + name: str + folder_key: str = Field(alias="folderId") + description: Optional[str] = None + id: Optional[str] = None + + +class EntityOntologyService(BaseService): + """HTTP service for Data Fabric ontology file retrieval. + + Backend target: ``datafabric_/api/ontologies``. + + See Also: + https://docs.uipath.com/data-service/automation-cloud/latest/user-guide/introduction + + !!! warning "Preview Feature" + This service is currently experimental. Behavior and parameters are + subject to change in future versions. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Optional[FolderService] = None, + ) -> None: + """Initialise the ontology service.""" + super().__init__(config=config, execution_context=execution_context) + self._folders_service = folders_service + + async def get_file_async( + self, + ontology_name: str, + file_type: str = "owl", + folder_key: Optional[str] = None, + ) -> Dict[str, Any]: + """Internal implementation; see :meth:`EntitiesService.get_ontology_file_async`.""" + spec = self._ontology_file_spec(ontology_name, file_type, folder_key) + response = await self.request_async( + spec.method, spec.endpoint, headers=spec.headers + ) + return response.json() + + @staticmethod + def _ontology_file_spec( + ontology_name: str, file_type: str, folder_key: Optional[str] = None + ) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint( + f"datafabric_/api/ontologies/{ontology_name}/files/{file_type}" + ), + headers=header_folder(folder_key, None), + ) diff --git a/packages/uipath-platform/tests/services/test_entities_service.py b/packages/uipath-platform/tests/services/test_entities_service.py index 2bef45193..8ea33b618 100644 --- a/packages/uipath-platform/tests/services/test_entities_service.py +++ b/packages/uipath-platform/tests/services/test_entities_service.py @@ -16,6 +16,7 @@ from uipath.platform.entities import ChoiceSetValue, DataFabricEntityItem, Entity from uipath.platform.entities._entities_service import EntitiesService from uipath.platform.entities._entity_data_service import EntityDataService +from uipath.platform.errors import EnrichedException @pytest.fixture @@ -2660,3 +2661,93 @@ def test_5xx_with_batch_shape_still_propagates( entity_key=str(entity_key), records=[{"name": "x"}], ) + + +class TestGetOntologyFileAsync: + """Tests for EntitiesService.get_ontology_file_async (delegates to + EntityOntologyService). The HTTP call goes through ``service._ontology``, + so the sub-service's ``request_async`` is what gets patched.""" + + @pytest.mark.anyio + async def test_builds_endpoint_and_folder_header( + self, service: EntitiesService + ) -> None: + response = MagicMock() + response.json.return_value = {"content": "OWL", "mediaType": "text/plain"} + service._ontology.request_async = AsyncMock(return_value=response) # type: ignore[method-assign] + + result = await service.get_ontology_file_async( + "library", "owl", folder_key="folder-1" + ) + + assert result == {"content": "OWL", "mediaType": "text/plain"} + service._ontology.request_async.assert_called_once() + call = service._ontology.request_async.call_args + method, endpoint = call.args[0], call.args[1] + headers = call.kwargs["headers"] + assert method == "GET" + assert str(endpoint) == "/datafabric_/api/ontologies/library/files/owl" + # Accept is added centrally by BaseService, not per-call. + assert headers["x-uipath-folderkey"] == "folder-1" + + @pytest.mark.anyio + async def test_no_folder_header_when_folder_key_none( + self, service: EntitiesService + ) -> None: + response = MagicMock() + response.json.return_value = {"content": "OWL", "mediaType": "text/plain"} + service._ontology.request_async = AsyncMock(return_value=response) # type: ignore[method-assign] + + await service.get_ontology_file_async("library") + + headers = service._ontology.request_async.call_args.kwargs["headers"] + assert "x-uipath-folderkey" not in headers + + @pytest.mark.anyio + @pytest.mark.parametrize( + "file_type", ["owl", "r2rml", "shacl", "summary", "context"] + ) + async def test_accepts_allowed_file_types( + self, service: EntitiesService, file_type: str + ) -> None: + response = MagicMock() + response.json.return_value = {"content": "x"} + service._ontology.request_async = AsyncMock(return_value=response) # type: ignore[method-assign] + + await service.get_ontology_file_async("library", file_type) + + endpoint = service._ontology.request_async.call_args.args[1] + assert str(endpoint) == f"/datafabric_/api/ontologies/library/files/{file_type}" + + @pytest.mark.anyio + async def test_rejects_unsupported_file_type( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """File-type validation is server-side (the ontology API), not in the + client. The SDK forwards the requested type and must surface the API's + rejection of an unsupported one as ``EnrichedException`` rather than + swallowing it.""" + api_message = "Unsupported ontology file type: exe" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/ontologies/library/files/exe", + method="GET", + status_code=400, + json={"message": api_message}, + ) + + with pytest.raises(EnrichedException) as exc_info: + await service.get_ontology_file_async("library", "exe") + + # The SDK surfaces the API's rejection verbatim — status code, response + # body, and the extracted message — rather than masking it. + exc = exc_info.value + assert exc.status_code == 400 + assert api_message in exc.response_content + assert exc.error_info is not None + assert exc.error_info.message == api_message diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index dceb4dbda..6ecc8cc13 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.0" +version = "0.2.1" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index ed676e5f6..7413e2515 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.13.1" +version = "2.13.2" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.26, <0.6.0", - "uipath-runtime>=0.12.1, <0.13.0", - "uipath-platform>=0.2.0, <0.3.0", + "uipath-runtime>=0.11.7, <0.13.0", + "uipath-platform>=0.2.1, <0.3.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 916417c94..033bb04ed 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -35,7 +35,7 @@ ) from uipath.eval.mocks import ExampleCall from uipath.platform.connections import Connection -from uipath.platform.entities import DataFabricEntityItem +from uipath.platform.entities import DataFabricEntityItem, DataFabricOntologyItem from uipath.platform.guardrails import ( BuiltInValidatorGuardrail, ) @@ -169,6 +169,7 @@ class AgentContextType(str, CaseInsensitiveEnum): INDEX = "index" ATTACHMENTS = "attachments" DATA_FABRIC_ENTITY_SET = "datafabricentityset" + DATA_FABRIC_ONTOLOGY = "datafabricontology" class AgentMessageRole(str, CaseInsensitiveEnum): @@ -440,6 +441,9 @@ class AgentContextResourceConfig(BaseAgentResourceConfig): None, description="Context settings" ) entity_set: Optional[List[DataFabricEntityItem]] = Field(None, alias="entitySet") + ontology_set: Optional[List[DataFabricOntologyItem]] = Field( + None, alias="ontologySet" + ) argument_properties: Dict[str, AgentToolArgumentProperties] = Field( {}, alias="argumentProperties" ) @@ -449,6 +453,11 @@ def is_datafabric(self) -> bool: """Check if this context is a Data Fabric entity set resource.""" return self.context_type == AgentContextType.DATA_FABRIC_ENTITY_SET + @property + def is_datafabric_ontology(self) -> bool: + """Check if this context is a Data Fabric ontology resource.""" + return self.context_type == AgentContextType.DATA_FABRIC_ONTOLOGY + @property def datafabric_entity_identifiers(self) -> list[str]: """Extract entity identifiers from entitySet.""" diff --git a/packages/uipath/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py index 202962c64..5d62149a9 100644 --- a/packages/uipath/tests/agent/models/test_agent.py +++ b/packages/uipath/tests/agent/models/test_agent.py @@ -3915,6 +3915,127 @@ def test_datafabric_context_config_parses(self): assert parsed.entity_set[1].entity_key == "orders-ref" assert parsed.entity_set[1].description is None + def test_ontology_context_parses(self): + """The ontology context (datafabricontology) holds an ontologySet array.""" + config = { + "$resourceType": "context", + "name": "Ontologies", + "description": "", + "contextType": "datafabricontology", + "ontologySet": [ + {"name": "library", "folderId": "f1"}, + {"name": "finance", "folderId": "f2"}, + ], + } + + parsed = AgentContextResourceConfig.model_validate(config) + + assert parsed.is_datafabric_ontology + assert not parsed.is_datafabric + assert parsed.ontology_set is not None + assert len(parsed.ontology_set) == 2 + assert parsed.ontology_set[0].name == "library" + assert parsed.ontology_set[0].folder_key == "f1" + assert parsed.ontology_set[1].name == "finance" + + def test_ontology_item_requires_folder_id(self): + """folderId is required on each ontology item.""" + config = { + "$resourceType": "context", + "name": "Ontologies", + "description": "", + "contextType": "datafabricontology", + "ontologySet": [{"name": "library"}], # missing folderId + } + + with pytest.raises(ValidationError): + AgentContextResourceConfig.model_validate(config) + + def test_ontology_context_dumps_by_alias(self): + """The ontology context round-trips back to aliased JSON keys.""" + parsed = AgentContextResourceConfig.model_validate( + { + "$resourceType": "context", + "name": "Ontologies", + "description": "", + "contextType": "datafabricontology", + "ontologySet": [{"name": "library", "folderId": "f1"}], + } + ) + dumped = parsed.model_dump(by_alias=True, exclude_none=True) + + assert dumped["contextType"] == "datafabricontology" + assert dumped["ontologySet"][0]["name"] == "library" + assert dumped["ontologySet"][0]["folderId"] == "f1" + + def test_entity_context_has_no_ontology_set(self): + """A plain entity context has no ontologySet and is not an ontology context.""" + config = { + "$resourceType": "context", + "name": "TestDataFabric", + "description": "", + "contextType": "datafabricentityset", + "entitySet": [{"id": "e1", "name": "Customers", "folderId": "f1"}], + } + + parsed = AgentContextResourceConfig.model_validate(config) + + assert parsed.is_datafabric + assert not parsed.is_datafabric_ontology + assert parsed.ontology_set is None + + def test_ontology_context_survives_full_definition_normalization(self): + """Regression: the datafabricontology context (with its ontologySet) + survives the full AgentDefinition normalizer and sits beside the entity + context, so the runtime can gather its ontologies to ground the DF query. + """ + json_data = { + "id": "test-ontology-def", + "name": "Agent with ontology context", + "version": "1.0.0", + "settings": { + "model": "gpt-4o-2024-11-20", + "maxTokens": 16384, + "temperature": 0, + "engine": "basic-v1", + }, + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": {"type": "object", "properties": {}}, + "resources": [ + { + "$resourceType": "context", + "contextType": "datafabricentityset", + "name": "Entities", + "description": "DF context", + "entitySet": [ + {"id": "e1", "name": "LibraryLoan", "folderId": "f1"} + ], + }, + { + "$resourceType": "context", + "contextType": "datafabricontology", + "name": "Ontologies", + "description": "", + "ontologySet": [{"name": "library", "folderId": "f1"}], + }, + ], + "messages": [{"role": "system", "content": "Test system message"}], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + ontology_ctxs = [ + r + for r in config.resources + if isinstance(r, AgentContextResourceConfig) and r.is_datafabric_ontology + ] + assert len(ontology_ctxs) == 1 + assert ontology_ctxs[0].ontology_set is not None + assert ontology_ctxs[0].ontology_set[0].name == "library" + assert ontology_ctxs[0].ontology_set[0].folder_key == "f1" + def test_is_datafabric(self): """Test is_datafabric property with datafabricentityset contextType.""" config = { diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index c236f7feb..e7cf75a96 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2598,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.1" +version = "2.13.2" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2674,7 +2674,7 @@ requires-dist = [ { name = "truststore", specifier = ">=0.10.1" }, { name = "uipath-core", editable = "../uipath-core" }, { name = "uipath-platform", editable = "../uipath-platform" }, - { name = "uipath-runtime", specifier = ">=0.12.1,<0.13.0" }, + { name = "uipath-runtime", specifier = ">=0.11.7,<0.13.0" }, ] [package.metadata.requires-dev] @@ -2741,7 +2741,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.0" +version = "0.2.1" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 44d557d0d72af2556cabe68f3f27cd50ebb5afb2 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Tue, 7 Jul 2026 13:26:19 +0300 Subject: [PATCH 151/191] feat: add timeout resume helpers (#1795) --- packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/triggers/__init__.py | 2 + .../src/uipath/core/triggers/trigger.py | 14 +++ .../tests/triggers/test_resume_metadata.py | 29 ++++++ packages/uipath-core/uv.lock | 2 +- packages/uipath-platform/pyproject.toml | 4 +- .../src/uipath/platform/common/__init__.py | 13 +++ .../src/uipath/platform/common/timeout.py | 56 +++++++++++ .../tests/common/test_timeout_helpers.py | 96 +++++++++++++++++++ packages/uipath-platform/uv.lock | 4 +- packages/uipath/pyproject.toml | 6 +- .../testcases/langchain-cross/pyproject.toml | 27 +++--- packages/uipath/uv.lock | 6 +- 13 files changed, 236 insertions(+), 25 deletions(-) create mode 100644 packages/uipath-core/tests/triggers/test_resume_metadata.py create mode 100644 packages/uipath-platform/src/uipath/platform/common/timeout.py create mode 100644 packages/uipath-platform/tests/common/test_timeout_helpers.py diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 185d84ab6..bcc05c8a3 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.28" +version = "0.5.29" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/triggers/__init__.py b/packages/uipath-core/src/uipath/core/triggers/__init__.py index d57929379..b60fc11a2 100644 --- a/packages/uipath-core/src/uipath/core/triggers/__init__.py +++ b/packages/uipath-core/src/uipath/core/triggers/__init__.py @@ -5,6 +5,7 @@ "UiPathResumeTriggerType", "UiPathApiTrigger", "UiPathIntegrationTrigger", + "UiPathResumeMetadata", "UiPathResumeTriggerName", "UIPATH_METADATA_KEY", ] @@ -13,6 +14,7 @@ UIPATH_METADATA_KEY, UiPathApiTrigger, UiPathIntegrationTrigger, + UiPathResumeMetadata, UiPathResumeTrigger, UiPathResumeTriggerName, UiPathResumeTriggerType, diff --git a/packages/uipath-core/src/uipath/core/triggers/trigger.py b/packages/uipath-core/src/uipath/core/triggers/trigger.py index 27680e6bb..1b8441e99 100644 --- a/packages/uipath-core/src/uipath/core/triggers/trigger.py +++ b/packages/uipath-core/src/uipath/core/triggers/trigger.py @@ -48,6 +48,20 @@ class UiPathResumeTriggerName(str, Enum): DEEP_RAG_RAW = "DeepRagRaw" +class UiPathResumeMetadata(BaseModel): + """UiPath metadata attached to resume values from multi-trigger interrupts.""" + + kind: str | None = None + trigger_type: UiPathResumeTriggerType | None = Field( + default=None, alias="triggerType" + ) + trigger_name: UiPathResumeTriggerName | None = Field( + default=None, alias="triggerName" + ) + + model_config = ConfigDict(validate_by_name=True) + + class UiPathApiTrigger(BaseModel): """API resume trigger request.""" diff --git a/packages/uipath-core/tests/triggers/test_resume_metadata.py b/packages/uipath-core/tests/triggers/test_resume_metadata.py new file mode 100644 index 000000000..044775265 --- /dev/null +++ b/packages/uipath-core/tests/triggers/test_resume_metadata.py @@ -0,0 +1,29 @@ +from uipath.core.triggers import ( + UiPathResumeMetadata, + UiPathResumeTriggerName, + UiPathResumeTriggerType, +) + + +def test_resume_metadata_accepts_trigger_aliases() -> None: + metadata = UiPathResumeMetadata.model_validate( + { + "kind": "timeout", + "triggerType": "Timer", + "triggerName": "Timer", + } + ) + + assert metadata.kind == "timeout" + assert metadata.trigger_type == UiPathResumeTriggerType.TIMER + assert metadata.trigger_name == UiPathResumeTriggerName.TIMER + + +def test_resume_metadata_accepts_field_names() -> None: + metadata = UiPathResumeMetadata( + trigger_type=UiPathResumeTriggerType.API, + trigger_name=UiPathResumeTriggerName.API, + ) + + assert metadata.trigger_type == UiPathResumeTriggerType.API + assert metadata.trigger_name == UiPathResumeTriggerName.API diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index a1322e25d..79a6c22cd 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -1011,7 +1011,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.28" +version = "0.5.29" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 90de5f8cc..09f17aa93 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.1" +version = "0.2.2" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" @@ -8,7 +8,7 @@ dependencies = [ "httpx>=0.28.1", "tenacity>=9.0.0", "truststore>=0.10.1", - "uipath-core>=0.5.28, <0.6.0", + "uipath-core>=0.5.29, <0.6.0", "pydantic-function-models>=0.1.11", "sqlparse>=0.5.5", ] diff --git a/packages/uipath-platform/src/uipath/platform/common/__init__.py b/packages/uipath-platform/src/uipath/platform/common/__init__.py index 8f4b23d57..14b8e2655 100644 --- a/packages/uipath-platform/src/uipath/platform/common/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/common/__init__.py @@ -3,6 +3,8 @@ This module contains common models used across multiple services. """ +from uipath.core.triggers import UiPathResumeMetadata + from ._api_client import ApiClient from ._base_service import BaseService, resolve_trace_id from ._bindings import ( @@ -62,6 +64,12 @@ WaitUntil, ) from .paging import PagedResult +from .timeout import ( + UiPathTimeoutError, + assert_no_timeout, + get_resume_metadata, + is_timeout, +) __all__ = [ "ApiClient", @@ -125,6 +133,11 @@ "resolve_service_url", "inject_routing_headers", "resolve_trace_id", + "UiPathTimeoutError", + "UiPathResumeMetadata", + "assert_no_timeout", + "get_resume_metadata", + "is_timeout", ] from .validation import validate_pagination_params diff --git a/packages/uipath-platform/src/uipath/platform/common/timeout.py b/packages/uipath-platform/src/uipath/platform/common/timeout.py new file mode 100644 index 000000000..2d27e986a --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/timeout.py @@ -0,0 +1,56 @@ +"""Helpers for resume values produced by timeout triggers.""" + +from collections.abc import Mapping +from typing import Any, TypeVar + +from pydantic import ValidationError +from uipath.core.triggers import ( + UIPATH_METADATA_KEY, + UiPathResumeMetadata, +) + +T = TypeVar("T") + +_TIMEOUT_KIND = "timeout" + + +class UiPathTimeoutError(TimeoutError): + """Raised when a resume value came from a UiPath timeout trigger.""" + + def __init__(self, value: Any): + """Create a timeout error that keeps the original resume value.""" + super().__init__("UiPath interrupt timed out.") + self.value = value + + +def is_timeout(value: Any) -> bool: + """Return True when a resume value came from a UiPath timeout trigger.""" + metadata = get_resume_metadata(value) + return metadata is not None and metadata.kind == _TIMEOUT_KIND + + +def assert_no_timeout(value: T) -> T: + """Raise UiPathTimeoutError if a resume value came from a timeout trigger.""" + if is_timeout(value): + raise UiPathTimeoutError(value) + return value + + +def get_resume_metadata(value: Any) -> UiPathResumeMetadata | None: + """Return UiPath resume metadata when present on a resume value.""" + metadata = _metadata(value) + if metadata is None: + return None + + try: + return UiPathResumeMetadata.model_validate(metadata) + except ValidationError: + return None + + +def _metadata(value: Any) -> Mapping[str, Any] | None: + if not isinstance(value, Mapping): + return None + + metadata = value.get(UIPATH_METADATA_KEY) + return metadata if isinstance(metadata, Mapping) else None diff --git a/packages/uipath-platform/tests/common/test_timeout_helpers.py b/packages/uipath-platform/tests/common/test_timeout_helpers.py new file mode 100644 index 000000000..1963f77d9 --- /dev/null +++ b/packages/uipath-platform/tests/common/test_timeout_helpers.py @@ -0,0 +1,96 @@ +import pytest +from uipath.core.triggers import ( + UIPATH_METADATA_KEY, + UiPathResumeTriggerName, + UiPathResumeTriggerType, +) + +from uipath.platform.common import ( + UiPathResumeMetadata, + UiPathTimeoutError, + assert_no_timeout, + get_resume_metadata, + is_timeout, +) + + +def test_is_timeout_detects_timeout_metadata() -> None: + value = { + UIPATH_METADATA_KEY: { + "kind": "timeout", + "triggerType": "Timer", + "triggerName": "Timer", + }, + "value": None, + } + + assert is_timeout(value) is True + + +def test_get_resume_metadata_returns_typed_metadata() -> None: + value = { + UIPATH_METADATA_KEY: { + "kind": "timeout", + "triggerType": "Timer", + "triggerName": "Timer", + }, + "resumeTime": "2026-07-07T12:00:00Z", + } + + metadata = get_resume_metadata(value) + + assert isinstance(metadata, UiPathResumeMetadata) + assert metadata.kind == "timeout" + assert metadata.trigger_type == UiPathResumeTriggerType.TIMER + assert metadata.trigger_name == UiPathResumeTriggerName.TIMER + + +def test_get_resume_metadata_returns_none_without_metadata() -> None: + assert get_resume_metadata({"result": "done"}) is None + assert get_resume_metadata("done") is None + + +def test_get_resume_metadata_returns_none_for_invalid_metadata() -> None: + value = { + UIPATH_METADATA_KEY: { + "triggerType": "NotARealTrigger", + "triggerName": "Timer", + }, + } + + assert get_resume_metadata(value) is None + + +def test_is_timeout_ignores_plain_timer_metadata() -> None: + value = { + UIPATH_METADATA_KEY: { + "triggerType": "Timer", + "triggerName": "Timer", + }, + "resumeTime": "2026-07-07T12:00:00Z", + } + + assert is_timeout(value) is False + + +def test_is_timeout_ignores_user_timed_out_fields() -> None: + assert is_timeout({"timedOut": True}) is False + assert is_timeout("timeout") is False + + +def test_assert_no_timeout_returns_original_value() -> None: + value = {"result": "done"} + + assert assert_no_timeout(value) is value + + +def test_assert_no_timeout_raises_with_resume_value() -> None: + value = { + UIPATH_METADATA_KEY: {"kind": "timeout"}, + "value": {"jobKey": "job-1"}, + } + + with pytest.raises(UiPathTimeoutError) as exc_info: + assert_no_timeout(value) + + assert exc_info.value.value is value diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 6ecc8cc13..1e7414565 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1063,7 +1063,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.28" +version = "0.5.29" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.1" +version = "0.2.2" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 7413e2515..20d154847 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.13.2" +version = "2.13.3" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath-core>=0.5.26, <0.6.0", + "uipath-core>=0.5.29, <0.6.0", "uipath-runtime>=0.11.7, <0.13.0", - "uipath-platform>=0.2.1, <0.3.0", + "uipath-platform>=0.2.2, <0.3.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/testcases/langchain-cross/pyproject.toml b/packages/uipath/testcases/langchain-cross/pyproject.toml index 0420b6c48..153ea57d8 100644 --- a/packages/uipath/testcases/langchain-cross/pyproject.toml +++ b/packages/uipath/testcases/langchain-cross/pyproject.toml @@ -3,18 +3,19 @@ name = "agent" version = "0.0.1" description = "agent" authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] -dependencies = [ - "uipath-langchain>=0.0.149", - "uipath" -] +dependencies = [ + "uipath-langchain>=0.14.0,<0.15.0", + "uipath" +] requires-python = ">=3.11" -[tool.uv.sources] -uipath = { path = "../../", editable = true } - -# Force the local uipath (the version under test) regardless of the upper bound -# the published uipath-langchain declares, so a backward-compatible minor bump -# does not break resolution purely on that cap. Mirrors the uv override used in -# the cross-repo test workflows. -[tool.uv] -override-dependencies = ["uipath"] \ No newline at end of file +[tool.uv.sources] +uipath = { path = "../../", editable = true } +uipath-core = { path = "../../../uipath-core", editable = true } +uipath-platform = { path = "../../../uipath-platform", editable = true } + +# Force local UiPath packages under test regardless of upper bounds declared by +# the published uipath-langchain package. Mirrors the uv override used in the +# cross-repo test workflows. +[tool.uv] +override-dependencies = ["uipath", "uipath-core", "uipath-platform"] diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index e7cf75a96..3e90c55d8 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2598,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.2" +version = "2.13.3" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2709,7 +2709,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.28" +version = "0.5.29" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, @@ -2741,7 +2741,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.1" +version = "0.2.2" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 8753504d0de9be663b6ac244a8074df67c4f16a9 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Tue, 7 Jul 2026 17:13:19 +0300 Subject: [PATCH 152/191] test: cover uipath server process startup (#1798) --- packages/uipath/tests/cli/test_server.py | 176 +++++++++++++++++++++-- 1 file changed, 162 insertions(+), 14 deletions(-) diff --git a/packages/uipath/tests/cli/test_server.py b/packages/uipath/tests/cli/test_server.py index 1d342fe03..7e069bd62 100644 --- a/packages/uipath/tests/cli/test_server.py +++ b/packages/uipath/tests/cli/test_server.py @@ -1,15 +1,37 @@ import asyncio import json import os +import subprocess +import sys import threading import time from typing import Any +from urllib.request import urlopen import aiohttp import pytest from uipath._cli.cli_server import start_tcp_server +SERVER_START_TIMEOUT_SECONDS = 20 +SERVER_STOP_TIMEOUT_SECONDS = 5 +JOB_START_TIMEOUT_SECONDS = 30 + + +@pytest.fixture +def simple_script() -> str: + return """ +from dataclasses import dataclass + +@dataclass +class Input: + message: str + repeat: int = 1 + +def main(input: Input) -> str: + return (input.message + " ") * input.repeat +""" + def create_uipath_json(script_path: str, entrypoint_name: str = "main"): """Helper to create uipath.json with functions.""" @@ -48,6 +70,60 @@ async def start_job_with_env( return await response.json() +def get_free_port() -> int: + """Use a random available port for testing.""" + import socket + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def read_process_output(process: subprocess.Popen[str]) -> str: + if process.stdout is None: + return "" + return process.stdout.read() + + +def stop_process(process: subprocess.Popen[str]) -> str: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=SERVER_STOP_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=SERVER_STOP_TIMEOUT_SECONDS) + return read_process_output(process) + + +def wait_for_server_health(port: int, process: subprocess.Popen[str]) -> None: + deadline = time.monotonic() + SERVER_START_TIMEOUT_SECONDS + last_error: Exception | None = None + + while time.monotonic() < deadline: + if process.poll() is not None: + output = read_process_output(process) + raise AssertionError( + f"uipath server exited before becoming healthy with code " + f"{process.returncode}\n\n{output}" + ) + + try: + with urlopen(f"http://127.0.0.1:{port}/health", timeout=1) as response: + if response.status == 200 and response.read().decode() == "OK": + return + except Exception as e: + last_error = e + + time.sleep(0.1) + + output = stop_process(process) + raise AssertionError( + f"uipath server did not become healthy within " + f"{SERVER_START_TIMEOUT_SECONDS}s. Last error: {last_error}\n\n{output}" + ) + + class TestServer: @pytest.fixture def server_port(self): @@ -78,20 +154,6 @@ def run_server(): yield server_port - @pytest.fixture - def simple_script(self) -> str: - return """ -from dataclasses import dataclass - -@dataclass -class Input: - message: str - repeat: int = 1 - -def main(input: Input) -> str: - return (input.message + " ") * input.repeat -""" - def test_start_job_success(self, server, temp_dir, simple_script): """Test starting a job through the server.""" port = server @@ -228,6 +290,92 @@ async def send_with_host(host: str): assert body == "OK" +class TestServerProcess: + """Tests for the real `uipath server` command startup path.""" + + @pytest.mark.skipif( + sys.platform == "win32", + reason="Windows GitHub runners fail before server startup while loading asyncio.", + ) + def test_uipath_server_process_starts_and_runs_job( + self, temp_dir: str, simple_script: str + ): + """The CLI command must preload modules, start the server, and run jobs.""" + port = get_free_port() + script_file = "entrypoint.py" + script_path = os.path.join(temp_dir, script_file) + input_file = os.path.join(temp_dir, "input.json") + output_file = os.path.join(temp_dir, "output.json") + + with open(script_path, "w") as f: + f.write(simple_script) + + with open(os.path.join(temp_dir, "uipath.json"), "w") as f: + json.dump(create_uipath_json(script_file), f) + + with open(input_file, "w") as f: + json.dump({"message": "Hello", "repeat": 2}, f) + + src_path = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "src") + ) + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join( + part for part in [src_path, env.get("PYTHONPATH")] if part + ) + + process = subprocess.Popen( + [ + sys.executable, + "-c", + "from uipath._cli import cli; cli()", + "server", + "--tcp", + "--port", + str(port), + ], + cwd=temp_dir, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + try: + wait_for_server_health(port, process) + + response = asyncio.run( + asyncio.wait_for( + start_job( + port, + "process-job-123", + "run", + [ + "main", + "--input-file", + input_file, + "--output-file", + output_file, + ], + ), + timeout=JOB_START_TIMEOUT_SECONDS, + ) + ) + + assert response["success"] is True + assert response["job_key"] == "process-job-123" + assert process.poll() is None + assert os.path.exists(output_file) + + with open(output_file, "r") as f: + output = f.read() + assert "Hello" in output + finally: + output = stop_process(process) + + assert "Traceback" not in output + + class TestServerEnvIsolation: """Test that environment variables are isolated between sequential server requests.""" From bf152bbe95100babf2a528fa55e27c7980df0435 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Tue, 7 Jul 2026 17:38:10 +0300 Subject: [PATCH 153/191] fix: remove timeout kind metadata (#1797) --- packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/triggers/trigger.py | 1 - .../tests/triggers/test_resume_metadata.py | 2 -- packages/uipath-core/uv.lock | 2 +- packages/uipath-platform/pyproject.toml | 4 ++-- .../src/uipath/platform/common/timeout.py | 7 ++++--- .../tests/common/test_timeout_helpers.py | 16 ++++++++-------- packages/uipath-platform/uv.lock | 4 ++-- packages/uipath/pyproject.toml | 2 +- packages/uipath/uv.lock | 4 ++-- 10 files changed, 21 insertions(+), 23 deletions(-) diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index bcc05c8a3..fdc902287 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.29" +version = "0.5.30" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/triggers/trigger.py b/packages/uipath-core/src/uipath/core/triggers/trigger.py index 1b8441e99..508b4ee47 100644 --- a/packages/uipath-core/src/uipath/core/triggers/trigger.py +++ b/packages/uipath-core/src/uipath/core/triggers/trigger.py @@ -51,7 +51,6 @@ class UiPathResumeTriggerName(str, Enum): class UiPathResumeMetadata(BaseModel): """UiPath metadata attached to resume values from multi-trigger interrupts.""" - kind: str | None = None trigger_type: UiPathResumeTriggerType | None = Field( default=None, alias="triggerType" ) diff --git a/packages/uipath-core/tests/triggers/test_resume_metadata.py b/packages/uipath-core/tests/triggers/test_resume_metadata.py index 044775265..99418fd1b 100644 --- a/packages/uipath-core/tests/triggers/test_resume_metadata.py +++ b/packages/uipath-core/tests/triggers/test_resume_metadata.py @@ -8,13 +8,11 @@ def test_resume_metadata_accepts_trigger_aliases() -> None: metadata = UiPathResumeMetadata.model_validate( { - "kind": "timeout", "triggerType": "Timer", "triggerName": "Timer", } ) - assert metadata.kind == "timeout" assert metadata.trigger_type == UiPathResumeTriggerType.TIMER assert metadata.trigger_name == UiPathResumeTriggerName.TIMER diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index 79a6c22cd..66a9c4ebe 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -1011,7 +1011,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.29" +version = "0.5.30" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 09f17aa93..175a3cf2f 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.2" +version = "0.2.3" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" @@ -8,7 +8,7 @@ dependencies = [ "httpx>=0.28.1", "tenacity>=9.0.0", "truststore>=0.10.1", - "uipath-core>=0.5.29, <0.6.0", + "uipath-core>=0.5.30, <0.6.0", "pydantic-function-models>=0.1.11", "sqlparse>=0.5.5", ] diff --git a/packages/uipath-platform/src/uipath/platform/common/timeout.py b/packages/uipath-platform/src/uipath/platform/common/timeout.py index 2d27e986a..20b79873c 100644 --- a/packages/uipath-platform/src/uipath/platform/common/timeout.py +++ b/packages/uipath-platform/src/uipath/platform/common/timeout.py @@ -7,12 +7,11 @@ from uipath.core.triggers import ( UIPATH_METADATA_KEY, UiPathResumeMetadata, + UiPathResumeTriggerType, ) T = TypeVar("T") -_TIMEOUT_KIND = "timeout" - class UiPathTimeoutError(TimeoutError): """Raised when a resume value came from a UiPath timeout trigger.""" @@ -26,7 +25,9 @@ def __init__(self, value: Any): def is_timeout(value: Any) -> bool: """Return True when a resume value came from a UiPath timeout trigger.""" metadata = get_resume_metadata(value) - return metadata is not None and metadata.kind == _TIMEOUT_KIND + return ( + metadata is not None and metadata.trigger_type == UiPathResumeTriggerType.TIMER + ) def assert_no_timeout(value: T) -> T: diff --git a/packages/uipath-platform/tests/common/test_timeout_helpers.py b/packages/uipath-platform/tests/common/test_timeout_helpers.py index 1963f77d9..a29f1524d 100644 --- a/packages/uipath-platform/tests/common/test_timeout_helpers.py +++ b/packages/uipath-platform/tests/common/test_timeout_helpers.py @@ -17,7 +17,6 @@ def test_is_timeout_detects_timeout_metadata() -> None: value = { UIPATH_METADATA_KEY: { - "kind": "timeout", "triggerType": "Timer", "triggerName": "Timer", }, @@ -30,7 +29,6 @@ def test_is_timeout_detects_timeout_metadata() -> None: def test_get_resume_metadata_returns_typed_metadata() -> None: value = { UIPATH_METADATA_KEY: { - "kind": "timeout", "triggerType": "Timer", "triggerName": "Timer", }, @@ -40,7 +38,6 @@ def test_get_resume_metadata_returns_typed_metadata() -> None: metadata = get_resume_metadata(value) assert isinstance(metadata, UiPathResumeMetadata) - assert metadata.kind == "timeout" assert metadata.trigger_type == UiPathResumeTriggerType.TIMER assert metadata.trigger_name == UiPathResumeTriggerName.TIMER @@ -61,13 +58,13 @@ def test_get_resume_metadata_returns_none_for_invalid_metadata() -> None: assert get_resume_metadata(value) is None -def test_is_timeout_ignores_plain_timer_metadata() -> None: +def test_is_timeout_ignores_non_timer_metadata() -> None: value = { UIPATH_METADATA_KEY: { - "triggerType": "Timer", - "triggerName": "Timer", + "triggerType": "Job", + "triggerName": "Job", }, - "resumeTime": "2026-07-07T12:00:00Z", + "value": {"jobKey": "job-1"}, } assert is_timeout(value) is False @@ -86,7 +83,10 @@ def test_assert_no_timeout_returns_original_value() -> None: def test_assert_no_timeout_raises_with_resume_value() -> None: value = { - UIPATH_METADATA_KEY: {"kind": "timeout"}, + UIPATH_METADATA_KEY: { + "triggerType": "Timer", + "triggerName": "Timer", + }, "value": {"jobKey": "job-1"}, } diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 1e7414565..d5ed8d8d8 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1063,7 +1063,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.29" +version = "0.5.30" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.2" +version = "0.2.3" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 20d154847..993529ae0 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -5,7 +5,7 @@ description = "Python SDK and CLI for UiPath Platform, enabling programmatic int readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath-core>=0.5.29, <0.6.0", + "uipath-core>=0.5.30, <0.6.0", "uipath-runtime>=0.11.7, <0.13.0", "uipath-platform>=0.2.2, <0.3.0", "click>=8.3.1", diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 3e90c55d8..a1ce78186 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2709,7 +2709,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.29" +version = "0.5.30" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, @@ -2741,7 +2741,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.2" +version = "0.2.3" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From d232e0910466cbf5c6784c894176c26d8239381e Mon Sep 17 00:00:00 2001 From: Vlad Cimpeanu Date: Tue, 7 Jul 2026 18:01:53 +0300 Subject: [PATCH 154/191] feat(cli): init enhancements (#1796) Co-authored-by: Claude Fable 5 --- packages/uipath/pyproject.toml | 4 +- packages/uipath/src/uipath/_cli/cli_init.py | 23 +++++ .../uipath/_cli/models/uipath_json_schema.py | 7 ++ packages/uipath/tests/cli/test_init.py | 89 +++++++++++++++++++ packages/uipath/uv.lock | 10 +-- 5 files changed, 126 insertions(+), 7 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 993529ae0..bfb1a3626 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "uipath" -version = "2.13.3" +version = "2.13.4" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.30, <0.6.0", - "uipath-runtime>=0.11.7, <0.13.0", + "uipath-runtime>=0.12.2, <0.13.0", "uipath-platform>=0.2.2, <0.3.0", "click>=8.3.1", "httpx>=0.28.1", diff --git a/packages/uipath/src/uipath/_cli/cli_init.py b/packages/uipath/src/uipath/_cli/cli_init.py index 9899df7dd..d6ff01a90 100644 --- a/packages/uipath/src/uipath/_cli/cli_init.py +++ b/packages/uipath/src/uipath/_cli/cli_init.py @@ -158,6 +158,27 @@ def write_bindings_file(bindings: Bindings) -> Path: return bindings_file_path +def mark_transaction_root_entrypoints( + entry_point_schemas: list[UiPathRuntimeSchema], +) -> None: + """Stamp entrypoints with 'isTransactionRoot: true' for vertical-solution projects. + + Reads 'runtimeOptions._uipathVerticalSolution' from uipath.json; when true, + marks every entrypoint schema in-place so the flag serializes into + entry-points.json. Otherwise leaves the schemas untouched, so the flag is + dropped on regeneration. + + Args: + entry_point_schemas: The discovered entrypoint schemas. + """ + config = UiPathJsonConfig.load_from_file(str(UiPathConfig.config_file_path)) + if config.runtime_options.uipath_vertical_solution is not True: + return + + for schema in entry_point_schemas: + schema.is_transaction_root = True + + def write_entry_points_file(entry_points: list[UiPathRuntimeSchema]) -> Path: """Write entrypoints to a JSON file. @@ -473,6 +494,8 @@ async def initialize() -> list[UiPathRuntimeSchema]: finally: await factory.dispose() + mark_transaction_root_entrypoints(entry_point_schemas) + # Write entry-points.json with all schemas entry_points_path = write_entry_points_file(entry_point_schemas) console.success( diff --git a/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py b/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py index a47b82522..ca5818b86 100644 --- a/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py +++ b/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py @@ -26,6 +26,13 @@ class RuntimeOptions(BaseModelWithDefaultConfig): alias="isConversational", description="Enable conversational mode for the runtime", ) + uipath_vertical_solution: bool | None = Field( + default=None, + alias="_uipathVerticalSolution", + description="Marks the project as part of a UiPath vertical solution. " + "When true, 'uipath init' stamps 'isTransactionRoot: true' on every " + "entrypoint in entry-points.json.", + ) class DesignOptions(BaseModelWithDefaultConfig): diff --git a/packages/uipath/tests/cli/test_init.py b/packages/uipath/tests/cli/test_init.py index afa5d15fa..b38725124 100644 --- a/packages/uipath/tests/cli/test_init.py +++ b/packages/uipath/tests/cli/test_init.py @@ -1,6 +1,7 @@ import json import os import uuid +from typing import Any from unittest.mock import patch import pytest @@ -714,6 +715,94 @@ def test_init_mixed_entrypoints_warns( "We recommend using a single type for all entrypoints" in result.output ) + @pytest.mark.parametrize("entrypoint_kind", ["functions", "agents"]) + def test_init_vertical_solution_stamps_transaction_root( + self, runner: CliRunner, temp_dir: str, entrypoint_kind: str + ) -> None: + """'_uipathVerticalSolution: true' stamps isTransactionRoot on entrypoints.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("main.py", "w") as f: + f.write("def main(input: str) -> str: return input") + with open("uipath.json", "w") as f: + json.dump( + { + "runtimeOptions": {"_uipathVerticalSolution": True}, + entrypoint_kind: {"main": "main.py:main"}, + }, + f, + ) + self._generate_pyproject() + + result = runner.invoke(cli, ["init"], env={}) + assert result.exit_code == 0 + + with open("entry-points.json", "r") as f: + entry_points = json.load(f)["entryPoints"] + assert len(entry_points) == 1 + assert entry_points[0]["isTransactionRoot"] is True + + @pytest.mark.parametrize( + "runtime_options", + [None, {}, {"_uipathVerticalSolution": False}], + ) + def test_init_without_vertical_solution_omits_transaction_root( + self, runner: CliRunner, temp_dir: str, runtime_options: dict[str, Any] | None + ) -> None: + """Entrypoints are not stamped when the flag is absent or false.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("main.py", "w") as f: + f.write("def main(input: str) -> str: return input") + uipath_config: dict[str, Any] = {"functions": {"main": "main.py:main"}} + if runtime_options is not None: + uipath_config["runtimeOptions"] = runtime_options + with open("uipath.json", "w") as f: + json.dump(uipath_config, f) + self._generate_pyproject() + + result = runner.invoke(cli, ["init"], env={}) + assert result.exit_code == 0 + + with open("entry-points.json", "r") as f: + entry_points = json.load(f)["entryPoints"] + assert "isTransactionRoot" not in entry_points[0] + + def test_init_rerun_syncs_transaction_root_with_vertical_solution_flag( + self, runner: CliRunner, temp_dir: str + ) -> None: + """Rerunning init keeps or removes isTransactionRoot to match the flag.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("main.py", "w") as f: + f.write("def main(input: str) -> str: return input") + with open("uipath.json", "w") as f: + json.dump( + { + "runtimeOptions": {"_uipathVerticalSolution": True}, + "functions": {"main": "main.py:main"}, + }, + f, + ) + self._generate_pyproject() + + assert runner.invoke(cli, ["init"], env={}).exit_code == 0 + with open("entry-points.json", "r") as f: + assert json.load(f)["entryPoints"][0]["isTransactionRoot"] is True + + # Rerun with the flag still present: the stamp is kept. + assert runner.invoke(cli, ["init"], env={}).exit_code == 0 + with open("entry-points.json", "r") as f: + assert json.load(f)["entryPoints"][0]["isTransactionRoot"] is True + + # Remove the flag (keep the backfilled id and functions untouched). + with open("uipath.json", "r") as f: + uipath_config = json.load(f) + del uipath_config["runtimeOptions"]["_uipathVerticalSolution"] + with open("uipath.json", "w") as f: + json.dump(uipath_config, f) + + assert runner.invoke(cli, ["init"], env={}).exit_code == 0 + with open("entry-points.json", "r") as f: + assert "isTransactionRoot" not in json.load(f)["entryPoints"][0] + def test_init_creates_studio_metadata_file( self, runner: CliRunner, temp_dir: str ) -> None: diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index a1ce78186..e90c2794a 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2598,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.3" +version = "2.13.4" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2674,7 +2674,7 @@ requires-dist = [ { name = "truststore", specifier = ">=0.10.1" }, { name = "uipath-core", editable = "../uipath-core" }, { name = "uipath-platform", editable = "../uipath-platform" }, - { name = "uipath-runtime", specifier = ">=0.11.7,<0.13.0" }, + { name = "uipath-runtime", specifier = ">=0.12.2,<0.13.0" }, ] [package.metadata.requires-dev] @@ -2779,16 +2779,16 @@ dev = [ [[package]] name = "uipath-runtime" -version = "0.12.1" +version = "0.12.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "chardet" }, { name = "uipath-core" }, { name = "vadersentiment" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/0b/6d6bfd912c2ad6978f58646becf3647430ccb087008a8c78dbc33b0b101e/uipath_runtime-0.12.1.tar.gz", hash = "sha256:a63ce739ea53da8479bb0314477f89d42724045ecbd7db3f2a3f0a09ab17a755", size = 235372, upload-time = "2026-07-06T11:14:51.947Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/eb/1638b8307c9305527a449664c95a03a2af1bfaf1bd150819fb882ef71415/uipath_runtime-0.12.2.tar.gz", hash = "sha256:0d1f56f41add0d932bbe0c975d473ec27a86c58e14e9aa745d6501356c51284a", size = 235789, upload-time = "2026-07-07T11:02:12.599Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/4c/433de72c4c5869ee26a63f68361b5c62d04bb03e9c7db9a69c0d3db7b458/uipath_runtime-0.12.1-py3-none-any.whl", hash = "sha256:88b014e0c5a50a5b41b2e532bd628eebc91d70ad42b6d3389fdc61b7ae78e0a6", size = 92004, upload-time = "2026-07-06T11:14:50.363Z" }, + { url = "https://files.pythonhosted.org/packages/09/7a/9042e41a71726386d6f7673f843decd5405daff0787bfe127a8ac15abaa4/uipath_runtime-0.12.2-py3-none-any.whl", hash = "sha256:8faa88ac0af208b48f08abfff11530ae0279f1e88a12e9d2935f7f74111ebde1", size = 92087, upload-time = "2026-07-07T11:02:11.221Z" }, ] [[package]] From 1f3d65523bc701a18842f349d17e9ab43e228fc2 Mon Sep 17 00:00:00 2001 From: Jesus Date: Tue, 7 Jul 2026 11:12:15 -0600 Subject: [PATCH 155/191] feat: add ReferenceContext for span reference hierarchy propagation (#1708) Co-authored-by: Claude Sonnet 4.6 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/common/__init__.py | 10 + .../platform/common/_reference_context.py | 264 ++++++++ .../src/uipath/platform/common/_span_utils.py | 44 +- .../tests/services/test_reference_context.py | 583 ++++++++++++++++++ .../tests/services/test_span_utils.py | 108 +++- packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 4 +- .../uipath/src/uipath/_cli/_utils/_tracing.py | 9 + packages/uipath/src/uipath/_cli/cli_debug.py | 4 +- packages/uipath/src/uipath/_cli/cli_dev.py | 5 +- packages/uipath/src/uipath/_cli/cli_eval.py | 4 +- packages/uipath/src/uipath/_cli/cli_run.py | 4 +- .../uipath/src/uipath/tracing/__init__.py | 8 + packages/uipath/uv.lock | 4 +- 15 files changed, 1039 insertions(+), 16 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/common/_reference_context.py create mode 100644 packages/uipath-platform/tests/services/test_reference_context.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 175a3cf2f..d2a0f4efc 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.3" +version = "0.2.4" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/common/__init__.py b/packages/uipath-platform/src/uipath/platform/common/__init__.py index 14b8e2655..802ec67bc 100644 --- a/packages/uipath-platform/src/uipath/platform/common/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/common/__init__.py @@ -22,9 +22,15 @@ from ._folder_context import FolderContext, header_folder from ._http_config import get_ca_bundle_path, get_httpx_client_kwargs from ._models import Endpoint, RequestSpec +from ._reference_context import ( + ReferenceContext, + ReferenceContextAccessor, + ReferenceEntry, +) from ._service_url_overrides import inject_routing_headers, resolve_service_url from ._span_utils import ( ExecutionType, + ReferenceHierarchySpanProcessor, SpanSource, SpanStatus, UiPathSpan, @@ -125,6 +131,10 @@ "ResourceOverwrite", "ResourceOverwriteParser", "ResourceOverwritesContext", + "ReferenceEntry", + "ReferenceContext", + "ReferenceContextAccessor", + "ReferenceHierarchySpanProcessor", "SpanSource", "SpanStatus", "UiPathSpan", diff --git a/packages/uipath-platform/src/uipath/platform/common/_reference_context.py b/packages/uipath-platform/src/uipath/platform/common/_reference_context.py new file mode 100644 index 000000000..648d25e69 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/_reference_context.py @@ -0,0 +1,264 @@ +"""Immutable reference-hierarchy context for span propagation. + +Follows the same design as service-common BaggageContext: +- Immutable, copy-on-write — each mutating call returns a NEW instance so + sibling spans cannot bleed context into each other. +- ContextVar-backed accessor — flows across await boundaries without + threading the value through every function signature. +- Wire format compatible with the ``ref.*`` keys in ``x-uipath-tracebaggage`` + so context parsed by service-common middleware is understood here and + vice-versa. +""" + +from __future__ import annotations + +import contextvars +import uuid +from dataclasses import dataclass +from typing import ClassVar, Dict, Iterator, List, Optional, Tuple + +__all__ = [ + "ReferenceEntry", + "ReferenceContext", + "ReferenceContextAccessor", + "BAGGAGE_HEADER_NAME", + "BAGGAGE_KEY_TYPE", + "BAGGAGE_KEY_ID", + "BAGGAGE_KEY_VERSION", +] + +BAGGAGE_HEADER_NAME = "x-uipath-tracebaggage" + +# Key names — matches service-common ReferenceHierarchyKeys +BAGGAGE_KEY_TYPE = "ref.type" +BAGGAGE_KEY_ID = "ref.id" +BAGGAGE_KEY_VERSION = "ref.v" + + +@dataclass(frozen=True) +class ReferenceEntry: + """A single node in the reference hierarchy call chain.""" + + service_type: str + reference_id: str # UUID string + version: Optional[str] = None + + +class ReferenceContext: + """Immutable, copy-on-write ordered list of reference entries. + + Outermost caller first, current service appended last. + Each mutating call returns a new instance — the original is never + modified, preventing sibling spans from sharing context. + + Usage:: + + ctx = ReferenceContext.Empty + ctx = ctx.add("maestro", process_id, "2.1.0") + ctx = ctx.add("agent", agent_id) + token = ReferenceContextAccessor.set(ctx) + try: + ... + finally: + ReferenceContextAccessor.reset(token) + """ + + Empty: ClassVar["ReferenceContext"] + __slots__ = ("_entries",) + + def __init__(self, entries: Tuple[ReferenceEntry, ...] = ()) -> None: + self._entries: Tuple[ReferenceEntry, ...] = entries + + @property + def entries(self) -> Tuple[ReferenceEntry, ...]: + return self._entries + + def __len__(self) -> int: + return len(self._entries) + + def __iter__(self) -> Iterator[ReferenceEntry]: + return iter(self._entries) + + def __bool__(self) -> bool: + return len(self._entries) > 0 + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ReferenceContext): + return NotImplemented + return self._entries == other._entries + + def __hash__(self) -> int: + return hash(self._entries) + + def add( + self, + service_type: str, + reference_id: str | uuid.UUID, + version: Optional[str] = None, + ) -> "ReferenceContext": + """Returns a new context with this entry appended (copy-on-write). + + Args: + service_type: Identifier for the calling service (e.g. ``"agent"``, + ``"maestro"``). + reference_id: UUID of the referenced entity (UUID object or string). + version: Optional version string. + + Returns: + A new :class:`ReferenceContext` with the entry appended. + """ + if not service_type or not service_type.strip(): + raise ValueError("service_type must be a non-empty string.") + if isinstance(reference_id, uuid.UUID): + id_str = str(reference_id) + elif isinstance(reference_id, str): + try: + id_str = str(uuid.UUID(reference_id)) + except ValueError as exc: + raise ValueError( + f"reference_id {reference_id!r} is not a valid UUID." + ) from exc + else: + raise TypeError("reference_id must be a UUID or string.") + entry = ReferenceEntry( + service_type=service_type, + reference_id=id_str, + version=version if version and version.strip() else None, + ) + return ReferenceContext(self._entries + (entry,)) + + def to_wire_list(self) -> List[Dict[str, str]]: + """Serialize to the ``referenceHierarchy`` wire format. + + Returns: + A list of dicts suitable for JSON serialization as + ``Context.referenceHierarchy`` in the span payload. + """ + result: List[Dict[str, str]] = [] + for e in self._entries: + item: Dict[str, str] = { + "serviceType": e.service_type, + "referenceId": e.reference_id, + } + if e.version: + item["version"] = e.version + result.append(item) + return result + + @staticmethod + def from_baggage_header(header_value: Optional[str]) -> "ReferenceContext": + """Parse ``x-uipath-tracebaggage`` header value into a ReferenceContext. + + Only entries that carry the ``ref.*`` shape (type + valid UUID id) are + included. Malformed or plain-KV entries are silently skipped so a bad + header from an upstream service cannot crash this one. + + Args: + header_value: Raw header string, e.g. + ``"ref.type=agent;ref.id=;ref.v=1.0,ref.type=maestro;ref.id="`` + + Returns: + Parsed :class:`ReferenceContext`, or :attr:`ReferenceContext.Empty` + if the header is absent, empty, or contains no valid ref entries. + """ + if not header_value or not header_value.strip(): + return ReferenceContext.Empty + + entries: List[ReferenceEntry] = [] + for raw_entry in header_value.split(","): + entry_text = raw_entry.strip() + if not entry_text: + continue + props: dict[str, str] = {} + for raw_pair in entry_text.split(";"): + pair_text = raw_pair.strip() + eq = pair_text.find("=") + if eq <= 0 or eq >= len(pair_text) - 1: + continue + key = pair_text[:eq].strip() + value = pair_text[eq + 1 :].strip() + if key and value: + props[key] = value + + type_v = props.get(BAGGAGE_KEY_TYPE) + id_v = props.get(BAGGAGE_KEY_ID) + if not type_v or not id_v: + continue + try: + parsed_uuid = uuid.UUID(id_v) + except (ValueError, AttributeError): + continue + entries.append( + ReferenceEntry( + service_type=type_v, + reference_id=str(parsed_uuid), + version=props.get(BAGGAGE_KEY_VERSION) or None, + ) + ) + + if not entries: + return ReferenceContext.Empty + return ReferenceContext(tuple(entries)) + + def to_baggage_header_value(self) -> str: + """Serialize to ``x-uipath-tracebaggage`` header value. + + Returns: + Comma-separated entries; each is a semicolon-separated list of + ``key=value`` pairs. Empty context returns ``""``. + """ + if not self._entries: + return "" + parts: List[str] = [] + for e in self._entries: + kv = ( + f"{BAGGAGE_KEY_TYPE}={e.service_type};{BAGGAGE_KEY_ID}={e.reference_id}" + ) + if e.version: + kv += f";{BAGGAGE_KEY_VERSION}={e.version}" + parts.append(kv) + return ",".join(parts) + + +# Assigned after class body so ReferenceContext is fully bound. +ReferenceContext.Empty = ReferenceContext() + + +class ReferenceContextAccessor: + """Ambient accessor for the current :class:`ReferenceContext`. + + Backed by :mod:`contextvars` so the value propagates across ``await`` + boundaries without being threaded through every call signature. + + Usage:: + + token = ReferenceContextAccessor.set(ctx) + try: + ... # code here sees ReferenceContextAccessor.get() == ctx + finally: + ReferenceContextAccessor.reset(token) + """ + + _current: contextvars.ContextVar[Optional[ReferenceContext]] = ( + contextvars.ContextVar("uipath_reference_context", default=None) + ) + + @classmethod + def get(cls) -> Optional[ReferenceContext]: + """Return the current ambient context, or ``None`` if not set.""" + return cls._current.get() + + @classmethod + def set( + cls, value: Optional[ReferenceContext] + ) -> contextvars.Token[Optional[ReferenceContext]]: + """Set the ambient context. Returns a token for restoration. + + Pass the token to :meth:`reset` in a ``finally`` block. + """ + return cls._current.set(value) + + @classmethod + def reset(cls, token: contextvars.Token[Optional[ReferenceContext]]) -> None: + """Restore the ambient context to its prior value.""" + cls._current.reset(token) diff --git a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py index 3d23bfa5a..cd0d9d709 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -10,7 +10,8 @@ from os import environ as env from typing import Any, Dict, List, Optional, TypeVar -from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry import context as context_api +from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor from opentelemetry.trace import StatusCode from pydantic import BaseModel, ConfigDict, Field from uipath.core.serialization import serialize_json @@ -26,6 +27,31 @@ ENV_UIPATH_TRACE_ID, ) +from ._reference_context import ReferenceContextAccessor + + +def _inject_reference_hierarchy(span: Span) -> None: + ref_ctx = ReferenceContextAccessor.get() + if ref_ctx: + wire = ref_ctx.to_wire_list() + if wire: + span.set_attribute("uipath.reference_hierarchy", json.dumps(wire)) + + +class ReferenceHierarchySpanProcessor(SpanProcessor): + """Stamps uipath.reference_hierarchy on every span at creation time. + + Runs on_start in the span-creating thread so ContextVar values are live. + Register this before any processor that reads the attribute on on_start + (e.g. LiveTrackingSpanProcessor). + """ + + def on_start( + self, span: Span, parent_context: Optional[context_api.Context] = None + ) -> None: + _inject_reference_hierarchy(span) + + logger = logging.getLogger(__name__) @@ -245,6 +271,7 @@ class UiPathSpan: agent_version: Optional[str] = None verbosity_level: Optional[VerbosityLevel] = None attachments: Optional[List[SpanAttachment]] = None + context: Optional[Dict[str, Any]] = None def to_dict(self, serialize_attributes: bool = True) -> Dict[str, Any]: """Convert the Span to a dictionary suitable for JSON serialization. @@ -304,6 +331,8 @@ def to_dict(self, serialize_attributes: bool = True) -> Dict[str, Any]: del result[guid_key] if self.verbosity_level is not None: result["VerbosityLevel"] = self.verbosity_level + if self.context is not None: + result["Context"] = self.context return result @@ -380,6 +409,11 @@ def otel_span_to_uipath_span( # Only copy if we need to modify - we'll build attributes_dict lazily attributes_dict: dict[str, Any] = dict(otel_attrs) if otel_attrs else {} + # Pull the reference hierarchy stamped by the span-start hook (runs in the + # correct thread/context; BatchSpanProcessor exports in a background thread + # where ContextVar values are not available). + ref_hierarchy_json = attributes_dict.pop("uipath.reference_hierarchy", None) + # Map status status = SpanStatus.OK if otel_span.status.status_code == StatusCode.ERROR: @@ -496,6 +530,13 @@ def otel_span_to_uipath_span( except Exception as e: logger.warning(f"Error processing attachments: {e}") + context: Optional[Dict[str, Any]] = None + if ref_hierarchy_json: + try: + context = {"referenceHierarchy": json.loads(ref_hierarchy_json)} + except (json.JSONDecodeError, TypeError): + pass + # Create UiPathSpan from OpenTelemetry span start_time = datetime.fromtimestamp( (otel_span.start_time or 0) / 1e9 @@ -527,6 +568,7 @@ def otel_span_to_uipath_span( reference_id=reference_id, source=source, attachments=attachments, + context=context, ) @staticmethod diff --git a/packages/uipath-platform/tests/services/test_reference_context.py b/packages/uipath-platform/tests/services/test_reference_context.py new file mode 100644 index 000000000..38be5aebf --- /dev/null +++ b/packages/uipath-platform/tests/services/test_reference_context.py @@ -0,0 +1,583 @@ +"""Tests for ReferenceContext, ReferenceContextAccessor, and span Context wiring.""" + +import json +from datetime import datetime +from unittest.mock import Mock + +import pytest +from opentelemetry.sdk.trace import Span as OTelSpan +from opentelemetry.trace import SpanContext, StatusCode + +from uipath.platform.common import _SpanUtils +from uipath.platform.common._reference_context import ( + ReferenceContext, + ReferenceContextAccessor, + ReferenceEntry, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_mock_span(attributes: dict[str, str] | None = None) -> Mock: + mock = Mock(spec=OTelSpan) + mock.get_span_context.return_value = SpanContext( + trace_id=0x123456789ABCDEF0123456789ABCDEF0, + span_id=0x0123456789ABCDEF, + is_remote=False, + ) + mock.name = "test-span" + mock.parent = None + mock.status.status_code = StatusCode.OK + mock.attributes = attributes or {} + mock.events = [] + mock.links = [] + now_ns = int(datetime.now().timestamp() * 1e9) + mock.start_time = now_ns + mock.end_time = now_ns + 1_000_000 + return mock + + +# --------------------------------------------------------------------------- +# ReferenceContext — immutability & copy-on-write +# --------------------------------------------------------------------------- + + +class TestReferenceContextImmutability: + def test_empty_singleton_is_falsy(self) -> None: + assert not ReferenceContext.Empty + + def test_add_returns_new_instance(self) -> None: + base = ReferenceContext.Empty + child = base.add("agent", "550e8400-e29b-41d4-a716-446655440001") + assert child is not base + + def test_original_unmodified_after_add(self) -> None: + base = ReferenceContext.Empty + base.add("agent", "550e8400-e29b-41d4-a716-446655440001") + assert len(base) == 0 + + def test_siblings_do_not_share_entries(self) -> None: + base = ReferenceContext.Empty.add( + "maestro", "550e8400-e29b-41d4-a716-446655440010", "2.0" + ) + child_a = base.add("agent", "550e8400-e29b-41d4-a716-446655440011") + child_b = base.add("agent", "550e8400-e29b-41d4-a716-446655440012") + + assert len(base) == 1 + assert len(child_a) == 2 + assert len(child_b) == 2 + assert child_a.entries[1].reference_id != child_b.entries[1].reference_id + + def test_equality_and_hash(self) -> None: + a = ReferenceContext.Empty.add("agent", "550e8400-e29b-41d4-a716-446655440001") + b = ReferenceContext.Empty.add("agent", "550e8400-e29b-41d4-a716-446655440001") + assert a == b + assert hash(a) == hash(b) + + def test_different_entries_not_equal(self) -> None: + a = ReferenceContext.Empty.add("agent", "550e8400-e29b-41d4-a716-446655440001") + b = ReferenceContext.Empty.add("agent", "550e8400-e29b-41d4-a716-446655440002") + assert a != b + + +# --------------------------------------------------------------------------- +# ReferenceContext.add — validation & UUID coercion +# --------------------------------------------------------------------------- + + +class TestReferenceContextAdd: + def test_add_with_string_uuid(self) -> None: + ctx = ReferenceContext.Empty.add( + "agent", "550e8400-e29b-41d4-a716-446655440001", "1.0" + ) + assert len(ctx) == 1 + e = ctx.entries[0] + assert e.service_type == "agent" + assert e.reference_id == "550e8400-e29b-41d4-a716-446655440001" + assert e.version == "1.0" + + def test_add_with_uuid_object(self) -> None: + import uuid + + uid = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") + ctx = ReferenceContext.Empty.add("agent", uid) + assert ctx.entries[0].reference_id == str(uid) + + def test_add_without_version(self) -> None: + ctx = ReferenceContext.Empty.add( + "agent", "550e8400-e29b-41d4-a716-446655440001" + ) + assert ctx.entries[0].version is None + + def test_add_blank_version_normalised_to_none(self) -> None: + ctx = ReferenceContext.Empty.add( + "agent", "550e8400-e29b-41d4-a716-446655440001", " " + ) + assert ctx.entries[0].version is None + + def test_add_empty_service_type_raises(self) -> None: + with pytest.raises(ValueError, match="service_type"): + ReferenceContext.Empty.add("", "550e8400-e29b-41d4-a716-446655440001") + + def test_add_invalid_reference_id_type_raises(self) -> None: + with pytest.raises(TypeError, match="reference_id"): + ReferenceContext.Empty.add("agent", 12345) # type: ignore[arg-type] + + def test_entries_ordered_oldest_first(self) -> None: + ctx = ReferenceContext.Empty.add( + "maestro", "550e8400-e29b-41d4-a716-446655440010", "2.0" + ).add("agent", "550e8400-e29b-41d4-a716-446655440011") + assert ctx.entries[0].service_type == "maestro" + assert ctx.entries[1].service_type == "agent" + + +# --------------------------------------------------------------------------- +# ReferenceContext.to_wire_list +# --------------------------------------------------------------------------- + + +class TestToWireList: + def test_empty_produces_empty_list(self) -> None: + assert ReferenceContext.Empty.to_wire_list() == [] + + def test_single_entry_with_version(self) -> None: + ctx = ReferenceContext.Empty.add( + "agent", "550e8400-e29b-41d4-a716-446655440001", "1.0.0" + ) + wire = ctx.to_wire_list() + assert wire == [ + { + "serviceType": "agent", + "referenceId": "550e8400-e29b-41d4-a716-446655440001", + "version": "1.0.0", + } + ] + + def test_single_entry_without_version_omits_key(self) -> None: + ctx = ReferenceContext.Empty.add( + "agent", "550e8400-e29b-41d4-a716-446655440001" + ) + wire = ctx.to_wire_list() + assert "version" not in wire[0] + + def test_multiple_entries_order_preserved(self) -> None: + ctx = ReferenceContext.Empty.add( + "maestro", "550e8400-e29b-41d4-a716-446655440010", "2.1.0" + ).add("agent", "550e8400-e29b-41d4-a716-446655440011") + wire = ctx.to_wire_list() + assert len(wire) == 2 + assert wire[0]["serviceType"] == "maestro" + assert wire[0]["version"] == "2.1.0" + assert wire[1]["serviceType"] == "agent" + assert "version" not in wire[1] + + +# --------------------------------------------------------------------------- +# ReferenceContext.from_baggage_header / to_baggage_header_value round-trip +# --------------------------------------------------------------------------- + + +class TestBaggageHeaderRoundTrip: + def test_round_trip_single_entry_with_version(self) -> None: + ctx = ReferenceContext.Empty.add( + "agent", "550e8400-e29b-41d4-a716-446655440001", "1.0" + ) + assert ( + ReferenceContext.from_baggage_header(ctx.to_baggage_header_value()) == ctx + ) + + def test_round_trip_multiple_entries(self) -> None: + ctx = ReferenceContext.Empty.add( + "maestro", "550e8400-e29b-41d4-a716-446655440010", "2.0" + ).add("agent", "550e8400-e29b-41d4-a716-446655440011") + assert ( + ReferenceContext.from_baggage_header(ctx.to_baggage_header_value()) == ctx + ) + + def test_empty_header_returns_empty(self) -> None: + assert ReferenceContext.from_baggage_header("") == ReferenceContext.Empty + assert ReferenceContext.from_baggage_header(None) == ReferenceContext.Empty + + def test_malformed_entry_skipped_silently(self) -> None: + # Only the second entry is valid + header = "not-a-valid-entry,ref.type=agent;ref.id=550e8400-e29b-41d4-a716-446655440001" + ctx = ReferenceContext.from_baggage_header(header) + assert len(ctx) == 1 + assert ctx.entries[0].service_type == "agent" + + def test_entry_with_invalid_uuid_skipped(self) -> None: + header = "ref.type=agent;ref.id=not-a-uuid" + ctx = ReferenceContext.from_baggage_header(header) + assert ctx == ReferenceContext.Empty + + def test_entry_missing_type_skipped(self) -> None: + header = "ref.id=550e8400-e29b-41d4-a716-446655440001" + ctx = ReferenceContext.from_baggage_header(header) + assert ctx == ReferenceContext.Empty + + def test_empty_context_produces_empty_header(self) -> None: + assert ReferenceContext.Empty.to_baggage_header_value() == "" + + +# --------------------------------------------------------------------------- +# ReferenceContextAccessor — ContextVar semantics +# --------------------------------------------------------------------------- + + +class TestReferenceContextAccessor: + def setup_method(self) -> None: + # Ensure clean state before each test + current = ReferenceContextAccessor.get() + if current is not None: + token = ReferenceContextAccessor.set(None) + # immediately reset to avoid polluting other tests + ReferenceContextAccessor.reset(token) + + def test_default_is_none(self) -> None: + assert ReferenceContextAccessor.get() is None + + def test_set_and_get(self) -> None: + ctx = ReferenceContext.Empty.add( + "agent", "550e8400-e29b-41d4-a716-446655440001" + ) + token = ReferenceContextAccessor.set(ctx) + try: + assert ReferenceContextAccessor.get() == ctx + finally: + ReferenceContextAccessor.reset(token) + + def test_reset_restores_prior_value(self) -> None: + ctx_a = ReferenceContext.Empty.add( + "agent", "550e8400-e29b-41d4-a716-446655440001" + ) + ctx_b = ctx_a.add("langgraph", "550e8400-e29b-41d4-a716-446655440002") + + token_a = ReferenceContextAccessor.set(ctx_a) + token_b = ReferenceContextAccessor.set(ctx_b) + + assert ReferenceContextAccessor.get() == ctx_b + ReferenceContextAccessor.reset(token_b) + assert ReferenceContextAccessor.get() == ctx_a + ReferenceContextAccessor.reset(token_a) + assert ReferenceContextAccessor.get() is None + + +# --------------------------------------------------------------------------- +# UiPathSpan.context wiring via otel_span_to_uipath_span +# --------------------------------------------------------------------------- + + +class TestContextWiring: + def test_context_absent_when_no_reference_context_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "test-org") + span = _SpanUtils.otel_span_to_uipath_span(_make_mock_span()) + assert span.context is None + assert "Context" not in span.to_dict() + + def test_context_present_when_reference_context_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "test-org") + ref_ctx = ReferenceContext.Empty.add( + "agent", "550e8400-e29b-41d4-a716-446655440001", "1.0" + ) + mock = _make_mock_span( + attributes={ + "uipath.reference_hierarchy": json.dumps(ref_ctx.to_wire_list()) + } + ) + span = _SpanUtils.otel_span_to_uipath_span(mock) + assert span.context == { + "referenceHierarchy": [ + { + "serviceType": "agent", + "referenceId": "550e8400-e29b-41d4-a716-446655440001", + "version": "1.0", + } + ] + } + wire = span.to_dict() + assert "Context" in wire + assert wire["Context"]["referenceHierarchy"][0]["serviceType"] == "agent" + + def test_context_carries_full_hierarchy( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "test-org") + ref_ctx = ReferenceContext.Empty.add( + "maestro", "550e8400-e29b-41d4-a716-446655440010", "2.0" + ).add("agent", "550e8400-e29b-41d4-a716-446655440011") + mock = _make_mock_span( + attributes={ + "uipath.reference_hierarchy": json.dumps(ref_ctx.to_wire_list()) + } + ) + wire = _SpanUtils.otel_span_to_uipath_span(mock).to_dict() + hierarchy = wire["Context"]["referenceHierarchy"] + assert len(hierarchy) == 2 + assert hierarchy[0]["serviceType"] == "maestro" + assert hierarchy[0]["version"] == "2.0" + assert hierarchy[1]["serviceType"] == "agent" + assert "version" not in hierarchy[1] + + def test_reference_hierarchy_not_in_attributes_field( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "test-org") + ref_ctx = ReferenceContext.Empty.add( + "agent", "550e8400-e29b-41d4-a716-446655440001" + ) + mock = _make_mock_span( + attributes={ + "uipath.reference_hierarchy": json.dumps(ref_ctx.to_wire_list()) + } + ) + wire = _SpanUtils.otel_span_to_uipath_span(mock).to_dict() + attributes = json.loads(wire["Attributes"]) + assert "uipath.reference_hierarchy" not in attributes + + +# --------------------------------------------------------------------------- +# ReferenceEntry — frozen dataclass +# --------------------------------------------------------------------------- + + +class TestReferenceEntry: + def test_frozen_raises_on_mutation(self) -> None: + entry = ReferenceEntry( + service_type="agent", reference_id="550e8400-e29b-41d4-a716-446655440001" + ) + with pytest.raises((AttributeError, TypeError)): + entry.service_type = "other" # type: ignore[misc] + + def test_equality_by_value(self) -> None: + a = ReferenceEntry( + service_type="agent", + reference_id="550e8400-e29b-41d4-a716-446655440001", + version="1.0", + ) + b = ReferenceEntry( + service_type="agent", + reference_id="550e8400-e29b-41d4-a716-446655440001", + version="1.0", + ) + assert a == b + + def test_version_defaults_to_none(self) -> None: + entry = ReferenceEntry( + service_type="agent", reference_id="550e8400-e29b-41d4-a716-446655440001" + ) + assert entry.version is None + + +# --------------------------------------------------------------------------- +# ReferenceContext — bool, iter, len +# --------------------------------------------------------------------------- + + +class TestReferenceContextProtocol: + def test_non_empty_context_is_truthy(self) -> None: + ctx = ReferenceContext.Empty.add( + "agent", "550e8400-e29b-41d4-a716-446655440001" + ) + assert bool(ctx) + + def test_iter_yields_entries_in_order(self) -> None: + ctx = ReferenceContext.Empty.add( + "maestro", "550e8400-e29b-41d4-a716-446655440010" + ).add("agent", "550e8400-e29b-41d4-a716-446655440011") + types = [e.service_type for e in ctx] + assert types == ["maestro", "agent"] + + def test_len_matches_entry_count(self) -> None: + ctx = ( + ReferenceContext.Empty.add( + "maestro", "550e8400-e29b-41d4-a716-446655440010" + ) + .add("agent", "550e8400-e29b-41d4-a716-446655440011") + .add("langgraph", "550e8400-e29b-41d4-a716-446655440012") + ) + assert len(ctx) == 3 + + +# --------------------------------------------------------------------------- +# ReferenceContext.add — additional validation cases +# --------------------------------------------------------------------------- + + +class TestReferenceContextAddValidation: + def test_whitespace_only_service_type_raises(self) -> None: + with pytest.raises(ValueError, match="service_type"): + ReferenceContext.Empty.add(" ", "550e8400-e29b-41d4-a716-446655440001") + + def test_invalid_uuid_string_raises_value_error(self) -> None: + with pytest.raises(ValueError, match="reference_id"): + ReferenceContext.Empty.add("agent", "not-a-valid-uuid") + + +# --------------------------------------------------------------------------- +# ReferenceContext.from_baggage_header — additional parsing cases +# --------------------------------------------------------------------------- + + +class TestFromBaggageHeaderParsing: + def test_whitespace_only_header_returns_empty(self) -> None: + assert ReferenceContext.from_baggage_header(" ") == ReferenceContext.Empty + + def test_entry_missing_ref_id_skipped(self) -> None: + header = "ref.type=agent" + ctx = ReferenceContext.from_baggage_header(header) + assert ctx == ReferenceContext.Empty + + def test_version_key_parsed_correctly(self) -> None: + header = ( + "ref.type=maestro;ref.id=550e8400-e29b-41d4-a716-446655440010;ref.v=2.1.0" + ) + ctx = ReferenceContext.from_baggage_header(header) + assert len(ctx) == 1 + assert ctx.entries[0].version == "2.1.0" + + def test_whitespace_only_entries_between_commas_skipped(self) -> None: + # ",, ," should not produce entries + header = "ref.type=agent;ref.id=550e8400-e29b-41d4-a716-446655440001, , " + ctx = ReferenceContext.from_baggage_header(header) + assert len(ctx) == 1 + + def test_mixed_valid_and_invalid_keeps_valid(self) -> None: + header = ( + "ref.type=agent;ref.id=not-a-uuid," + "ref.type=maestro;ref.id=550e8400-e29b-41d4-a716-446655440010" + ) + ctx = ReferenceContext.from_baggage_header(header) + assert len(ctx) == 1 + assert ctx.entries[0].service_type == "maestro" + + +# --------------------------------------------------------------------------- +# ReferenceContext.to_baggage_header_value — format checks +# --------------------------------------------------------------------------- + + +class TestToBaggageHeaderValue: + def test_single_entry_with_version_format(self) -> None: + ctx = ReferenceContext.Empty.add( + "agent", "550e8400-e29b-41d4-a716-446655440001", "1.0" + ) + value = ctx.to_baggage_header_value() + assert ( + value + == "ref.type=agent;ref.id=550e8400-e29b-41d4-a716-446655440001;ref.v=1.0" + ) + + def test_single_entry_without_version_format(self) -> None: + ctx = ReferenceContext.Empty.add( + "agent", "550e8400-e29b-41d4-a716-446655440001" + ) + value = ctx.to_baggage_header_value() + assert value == "ref.type=agent;ref.id=550e8400-e29b-41d4-a716-446655440001" + assert "ref.v" not in value + + def test_multiple_entries_comma_separated(self) -> None: + ctx = ReferenceContext.Empty.add( + "maestro", "550e8400-e29b-41d4-a716-446655440010", "2.0" + ).add("agent", "550e8400-e29b-41d4-a716-446655440011") + value = ctx.to_baggage_header_value() + parts = value.split(",") + assert len(parts) == 2 + assert parts[0].startswith("ref.type=maestro") + assert parts[1].startswith("ref.type=agent") + + +# --------------------------------------------------------------------------- +# ReferenceContextAccessor — async propagation +# --------------------------------------------------------------------------- + + +class TestReferenceContextAccessorAsync: + @pytest.mark.asyncio + async def test_context_propagates_across_await(self) -> None: + import asyncio + + ctx = ReferenceContext.Empty.add( + "agent", "550e8400-e29b-41d4-a716-446655440001" + ) + token = ReferenceContextAccessor.set(ctx) + try: + await asyncio.sleep(0) + assert ReferenceContextAccessor.get() == ctx + finally: + ReferenceContextAccessor.reset(token) + + @pytest.mark.asyncio + async def test_context_isolated_between_concurrent_tasks(self) -> None: + import asyncio + + ctx_a = ReferenceContext.Empty.add( + "agent", "550e8400-e29b-41d4-a716-446655440001" + ) + ctx_b = ReferenceContext.Empty.add( + "maestro", "550e8400-e29b-41d4-a716-446655440002" + ) + seen: dict[str, object] = {} + + async def task_a() -> None: + token = ReferenceContextAccessor.set(ctx_a) + await asyncio.sleep(0) + seen["a"] = ReferenceContextAccessor.get() + ReferenceContextAccessor.reset(token) + + async def task_b() -> None: + token = ReferenceContextAccessor.set(ctx_b) + await asyncio.sleep(0) + seen["b"] = ReferenceContextAccessor.get() + ReferenceContextAccessor.reset(token) + + await asyncio.gather(task_a(), task_b()) + assert seen["a"] == ctx_a + assert seen["b"] == ctx_b + + +# --------------------------------------------------------------------------- +# _inject_reference_hierarchy hook +# --------------------------------------------------------------------------- + + +class TestReferenceHierarchyHook: + def setup_method(self) -> None: + token = ReferenceContextAccessor.set(None) + ReferenceContextAccessor.reset(token) + + def test_hook_stamps_attribute_when_context_set(self) -> None: + from uipath.platform.common._span_utils import _inject_reference_hierarchy + + ref_ctx = ReferenceContext.Empty.add( + "agent", "550e8400-e29b-41d4-a716-446655440001" + ) + token = ReferenceContextAccessor.set(ref_ctx) + try: + mock_span = Mock() + _inject_reference_hierarchy(mock_span) + mock_span.set_attribute.assert_called_once() + key, value = mock_span.set_attribute.call_args[0] + assert key == "uipath.reference_hierarchy" + hierarchy = json.loads(value) + assert len(hierarchy) == 1 + assert hierarchy[0]["serviceType"] == "agent" + assert hierarchy[0]["referenceId"] == "550e8400-e29b-41d4-a716-446655440001" + finally: + ReferenceContextAccessor.reset(token) + + def test_hook_noop_when_context_not_set(self) -> None: + from uipath.platform.common._span_utils import _inject_reference_hierarchy + + token = ReferenceContextAccessor.set(None) + try: + mock_span = Mock() + _inject_reference_hierarchy(mock_span) + mock_span.set_attribute.assert_not_called() + finally: + ReferenceContextAccessor.reset(token) diff --git a/packages/uipath-platform/tests/services/test_span_utils.py b/packages/uipath-platform/tests/services/test_span_utils.py index 14bff69ad..f45351864 100644 --- a/packages/uipath-platform/tests/services/test_span_utils.py +++ b/packages/uipath-platform/tests/services/test_span_utils.py @@ -5,10 +5,20 @@ from unittest.mock import Mock, patch import pytest +from opentelemetry import context as context_api from opentelemetry.sdk.trace import Span as OTelSpan +from opentelemetry.sdk.trace import SpanProcessor from opentelemetry.trace import SpanContext, StatusCode -from uipath.platform.common import UiPathSpan, _SpanUtils +from uipath.platform.common import ( + ReferenceHierarchySpanProcessor, + UiPathSpan, + _SpanUtils, +) +from uipath.platform.common._reference_context import ( + ReferenceContext, + ReferenceContextAccessor, +) from uipath.platform.common._span_utils import ( _SOURCE_BY_INT, ExecutionType, @@ -1108,3 +1118,99 @@ def test_verbosity_level_int_maps_to_str_enum(self): span = _SpanUtils.otel_span_to_uipath_span(mock_span) assert span.verbosity_level == VerbosityLevel.OFF assert span.to_dict()["VerbosityLevel"] == "Off" + + +# --------------------------------------------------------------------------- +# ReferenceHierarchySpanProcessor +# --------------------------------------------------------------------------- + + +class TestReferenceHierarchySpanProcessor: + """Tests for ReferenceHierarchySpanProcessor.on_start. + + The only change made to this class was aligning the parent_context + parameter type annotation with the SpanProcessor base class + (Optional[context_api.Context] instead of context_api.Context | None). + These tests verify the processor is a proper SpanProcessor subclass and + that on_start behaves correctly under all valid call forms. + """ + + def setup_method(self) -> None: + token = ReferenceContextAccessor.set(None) + ReferenceContextAccessor.reset(token) + + def _make_mock_span(self) -> Mock: + mock = Mock(spec=OTelSpan) + return mock + + def test_is_span_processor_subclass(self) -> None: + assert issubclass(ReferenceHierarchySpanProcessor, SpanProcessor) + + def test_on_start_with_default_parent_context(self) -> None: + ref_ctx = ReferenceContext.Empty.add( + "agent", "550e8400-e29b-41d4-a716-446655440001" + ) + token = ReferenceContextAccessor.set(ref_ctx) + try: + processor = ReferenceHierarchySpanProcessor() + mock_span = self._make_mock_span() + processor.on_start(mock_span) # parent_context omitted — uses default None + mock_span.set_attribute.assert_called_once() + key, value = mock_span.set_attribute.call_args[0] + assert key == "uipath.reference_hierarchy" + assert json.loads(value)[0]["serviceType"] == "agent" + finally: + ReferenceContextAccessor.reset(token) + + def test_on_start_with_explicit_none_parent_context(self) -> None: + ref_ctx = ReferenceContext.Empty.add( + "maestro", "550e8400-e29b-41d4-a716-446655440010" + ) + token = ReferenceContextAccessor.set(ref_ctx) + try: + processor = ReferenceHierarchySpanProcessor() + mock_span = self._make_mock_span() + processor.on_start(mock_span, parent_context=None) + mock_span.set_attribute.assert_called_once() + finally: + ReferenceContextAccessor.reset(token) + + def test_on_start_with_real_context_object(self) -> None: + ref_ctx = ReferenceContext.Empty.add( + "agent", "550e8400-e29b-41d4-a716-446655440001" + ) + token = ReferenceContextAccessor.set(ref_ctx) + try: + processor = ReferenceHierarchySpanProcessor() + mock_span = self._make_mock_span() + otel_ctx = context_api.create_key("test") + real_ctx = context_api.set_value(otel_ctx, "val") + processor.on_start(mock_span, parent_context=real_ctx) + mock_span.set_attribute.assert_called_once() + finally: + ReferenceContextAccessor.reset(token) + + def test_on_start_noop_when_no_reference_context(self) -> None: + processor = ReferenceHierarchySpanProcessor() + mock_span = self._make_mock_span() + processor.on_start(mock_span) + mock_span.set_attribute.assert_not_called() + + def test_on_start_stamps_full_hierarchy(self) -> None: + ref_ctx = ReferenceContext.Empty.add( + "maestro", "550e8400-e29b-41d4-a716-446655440010", "2.0" + ).add("agent", "550e8400-e29b-41d4-a716-446655440011") + token = ReferenceContextAccessor.set(ref_ctx) + try: + processor = ReferenceHierarchySpanProcessor() + mock_span = self._make_mock_span() + processor.on_start(mock_span) + key, value = mock_span.set_attribute.call_args[0] + hierarchy = json.loads(value) + assert len(hierarchy) == 2 + assert hierarchy[0]["serviceType"] == "maestro" + assert hierarchy[0]["version"] == "2.0" + assert hierarchy[1]["serviceType"] == "agent" + assert "version" not in hierarchy[1] + finally: + ReferenceContextAccessor.reset(token) diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index d5ed8d8d8..f5253f90c 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.3" +version = "0.2.4" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index bfb1a3626..210adae85 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.13.4" +version = "2.13.5" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.30, <0.6.0", "uipath-runtime>=0.12.2, <0.13.0", - "uipath-platform>=0.2.2, <0.3.0", + "uipath-platform>=0.2.4, <0.3.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/src/uipath/_cli/_utils/_tracing.py b/packages/uipath/src/uipath/_cli/_utils/_tracing.py index fdc4a2238..b085075f0 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_tracing.py +++ b/packages/uipath/src/uipath/_cli/_utils/_tracing.py @@ -24,6 +24,15 @@ def filter(self, record): return False +def create_trace_manager(): + from uipath.core.tracing import UiPathTraceManager + from uipath.platform.common import ReferenceHierarchySpanProcessor + + tm = UiPathTraceManager() + tm.add_span_processor(ReferenceHierarchySpanProcessor()) + return tm + + def setup_tracer_httpx_logging(url: str): # Create a custom logger for httpx # Add the custom filter to the root logger diff --git a/packages/uipath/src/uipath/_cli/cli_debug.py b/packages/uipath/src/uipath/_cli/cli_debug.py index 92fc3ffea..fa7e7a170 100644 --- a/packages/uipath/src/uipath/_cli/cli_debug.py +++ b/packages/uipath/src/uipath/_cli/cli_debug.py @@ -8,7 +8,7 @@ from uipath._cli._debug._bridge import DebugAttachMode, get_debug_bridge from uipath._cli._utils._debug import setup_debugging from uipath._cli._utils._studio_project import StudioClient -from uipath.core.tracing import UiPathTraceManager +from uipath._cli._utils._tracing import create_trace_manager from uipath.eval.mocks import UiPathMockRuntime from uipath.eval.mocks._mock_runtime import load_simulation_config from uipath.platform.common import ( @@ -125,7 +125,7 @@ def debug( try: async def execute_debug_runtime(): - trace_manager = UiPathTraceManager() + trace_manager = create_trace_manager() ctx = UiPathRuntimeContext.with_defaults( input=input, diff --git a/packages/uipath/src/uipath/_cli/cli_dev.py b/packages/uipath/src/uipath/_cli/cli_dev.py index f6d7021bb..16b331dd3 100644 --- a/packages/uipath/src/uipath/_cli/cli_dev.py +++ b/packages/uipath/src/uipath/_cli/cli_dev.py @@ -5,6 +5,7 @@ from uipath._cli._utils._console import ConsoleLogger from uipath._cli._utils._debug import setup_debugging +from uipath._cli._utils._tracing import create_trace_manager from uipath._cli.middlewares import Middlewares from uipath.core.tracing import UiPathTraceManager from uipath.platform.common import ExecutionSourceContext @@ -86,7 +87,7 @@ async def run_terminal() -> None: factory = None try: - trace_manager = UiPathTraceManager() + trace_manager = create_trace_manager() context, factory = _create_dev_context_and_factory(trace_manager) app = UiPathDeveloperConsole( @@ -127,7 +128,7 @@ def signal_handler(sig, frame): signal.signal(signal.SIGTERM, signal_handler) try: - trace_manager = UiPathTraceManager() + trace_manager = create_trace_manager() context, factory = _create_dev_context_and_factory(trace_manager) app = UiPathDeveloperServer( diff --git a/packages/uipath/src/uipath/_cli/cli_eval.py b/packages/uipath/src/uipath/_cli/cli_eval.py index 310909e59..12ae1f062 100644 --- a/packages/uipath/src/uipath/_cli/cli_eval.py +++ b/packages/uipath/src/uipath/_cli/cli_eval.py @@ -14,9 +14,9 @@ from uipath._cli._evals._telemetry import EvalTelemetrySubscriber from uipath._cli._utils._folders import get_personal_workspace_key_async from uipath._cli._utils._studio_project import StudioClient +from uipath._cli._utils._tracing import create_trace_manager from uipath._cli.middlewares import Middlewares from uipath.core.events import EventBus -from uipath.core.tracing import UiPathTraceManager from uipath.eval.helpers import EVAL_SETS_DIRECTORY_NAME, EvalHelpers, get_agent_model from uipath.eval.models.evaluation_set import EvaluationSet from uipath.eval.runtime import UiPathEvalContext, evaluate @@ -312,7 +312,7 @@ async def execute_eval(): telemetry_subscriber = EvalTelemetrySubscriber() await telemetry_subscriber.subscribe_to_eval_runtime_events(event_bus) - trace_manager = UiPathTraceManager() + trace_manager = create_trace_manager() ctx = UiPathRuntimeContext.with_defaults( output_file=output_file, diff --git a/packages/uipath/src/uipath/_cli/cli_run.py b/packages/uipath/src/uipath/_cli/cli_run.py index 32b245b6f..1fca4b8cb 100644 --- a/packages/uipath/src/uipath/_cli/cli_run.py +++ b/packages/uipath/src/uipath/_cli/cli_run.py @@ -8,7 +8,7 @@ from uipath._cli._debug._bridge import ConsoleDebugBridge from uipath._cli._utils._common import read_resource_overwrites_from_file from uipath._cli._utils._debug import setup_debugging -from uipath.core.tracing import UiPathTraceManager +from uipath._cli._utils._tracing import create_trace_manager from uipath.eval.mocks import SimulationConfig, UiPathMockRuntime, build_mocking_context from uipath.platform.common import ( ExecutionSourceContext, @@ -192,7 +192,7 @@ async def debug_runtime( return ctx.result async def execute() -> None: - trace_manager = UiPathTraceManager() + trace_manager = create_trace_manager() ctx = UiPathRuntimeContext.with_defaults( entrypoint=entrypoint, diff --git a/packages/uipath/src/uipath/tracing/__init__.py b/packages/uipath/src/uipath/tracing/__init__.py index 4fcf2b2db..a586e04b9 100644 --- a/packages/uipath/src/uipath/tracing/__init__.py +++ b/packages/uipath/src/uipath/tracing/__init__.py @@ -1,6 +1,11 @@ """Tracing utilities and OpenTelemetry exporters.""" from uipath.core import traced +from uipath.platform.common._reference_context import ( + ReferenceContext, + ReferenceContextAccessor, + ReferenceEntry, +) from uipath.platform.common._span_utils import ( AttachmentDirection, AttachmentProvider, @@ -25,4 +30,7 @@ "AttachmentProvider", "SpanAttachment", "VerbosityLevel", + "ReferenceEntry", + "ReferenceContext", + "ReferenceContextAccessor", ] diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index e90c2794a..881bfdaf7 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2598,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.4" +version = "2.13.5" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2741,7 +2741,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.3" +version = "0.2.4" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From b8d2f6563ff43a21047c220407f088c6caf73c14 Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:38:59 -0400 Subject: [PATCH 156/191] feat: add set_conversational_output tool + generate-output prompt for conversational agents (#1785) --- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/agent/react/__init__.py | 6 +++++ .../agent/react/conversational_prompts.py | 18 +++++++++++++++ .../uipath/src/uipath/agent/react/tools.py | 23 +++++++++++++++++++ .../react/test_conversational_prompts.py | 14 +++++++++++ packages/uipath/uv.lock | 2 +- 6 files changed, 63 insertions(+), 2 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 210adae85..035dac2d1 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.13.5" +version = "2.13.6" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/agent/react/__init__.py b/packages/uipath/src/uipath/agent/react/__init__.py index 835fd0bda..539f062b6 100644 --- a/packages/uipath/src/uipath/agent/react/__init__.py +++ b/packages/uipath/src/uipath/agent/react/__init__.py @@ -6,15 +6,18 @@ from .conversational_prompts import ( PromptUserSettings, get_chat_system_prompt, + get_generate_output_prompt, ) from .conversational_voice_prompts import get_voice_system_prompt from .prompts import AGENT_SYSTEM_PROMPT_TEMPLATE from .tools import ( END_EXECUTION_TOOL, RAISE_ERROR_TOOL, + SET_CONVERSATIONAL_OUTPUT_TOOL, EndExecutionToolSchemaModel, FlowControlToolConfig, RaiseErrorToolSchemaModel, + SetConversationalOutputToolSchemaModel, ) __all__ = [ @@ -22,9 +25,12 @@ "FlowControlToolConfig", "END_EXECUTION_TOOL", "RAISE_ERROR_TOOL", + "SET_CONVERSATIONAL_OUTPUT_TOOL", "EndExecutionToolSchemaModel", "RaiseErrorToolSchemaModel", + "SetConversationalOutputToolSchemaModel", "PromptUserSettings", "get_chat_system_prompt", + "get_generate_output_prompt", "get_voice_system_prompt", ] diff --git a/packages/uipath/src/uipath/agent/react/conversational_prompts.py b/packages/uipath/src/uipath/agent/react/conversational_prompts.py index f79de6185..d966dd55b 100644 --- a/packages/uipath/src/uipath/agent/react/conversational_prompts.py +++ b/packages/uipath/src/uipath/agent/react/conversational_prompts.py @@ -233,3 +233,21 @@ def get_conversation_id_template(conversation_id: str | None) -> str: if not conversation_id: return "" return _CONVERSATION_ID_TEMPLATE.format(conversation_id=conversation_id) + + +_GENERATE_OUTPUT_INSTRUCTION = """The conversational response for this turn has already been delivered to the user. Call the `set_conversational_output` tool to record the structured output fields for this turn. + +Rules: +- For each field, use values inferred from the conversation's recent turn. +- For optional fields that are not yet relevant or determinable (e.g. the conversation is still gathering context, or the topic hasn't surfaced yet), omit them entirely. +- For required fields that cannot yet be determined, provide a default placeholder. DO NOT fabricate, guess, or hallucinate meaningful values. +- Do not produce any text response, as this will not be seen by the user. Only call the tool.""" + + +def get_generate_output_prompt() -> str: + """Return the framework-internal generate-output instruction. + + Appended as a final user-message to the conversational structured-output + node's LLM call. + """ + return _GENERATE_OUTPUT_INSTRUCTION diff --git a/packages/uipath/src/uipath/agent/react/tools.py b/packages/uipath/src/uipath/agent/react/tools.py index c2a162be9..050653543 100644 --- a/packages/uipath/src/uipath/agent/react/tools.py +++ b/packages/uipath/src/uipath/agent/react/tools.py @@ -11,6 +11,7 @@ class FlowControlToolName(str, Enum): END_EXECUTION = "end_execution" RAISE_ERROR = "raise_error" + SET_CONVERSATIONAL_OUTPUT = "set_conversational_output" @dataclass(frozen=True) @@ -72,3 +73,25 @@ class RaiseErrorToolSchemaModel(BaseModel): description="Raises an error and ends the execution of the agent", args_schema=RaiseErrorToolSchemaModel, ) + + +class SetConversationalOutputToolSchemaModel(BaseModel): + """Placeholder args_schema for the `set_conversational_output` tool. + + Always overridden at construction time with the agent's stripped output + schema (i.e. the user's `outputSchema` with `uipath__agent_response_messages` + removed). Declared here so the tool entry has a well-typed default. + """ + + model_config = ConfigDict(extra="forbid") + + +SET_CONVERSATIONAL_OUTPUT_TOOL = FlowControlToolConfig( + name=FlowControlToolName.SET_CONVERSATIONAL_OUTPUT, + description=( + "Sets the structured output fields for the current conversational " + "turn. Called once per turn after the conversational response has been " + "delivered, to populate fields as the agent's output." + ), + args_schema=SetConversationalOutputToolSchemaModel, +) diff --git a/packages/uipath/tests/agent/react/test_conversational_prompts.py b/packages/uipath/tests/agent/react/test_conversational_prompts.py index 434f68465..2ddafc904 100644 --- a/packages/uipath/tests/agent/react/test_conversational_prompts.py +++ b/packages/uipath/tests/agent/react/test_conversational_prompts.py @@ -8,6 +8,7 @@ from uipath.agent.react.conversational_prompts import ( PromptUserSettings, get_chat_system_prompt, + get_generate_output_prompt, get_user_settings_template, ) @@ -348,3 +349,16 @@ def test_full_settings_json_format(self): assert json_data["company"] == "Big Corp" assert json_data["country"] == "UK" assert json_data["timezone"] == "Europe/London" + + +class TestGetGenerateOutputPrompt: + """Tests for get_generate_output_prompt function.""" + + def test_returns_non_empty_string(self): + instruction = get_generate_output_prompt() + assert isinstance(instruction, str) + assert instruction.strip() + + def test_references_set_conversational_output_tool(self): + """The instruction must name the tool the new node binds.""" + assert "set_conversational_output" in get_generate_output_prompt() diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 881bfdaf7..19e974d12 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2598,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.5" +version = "2.13.6" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From e1ceda54d59d86313873f9885d6586473be82af8 Mon Sep 17 00:00:00 2001 From: Andrei Petraru Date: Wed, 8 Jul 2026 11:58:02 +0300 Subject: [PATCH 157/191] feat(guardrails): add LLMAsJudgeValidator for the @guardrail decorator [AL-470] (#1802) Co-authored-by: Claude Opus 4.8 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../uipath/platform/guardrails/__init__.py | 2 + .../guardrails/decorators/__init__.py | 2 + .../decorators/validators/__init__.py | 2 + .../decorators/validators/llm_as_judge.py | 157 ++++++++++++++++++ .../services/test_guardrails_decorators.py | 67 ++++++++ .../services/test_llm_as_judge_validator.py | 142 ++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 9 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/llm_as_judge.py create mode 100644 packages/uipath-platform/tests/services/test_llm_as_judge_validator.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index d2a0f4efc..72b0d0b3a 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.4" +version = "0.2.5" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py b/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py index 93c60b0a4..ed6c8d4dd 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py @@ -30,6 +30,7 @@ HarmfulContentValidator, IntellectualPropertyEntityType, IntellectualPropertyValidator, + LLMAsJudgeValidator, LogAction, LoggingSeverityLevel, PIIDetectionEntity, @@ -69,6 +70,7 @@ "CustomGuardrailValidator", "HarmfulContentValidator", "IntellectualPropertyValidator", + "LLMAsJudgeValidator", "PIIValidator", "PromptInjectionValidator", "UserPromptAttacksValidator", diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py index e8d692164..841da6660 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py @@ -24,6 +24,7 @@ GuardrailValidatorBase, HarmfulContentValidator, IntellectualPropertyValidator, + LLMAsJudgeValidator, PIIValidator, PromptInjectionValidator, RuleFunction, @@ -39,6 +40,7 @@ "CustomGuardrailValidator", "HarmfulContentValidator", "IntellectualPropertyValidator", + "LLMAsJudgeValidator", "PIIValidator", "PromptInjectionValidator", "UserPromptAttacksValidator", diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py index bbcf29039..14cea0d4b 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py @@ -8,6 +8,7 @@ from .custom import CustomValidator, RuleFunction from .harmful_content import HarmfulContentValidator from .intellectual_property import IntellectualPropertyValidator +from .llm_as_judge import LLMAsJudgeValidator from .pii import PIIValidator from .prompt_injection import PromptInjectionValidator from .user_prompt_attacks import UserPromptAttacksValidator @@ -18,6 +19,7 @@ "CustomGuardrailValidator", "HarmfulContentValidator", "IntellectualPropertyValidator", + "LLMAsJudgeValidator", "PIIValidator", "PromptInjectionValidator", "UserPromptAttacksValidator", diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/llm_as_judge.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/llm_as_judge.py new file mode 100644 index 000000000..f639a6ef3 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/llm_as_judge.py @@ -0,0 +1,157 @@ +"""LLM-as-judge guardrail validator.""" + +from typing import Any, Sequence +from uuid import uuid4 + +from uipath.platform.guardrails.guardrails import ( + BuiltInValidatorGuardrail, + EnumParameterValue, + NumberParameterValue, + TextListParameterValue, + TextParameterValue, +) + +from ._base import BuiltInGuardrailValidator + +# Threshold scale matches the backend OOTB catalog: any float in [0, 6], default 2 +# (the catalog's UI step of 2 is only a slider increment; the server accepts any +# value in range). HIGHER = more lenient (only flag clear violations), LOWER = stricter. +_THRESHOLD_MIN = 0.0 +_THRESHOLD_MAX = 6.0 +_THRESHOLD_DEFAULT = 2.0 + +# Input limits mirror the backend OOTB catalog / LlmAsJudgeProviderApi; keep in sync. +_MAX_GUARDRAIL_TEXT_LENGTH = 4000 +_MAX_EXAMPLE_LENGTH = 1000 +_MAX_EXAMPLES_PER_LIST = 2 + + +class LLMAsJudgeValidator(BuiltInGuardrailValidator): + """Validate content against a natural-language rule via an LLM judge. + + The customer expresses a rule in natural language (``guardrail_text``) and picks a + judge model; a judge LLM decides whether the evaluated payload complies. Works at + any scope/stage the ``@guardrail`` decorator wraps (scope is implicit in the + decorated target; ``supported_stages`` is left as the default empty list, which + means all stages are allowed — both PRE and POST). + + Args: + guardrail_text: The natural-language rule the judge evaluates against + (at most 4000 characters). + model: The judge model to use (a model id supported by LLM Gateway). + positive_examples: Optional example payloads that comply with the rule + (at most 2 entries, each at most 1000 characters). + negative_examples: Optional example payloads that violate the rule + (at most 2 entries, each at most 1000 characters). + threshold: Strictness on a 0-6 scale (default 2); higher is more lenient. + + Raises: + ValueError: If ``guardrail_text``/``model`` are empty, ``threshold`` is + outside [0, 6], ``guardrail_text`` exceeds 4000 characters, either example + list has more than 2 entries, or any example exceeds 1000 characters. + """ + + def __init__( + self, + guardrail_text: str, + model: str, + *, + positive_examples: Sequence[str] | None = None, + negative_examples: Sequence[str] | None = None, + threshold: float = _THRESHOLD_DEFAULT, + ) -> None: + """Initialize LLMAsJudgeValidator with the rule, judge model, and options.""" + if not guardrail_text or not guardrail_text.strip(): + raise ValueError("guardrail_text must be a non-empty string") + if not model or not model.strip(): + raise ValueError("model must be a non-empty string") + if not _THRESHOLD_MIN <= threshold <= _THRESHOLD_MAX: + raise ValueError( + f"threshold must be between {_THRESHOLD_MIN} and {_THRESHOLD_MAX}, " + f"got {threshold}" + ) + if len(guardrail_text) > _MAX_GUARDRAIL_TEXT_LENGTH: + raise ValueError( + f"guardrail_text exceeds the {_MAX_GUARDRAIL_TEXT_LENGTH}-character " + f"limit (got {len(guardrail_text)})" + ) + positive_examples = list(positive_examples or []) + negative_examples = list(negative_examples or []) + for label, examples in ( + ("positive_examples", positive_examples), + ("negative_examples", negative_examples), + ): + if len(examples) > _MAX_EXAMPLES_PER_LIST: + raise ValueError( + f"{label} allows at most {_MAX_EXAMPLES_PER_LIST} examples " + f"(got {len(examples)})" + ) + if any(len(e) > _MAX_EXAMPLE_LENGTH for e in examples): + raise ValueError( + f"each {label} entry must be at most {_MAX_EXAMPLE_LENGTH} characters" + ) + self.guardrail_text = guardrail_text + self.model = model + self.positive_examples = positive_examples + self.negative_examples = negative_examples + self.threshold = threshold + + def get_built_in_guardrail( + self, + name: str, + description: str | None, + enabled_for_evals: bool, + ) -> BuiltInValidatorGuardrail: + """Build an LLM-as-judge :class:`BuiltInValidatorGuardrail`. + + Args: + name: Name for the guardrail. + description: Optional description. + enabled_for_evals: Whether active in evaluation scenarios. + + Returns: + Configured :class:`BuiltInValidatorGuardrail` for llm_as_judge. + """ + validator_parameters: list[Any] = [ + TextParameterValue( + parameter_type="text", + id="guardrailText", + value=self.guardrail_text, + ), + EnumParameterValue( + parameter_type="enum", + id="model", + value=self.model, + ), + NumberParameterValue( + parameter_type="number", + id="threshold", + value=self.threshold, + ), + ] + if self.positive_examples: + validator_parameters.append( + TextListParameterValue( + parameter_type="text-list", + id="positiveExamples", + value=self.positive_examples, + ) + ) + if self.negative_examples: + validator_parameters.append( + TextListParameterValue( + parameter_type="text-list", + id="negativeExamples", + value=self.negative_examples, + ) + ) + + return BuiltInValidatorGuardrail( + id=str(uuid4()), + name=name, + description=description or "LLM-as-judge evaluation", + enabled_for_evals=enabled_for_evals, + guardrail_type="builtInValidator", + validator_type="llm_as_judge", + validator_parameters=validator_parameters, + ) diff --git a/packages/uipath-platform/tests/services/test_guardrails_decorators.py b/packages/uipath-platform/tests/services/test_guardrails_decorators.py index e578cba84..29674ca09 100644 --- a/packages/uipath-platform/tests/services/test_guardrails_decorators.py +++ b/packages/uipath-platform/tests/services/test_guardrails_decorators.py @@ -1169,3 +1169,70 @@ def submit_joke(joke: str) -> str: # Short joke passes through assert submit_joke(joke="short") == "short" + + +# --------------------------------------------------------------------------- +# LLMAsJudgeValidator via @guardrail +# --------------------------------------------------------------------------- + + +class TestLLMAsJudgeDecorator: + """@guardrail(validator=LLMAsJudgeValidator(...)) behavior on a mocked verdict.""" + + def test_block_action_raises_on_failed_verdict(self): + mock_validator = MagicMock() + mock_validator.supported_stages = [] + mock_validator.validate_stage = MagicMock() + mock_validator.run.return_value = _FAILED + + @guardrail( + validator=mock_validator, + action=BlockAction(title="Blocked", detail="off-topic"), + stage=GuardrailExecutionStage.POST, + ) + def joke(topic: str) -> str: + return f"joke about {topic}" + + with pytest.raises(GuardrailBlockException, match="Blocked"): + joke("cats") + + def test_log_action_continues_on_failed_verdict(self, caplog): + mock_validator = MagicMock() + mock_validator.supported_stages = [] + mock_validator.validate_stage = MagicMock() + mock_validator.run.return_value = _FAILED + + @guardrail( + validator=mock_validator, + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + name="Judge", + ) + def joke(topic: str) -> str: + return f"joke about {topic}" + + with caplog.at_level(logging.WARNING): + assert joke("cats") == "joke about cats" # log does not block + assert any("Judge" in r.message for r in caplog.records) + + def test_real_validator_block_end_to_end_mocked_backend(self): + """Real LLMAsJudgeValidator.run() path with the UiPath API mocked.""" + from uipath.platform.guardrails.decorators import LLMAsJudgeValidator + + mock_uipath = MagicMock() + mock_uipath.guardrails.evaluate_guardrail.return_value = _FAILED + + @guardrail( + validator=LLMAsJudgeValidator( + guardrail_text="Must be on-topic.", model="gpt-4o-2024-08-06" + ), + action=BlockAction(title="Off-topic", detail="blocked"), + stage=GuardrailExecutionStage.POST, + ) + def joke(topic: str) -> str: + return f"joke about {topic}" + + with patch("uipath.platform.UiPath", return_value=mock_uipath): + with pytest.raises(GuardrailBlockException): + joke("cats") + mock_uipath.guardrails.evaluate_guardrail.assert_called_once() diff --git a/packages/uipath-platform/tests/services/test_llm_as_judge_validator.py b/packages/uipath-platform/tests/services/test_llm_as_judge_validator.py new file mode 100644 index 000000000..aba269511 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_llm_as_judge_validator.py @@ -0,0 +1,142 @@ +"""Tests for LLMAsJudgeValidator — the decorator-path llm_as_judge validator. + +Verifies guardrail construction (validator_type + parameters), the +examples-only-when-present behavior, selector-is-None convention, stage support, +and input validation. Pure construction — no network. +""" + +from __future__ import annotations + +import pytest + +from uipath.platform.guardrails.decorators import ( + GuardrailExecutionStage, + LLMAsJudgeValidator, +) + +_RULE = "The answer must be genuinely funny, clean, and on-topic." + + +class TestLLMAsJudgeValidator: + """Tests for LLMAsJudgeValidator.""" + + def test_builds_guardrail_type(self): + guardrail = LLMAsJudgeValidator( + guardrail_text=_RULE, model="gpt-4o-2024-08-06" + ).get_built_in_guardrail(name="Judge", description=None, enabled_for_evals=True) + assert guardrail.guardrail_type == "builtInValidator" + assert guardrail.validator_type == "llm_as_judge" + + def test_required_parameters(self): + guardrail = LLMAsJudgeValidator( + guardrail_text=_RULE, model="gpt-4o-2024-08-06", threshold=2.0 + ).get_built_in_guardrail("Judge", None, True) + params = {p.id: p for p in guardrail.validator_parameters} + assert params["guardrailText"].parameter_type == "text" + assert params["guardrailText"].value == _RULE + assert params["model"].parameter_type == "enum" + assert params["model"].value == "gpt-4o-2024-08-06" + assert params["threshold"].parameter_type == "number" + assert params["threshold"].value == 2.0 + + def test_examples_only_when_present(self): + ids = { + p.id + for p in LLMAsJudgeValidator(guardrail_text=_RULE, model="m") + .get_built_in_guardrail("Judge", None, True) + .validator_parameters + } + assert "positiveExamples" not in ids + assert "negativeExamples" not in ids + + params = { + p.id: p + for p in LLMAsJudgeValidator( + guardrail_text=_RULE, + model="m", + positive_examples=["a clean pun"], + negative_examples=["not a joke"], + ) + .get_built_in_guardrail("Judge", None, True) + .validator_parameters + } + assert params["positiveExamples"].parameter_type == "text-list" + assert params["positiveExamples"].value == ["a clean pun"] + assert params["negativeExamples"].value == ["not a joke"] + + def test_selector_is_none(self): + # Decorator-path convention (matches PIIValidator / PromptInjectionValidator): + # scope comes from the decorated target, not the validator. + guardrail = LLMAsJudgeValidator( + guardrail_text=_RULE, model="m" + ).get_built_in_guardrail("Judge", None, True) + assert guardrail.selector is None + + def test_default_description(self): + guardrail = LLMAsJudgeValidator( + guardrail_text=_RULE, model="m" + ).get_built_in_guardrail("Judge", None, True) + assert guardrail.description == "LLM-as-judge evaluation" + + def test_all_stages_supported(self): + validator = LLMAsJudgeValidator(guardrail_text=_RULE, model="m") + assert validator.supported_stages == [] + # No stage restriction: both PRE and POST validate without raising. + validator.validate_stage(GuardrailExecutionStage.PRE) + validator.validate_stage(GuardrailExecutionStage.POST) + + def test_empty_guardrail_text_raises(self): + with pytest.raises(ValueError, match="guardrail_text"): + LLMAsJudgeValidator(guardrail_text=" ", model="m") + + def test_empty_model_raises(self): + with pytest.raises(ValueError, match="model"): + LLMAsJudgeValidator(guardrail_text=_RULE, model="") + + def test_threshold_out_of_range_raises(self): + with pytest.raises(ValueError, match="threshold"): + LLMAsJudgeValidator(guardrail_text=_RULE, model="m", threshold=7.0) + + def test_guardrail_text_over_limit_raises(self): + with pytest.raises(ValueError, match="guardrail_text exceeds"): + LLMAsJudgeValidator(guardrail_text="x" * 4001, model="m") + + def test_guardrail_text_at_limit_ok(self): + LLMAsJudgeValidator(guardrail_text="x" * 4000, model="m") + + def test_too_many_positive_examples_raises(self): + with pytest.raises(ValueError, match="positive_examples allows at most 2"): + LLMAsJudgeValidator( + guardrail_text=_RULE, model="m", positive_examples=["a", "b", "c"] + ) + + def test_too_many_negative_examples_raises(self): + with pytest.raises(ValueError, match="negative_examples allows at most 2"): + LLMAsJudgeValidator( + guardrail_text=_RULE, model="m", negative_examples=["a", "b", "c"] + ) + + def test_two_examples_each_ok(self): + LLMAsJudgeValidator( + guardrail_text=_RULE, + model="m", + positive_examples=["a", "b"], + negative_examples=["c", "d"], + ) + + def test_positive_example_over_length_raises(self): + with pytest.raises(ValueError, match="positive_examples entry"): + LLMAsJudgeValidator( + guardrail_text=_RULE, model="m", positive_examples=["x" * 1001] + ) + + def test_negative_example_over_length_raises(self): + with pytest.raises(ValueError, match="negative_examples entry"): + LLMAsJudgeValidator( + guardrail_text=_RULE, model="m", negative_examples=["x" * 1001] + ) + + def test_example_at_length_limit_ok(self): + LLMAsJudgeValidator( + guardrail_text=_RULE, model="m", positive_examples=["x" * 1000] + ) diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index f5253f90c..2144530f3 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.4" +version = "0.2.5" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 19e974d12..070142638 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2741,7 +2741,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.4" +version = "0.2.5" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 082e38c84430bdfa78f6abbcd8d7e2a7e62dd4a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrei=20Ancu=C8=9Ba?= Date: Wed, 8 Jul 2026 18:56:32 +0300 Subject: [PATCH 158/191] fix: shutdown trace manager, not the executor, between jobs (#1800) --- packages/uipath/pyproject.toml | 2 +- packages/uipath/src/uipath/_cli/cli_debug.py | 26 ++- packages/uipath/src/uipath/_cli/cli_run.py | 35 ++-- .../tracing/_live_tracking_processor.py | 4 + .../src/uipath/tracing/_otel_exporters.py | 4 + .../eval/test_live_tracking_span_processor.py | 31 +++- .../integration/test_trace_shutdown_server.py | 155 ++++++++++++++++++ packages/uipath/tests/cli/test_server.py | 91 ++-------- packages/uipath/tests/cli/utils/server.py | 93 +++++++++++ .../tests/tracing/test_otel_exporters.py | 11 ++ packages/uipath/uv.lock | 2 +- 11 files changed, 337 insertions(+), 117 deletions(-) create mode 100644 packages/uipath/tests/cli/integration/test_trace_shutdown_server.py create mode 100644 packages/uipath/tests/cli/utils/server.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 035dac2d1..2ca26e087 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.13.6" +version = "2.13.7" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/cli_debug.py b/packages/uipath/src/uipath/_cli/cli_debug.py index fa7e7a170..7f542e871 100644 --- a/packages/uipath/src/uipath/_cli/cli_debug.py +++ b/packages/uipath/src/uipath/_cli/cli_debug.py @@ -138,7 +138,6 @@ async def execute_debug_runtime(): with ExecutionSourceContext(ctx.execution_source), ctx: factory: UiPathRuntimeFactoryProtocol | None = None governance_bootstrap: GovernanceBootstrap | None = None - live_tracking_processor: LiveTrackingSpanProcessor | None = None try: trigger_poll_interval: float = 5.0 @@ -169,12 +168,11 @@ async def execute_debug_runtime(): if ctx.job_id: if UiPathConfig.is_tracing_enabled: - live_tracking_processor = LiveTrackingSpanProcessor( - LlmOpsHttpExporter(), - settings=trace_settings, - ) trace_manager.add_span_processor( - live_tracking_processor + LiveTrackingSpanProcessor( + LlmOpsHttpExporter(), + settings=trace_settings, + ) ) trigger_poll_interval = ( 0.0 # Polling disabled for production jobs @@ -260,15 +258,13 @@ async def execute_debug_runtime(): await execute_debug_runtime() finally: - # Drain runtime-scoped sinks before the factory - # shuts down — the factory may own transports they - # use. (The inner runtime already disposed above.) - if live_tracking_processor is not None: - live_tracking_processor.shutdown() - if governance_bootstrap is not None: - governance_bootstrap.dispose() - if factory: - await factory.dispose() + try: + if governance_bootstrap is not None: + governance_bootstrap.dispose() + if factory: + await factory.dispose() + finally: + trace_manager.shutdown() asyncio.run(execute_debug_runtime()) except Exception as e: diff --git a/packages/uipath/src/uipath/_cli/cli_run.py b/packages/uipath/src/uipath/_cli/cli_run.py index 1fca4b8cb..9d12a86c3 100644 --- a/packages/uipath/src/uipath/_cli/cli_run.py +++ b/packages/uipath/src/uipath/_cli/cli_run.py @@ -221,7 +221,6 @@ async def execute() -> None: chat_runtime: UiPathRuntimeProtocol | None = None factory: UiPathRuntimeFactoryProtocol | None = None governance_bootstrap: GovernanceBootstrap | None = None - live_tracking_processor: LiveTrackingSpanProcessor | None = None try: factory = UiPathRuntimeFactoryRegistry.get(context=ctx) @@ -296,12 +295,11 @@ async def execute() -> None: if ctx.job_id: if UiPathConfig.is_tracing_enabled: - live_tracking_processor = LiveTrackingSpanProcessor( - LlmOpsHttpExporter(), - settings=trace_settings, - ) trace_manager.add_span_processor( - live_tracking_processor + LiveTrackingSpanProcessor( + LlmOpsHttpExporter(), + settings=trace_settings, + ) ) if ctx.conversation_id and ctx.exchange_id: @@ -318,18 +316,19 @@ async def execute() -> None: else: ctx.result = await debug_runtime(ctx, runtime) finally: - if chat_runtime: - await chat_runtime.dispose() - if runtime is not None and runtime is not base_runtime: - await runtime.dispose() - if base_runtime is not None: - await base_runtime.dispose() - if live_tracking_processor is not None: - live_tracking_processor.shutdown() - if governance_bootstrap is not None: - governance_bootstrap.dispose() - if factory: - await factory.dispose() + try: + if chat_runtime: + await chat_runtime.dispose() + if runtime is not None and runtime is not base_runtime: + await runtime.dispose() + if base_runtime is not None: + await base_runtime.dispose() + if governance_bootstrap is not None: + governance_bootstrap.dispose() + if factory: + await factory.dispose() + finally: + trace_manager.shutdown() asyncio.run(execute()) diff --git a/packages/uipath/src/uipath/tracing/_live_tracking_processor.py b/packages/uipath/src/uipath/tracing/_live_tracking_processor.py index 4b0aa0b92..a338a87ed 100644 --- a/packages/uipath/src/uipath/tracing/_live_tracking_processor.py +++ b/packages/uipath/src/uipath/tracing/_live_tracking_processor.py @@ -89,6 +89,10 @@ def shutdown(self) -> None: self.executor.shutdown(wait=True) except Exception as e: logger.debug(f"Executor shutdown failed: {e}") + try: + self.exporter.shutdown() + except Exception as e: + logger.debug(f"Exporter shutdown failed: {e}") def force_flush(self, timeout_millis: int = 30000) -> bool: """Force flush - no-op for live tracking.""" diff --git a/packages/uipath/src/uipath/tracing/_otel_exporters.py b/packages/uipath/src/uipath/tracing/_otel_exporters.py index f8ec3aace..6796c9629 100644 --- a/packages/uipath/src/uipath/tracing/_otel_exporters.py +++ b/packages/uipath/src/uipath/tracing/_otel_exporters.py @@ -176,6 +176,10 @@ def force_flush(self, timeout_millis: int = 30000) -> bool: """Force flush the exporter.""" return True + def shutdown(self) -> None: + """Close the HTTP client.""" + self.http_client.close() + def upsert_span( self, span: ReadableSpan, diff --git a/packages/uipath/tests/cli/eval/test_live_tracking_span_processor.py b/packages/uipath/tests/cli/eval/test_live_tracking_span_processor.py index a20c18d9b..d26e89985 100644 --- a/packages/uipath/tests/cli/eval/test_live_tracking_span_processor.py +++ b/packages/uipath/tests/cli/eval/test_live_tracking_span_processor.py @@ -3,7 +3,7 @@ import threading import time from typing import Any -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from opentelemetry import context as context_api @@ -348,6 +348,35 @@ def test_shutdown_waits_for_pending_tasks(self, mock_exporter): # Verify executor is shutdown (calling shutdown multiple times should be safe) processor.shutdown() # Should not raise + def test_shutdown_closes_exporter_after_pending_tasks(self, mock_exporter): + calls = [] + + def upsert(*args, **kwargs): + calls.append("upsert") + + def shutdown(): + calls.append("shutdown") + + mock_exporter.upsert_span = Mock(side_effect=upsert) + mock_exporter.shutdown = Mock(side_effect=shutdown) + processor = LiveTrackingSpanProcessor(mock_exporter, max_workers=1) + span = self.create_mock_span({"span_type": "eval"}) + + processor.on_start(span, None) + processor.shutdown() + + assert calls == ["upsert", "shutdown"] + + def test_shutdown_logs_exporter_shutdown_failure(self, mock_exporter): + mock_exporter.shutdown = Mock(side_effect=RuntimeError("close failed")) + processor = LiveTrackingSpanProcessor(mock_exporter) + + with patch("uipath.tracing._live_tracking_processor.logger.debug") as debug: + processor.shutdown() + + mock_exporter.shutdown.assert_called_once_with() + debug.assert_called_once_with("Exporter shutdown failed: close failed") + def test_multiple_processors_independent_thread_pools(self, mock_exporter): """Test that multiple processors have independent thread pools.""" processor1 = LiveTrackingSpanProcessor(mock_exporter, max_workers=5) diff --git a/packages/uipath/tests/cli/integration/test_trace_shutdown_server.py b/packages/uipath/tests/cli/integration/test_trace_shutdown_server.py new file mode 100644 index 000000000..754c2fe10 --- /dev/null +++ b/packages/uipath/tests/cli/integration/test_trace_shutdown_server.py @@ -0,0 +1,155 @@ +import asyncio +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any, Generator, cast + +import pytest + +from tests.cli.utils.server import ( + get_free_port, + start_cli_server_thread, + start_job_with_env, +) + + +class TraceServer(ThreadingHTTPServer): + posts: list[bytes] + + +class TraceHandler(BaseHTTPRequestHandler): + def do_POST(self) -> None: + length = int(self.headers.get("content-length", "0")) + body = self.rfile.read(length) + cast(TraceServer, self.server).posts.append(body) + self.send_response(200) + self.end_headers() + self.wfile.write(b"OK") + + def log_message(self, format: str, *args: object) -> None: + return + + +@pytest.fixture +def trace_stub() -> Generator[tuple[TraceServer, str], None, None]: + port = get_free_port() + server = TraceServer(("127.0.0.1", port), TraceHandler) + server.posts = [] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + try: + yield server, f"http://127.0.0.1:{port}" + finally: + server.shutdown() + server.server_close() + + +def write_project(project: Path) -> None: + (project / "entrypoint.py").write_text( + """from dataclasses import dataclass +from uipath.tracing import traced + + +@dataclass +class Input: + message: str + + +@dataclass +class Output: + message: str + + +@traced(name="actual-agent-span") +def main(input: Input) -> Output: + return Output(message=f"ok: {input.message}") +""", + encoding="utf-8", + ) + (project / "uipath.json").write_text( + json.dumps({"agents": {"main": "entrypoint.py:main"}}, indent=2), + encoding="utf-8", + ) + + +async def run_traced_job( + port: int, + project: Path, + trace_url: str, + job_key: str, + command: str, + extra_args: list[str], +) -> tuple[dict[str, Any], Path]: + input_file = project / f"{job_key}-input.json" + output_file = project / f"{job_key}-output.json" + input_file.write_text(json.dumps({"message": job_key}), encoding="utf-8") + + response = await start_job_with_env( + port, + job_key, + command, + [ + "main", + "--input-file", + str(input_file), + "--output-file", + str(output_file), + *extra_args, + ], + { + "UIPATH_ACCESS_TOKEN": "fake-token", + "UIPATH_JOB_KEY": job_key, + "UIPATH_ORGANIZATION_ID": "org-123", + "UIPATH_TENANT_ID": "tenant-123", + "UIPATH_TRACE_BASE_URL": trace_url, + "UIPATH_TRACING_ENABLED": "true", + "LOG_LEVEL": "DEBUG", + }, + working_directory=str(project), + ) + return response, output_file + + +def read_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +@pytest.mark.parametrize( + ("command", "extra_args"), + [ + ("run", []), + ("debug", ["--attach", "none"]), + ], +) +def test_server_runs_two_traced_jobs_after_trace_shutdown( + tmp_path: Path, + trace_stub: tuple[TraceServer, str], + command: str, + extra_args: list[str], +) -> None: + port = get_free_port() + start_cli_server_thread(port) + + trace_server, trace_url = trace_stub + project = tmp_path / "project" + project.mkdir() + write_project(project) + + job1_response, job1_output = asyncio.run( + run_traced_job(port, project, trace_url, "job-1", command, extra_args) + ) + job2_response, job2_output = asyncio.run( + run_traced_job(port, project, trace_url, "job-2", command, extra_args) + ) + + assert job1_response["success"] is True, job1_response + assert job2_response["success"] is True, job2_response + assert read_json(job1_output) == {"message": "ok: job-1"} + assert read_json(job2_output) == {"message": "ok: job-2"} + + result = read_json(project / "__uipath" / "output.json") + assert result["status"] == "successful" + assert "error" not in result + assert trace_server.posts diff --git a/packages/uipath/tests/cli/test_server.py b/packages/uipath/tests/cli/test_server.py index 7e069bd62..c4d73f361 100644 --- a/packages/uipath/tests/cli/test_server.py +++ b/packages/uipath/tests/cli/test_server.py @@ -3,15 +3,18 @@ import os import subprocess import sys -import threading import time -from typing import Any from urllib.request import urlopen import aiohttp import pytest -from uipath._cli.cli_server import start_tcp_server +from tests.cli.utils.server import ( + get_free_port, + start_cli_server_thread, + start_job, + start_job_with_env, +) SERVER_START_TIMEOUT_SECONDS = 20 SERVER_STOP_TIMEOUT_SECONDS = 5 @@ -38,47 +41,6 @@ def create_uipath_json(script_path: str, entrypoint_name: str = "main"): return {"functions": {entrypoint_name: f"{script_path}:main"}} -async def start_job( - port: int, job_key: str, command: str, args: list[str] -) -> dict[str, Any]: - """Start a job on the server.""" - async with aiohttp.ClientSession() as session: - async with session.post( - f"http://127.0.0.1:{port}/jobs/{job_key}/start", - json={"command": command, "args": args}, - ) as response: - return await response.json() - - -async def start_job_with_env( - port: int, - job_key: str, - command: str, - args: list[str], - env_vars: dict[str, str], -) -> dict[str, Any]: - """Start a job on the server with environment variables.""" - async with aiohttp.ClientSession() as session: - async with session.post( - f"http://127.0.0.1:{port}/jobs/{job_key}/start", - json={ - "command": command, - "args": args, - "environmentVariables": env_vars, - }, - ) as response: - return await response.json() - - -def get_free_port() -> int: - """Use a random available port for testing.""" - import socket - - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - def read_process_output(process: subprocess.Popen[str]) -> str: if process.stdout is None: return "" @@ -128,29 +90,12 @@ class TestServer: @pytest.fixture def server_port(self): """Use a random available port for testing.""" - import socket - - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] + return get_free_port() @pytest.fixture def server(self, server_port): """Start the server in a background thread.""" - - def run_server(): - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - loop.run_until_complete(start_tcp_server("127.0.0.1", server_port)) - except asyncio.CancelledError: - pass - finally: - loop.close() - - thread = threading.Thread(target=run_server, daemon=True) - thread.start() - time.sleep(0.5) + start_cli_server_thread(server_port) yield server_port @@ -381,11 +326,7 @@ class TestServerEnvIsolation: @pytest.fixture def server_port(self): - import socket - - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] + return get_free_port() @pytest.fixture def env_snapshots(self): @@ -405,19 +346,7 @@ def spy_cmd(): original_commands = cli_server.COMMANDS.copy() cli_server.COMMANDS["spy"] = spy_cmd - def run_server(): - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - loop.run_until_complete(start_tcp_server("127.0.0.1", server_port)) - except asyncio.CancelledError: - pass - finally: - loop.close() - - thread = threading.Thread(target=run_server, daemon=True) - thread.start() - time.sleep(0.5) + start_cli_server_thread(server_port) yield server_port diff --git a/packages/uipath/tests/cli/utils/server.py b/packages/uipath/tests/cli/utils/server.py new file mode 100644 index 000000000..abf1681f8 --- /dev/null +++ b/packages/uipath/tests/cli/utils/server.py @@ -0,0 +1,93 @@ +import asyncio +import socket +import threading +import time +from typing import Any +from urllib.request import urlopen + +import aiohttp + +from uipath._cli.cli_server import start_tcp_server +from uipath.functions import register_default_runtime_factory + + +def get_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _wait_for_health(port: int, timeout: float = 10.0) -> None: + deadline = time.monotonic() + timeout + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + with urlopen(f"http://127.0.0.1:{port}/health", timeout=0.5) as response: + if response.status == 200: + return + except Exception as exc: + last_error = exc + time.sleep(0.05) + raise RuntimeError(f"server on port {port} did not become healthy") from last_error + + +def start_cli_server_thread(port: int) -> threading.Thread: + register_default_runtime_factory() + + def run_server() -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(start_tcp_server("127.0.0.1", port)) + except asyncio.CancelledError: + pass + finally: + loop.close() + + thread = threading.Thread(target=run_server, daemon=True) + thread.start() + _wait_for_health(port) + return thread + + +async def start_job( + port: int, + job_key: str, + command: str, + args: list[str], + working_directory: str | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = {"command": command, "args": args} + if working_directory is not None: + payload["workingDirectory"] = working_directory + + async with aiohttp.ClientSession() as session: + async with session.post( + f"http://127.0.0.1:{port}/jobs/{job_key}/start", + json=payload, + ) as response: + return await response.json() + + +async def start_job_with_env( + port: int, + job_key: str, + command: str, + args: list[str], + env_vars: dict[str, str], + working_directory: str | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "command": command, + "args": args, + "environmentVariables": env_vars, + } + if working_directory is not None: + payload["workingDirectory"] = working_directory + + async with aiohttp.ClientSession() as session: + async with session.post( + f"http://127.0.0.1:{port}/jobs/{job_key}/start", + json=payload, + ) as response: + return await response.json() diff --git a/packages/uipath/tests/tracing/test_otel_exporters.py b/packages/uipath/tests/tracing/test_otel_exporters.py index 939dbb8cd..c8630b539 100644 --- a/packages/uipath/tests/tracing/test_otel_exporters.py +++ b/packages/uipath/tests/tracing/test_otel_exporters.py @@ -161,6 +161,17 @@ def test_force_flush(exporter): assert exporter.force_flush() is True +def test_shutdown_closes_http_client(mock_env_vars): + with patch("uipath.tracing._otel_exporters.httpx.Client") as mock_client_class: + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + exporter = LlmOpsHttpExporter() + exporter.shutdown() + + mock_client.close.assert_called_once_with() + + def test_get_base_url(): """Test _get_base_url method with different environment configurations.""" # Test with environment variable set diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 070142638..19081c3f3 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2598,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.6" +version = "2.13.7" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 066f60cf6b6bfe15461c89c2c36abffef978af0d Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Wed, 8 Jul 2026 16:41:03 -0700 Subject: [PATCH 159/191] feat(eval): add agent memory settings to eval sets and CLI override (#1801) Co-authored-by: Claude Fable 5 --- packages/uipath/pyproject.toml | 2 +- packages/uipath/src/uipath/_cli/cli_eval.py | 65 ++++++ packages/uipath/src/uipath/eval/helpers.py | 2 + .../src/uipath/eval/models/evaluation_set.py | 24 ++ .../cli/eval/test_agent_memory_settings.py | 207 ++++++++++++++++++ packages/uipath/uv.lock | 2 +- 6 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 packages/uipath/tests/cli/eval/test_agent_memory_settings.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 2ca26e087..1732eb0ea 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.13.7" +version = "2.13.8" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/cli_eval.py b/packages/uipath/src/uipath/_cli/cli_eval.py index 12ae1f062..547266da7 100644 --- a/packages/uipath/src/uipath/_cli/cli_eval.py +++ b/packages/uipath/src/uipath/_cli/cli_eval.py @@ -119,6 +119,57 @@ def _resolve_model_settings_override( return override if override else None +def _resolve_agent_memory_settings_override( + agent_memory_settings_id: str, evaluation_set: EvaluationSet +) -> dict[str, Any]: + """Resolve agent memory settings override from evaluation set. + + Evaluation runs never consume agent memory unless the evaluation set + explicitly enables it (agentMemoryEnabled), mirroring the Agents + backend behavior for eval runs. + + Returns: + Memory override dict passed to the factory via the + agent_memory_settings kwarg. ``{"enabled": False}`` disables agent + memory for the run; ``{"enabled": True, ...}`` force-enables it with + the selected settings ("same-as-agent" values preserve the agent's + own configuration). + """ + if not evaluation_set.agent_memory_enabled: + return {"enabled": False} + + memory_settings = evaluation_set.agent_memory_settings + target = None + if agent_memory_settings_id: + # "default" is looked up like any other id: the eval-set editor persists a + # "default" entry (all fields "same-as-agent") alongside user-defined ones. + target = next( + (ms for ms in memory_settings if ms.id == agent_memory_settings_id), + None, + ) + if not target and agent_memory_settings_id != "default": + logger.warning( + f"Agent memory settings ID '{agent_memory_settings_id}' not found in evaluation set" + ) + if target is None: + target = memory_settings[0] if memory_settings else None + + if target is None: + # Memory enabled but no settings configured: keep the agent's own configuration + return {"enabled": True} + + logger.info( + f"Applying agent memory settings override: searchMode={target.search_mode}, " + f"resultCount={target.result_count}, threshold={target.threshold}" + ) + return { + "enabled": True, + "resultCount": target.result_count, + "searchMode": target.search_mode, + "threshold": target.threshold, + } + + class _EvalDiscoveryError(EntrypointDiscoveryException): """Raised when auto-discovery of entrypoint or eval set fails.""" @@ -203,6 +254,12 @@ def _discover_eval_sets() -> list[Path]: default="default", help="Model settings ID from evaluation set to override agent settings (default: 'default')", ) +@click.option( + "--agent-memory-settings-id", + type=str, + default="default", + help="Agent memory settings ID from evaluation set to override agent memory settings (default: 'default')", +) @click.option( "--trace-file", required=False, @@ -244,6 +301,7 @@ def eval( enable_mocker_cache: bool, report_coverage: bool, model_settings_id: str, + agent_memory_settings_id: str, trace_file: str | None, max_llm_concurrency: int, input_overrides: dict[str, Any], @@ -262,6 +320,7 @@ def eval( enable_mocker_cache: Enable caching for LLM mocker responses report_coverage: Report evaluation coverage model_settings_id: Model settings ID to override agent settings + agent_memory_settings_id: Agent memory settings ID to override agent memory settings trace_file: File path where traces will be written in JSONL format max_llm_concurrency: Maximum concurrent LLM requests input_overrides: Input field overrides mapping (direct field override with deep merge) @@ -403,11 +462,17 @@ async def execute_eval(): settings_override = _resolve_model_settings_override( model_settings_id, eval_context.evaluation_set ) + agent_memory_settings_override = ( + _resolve_agent_memory_settings_override( + agent_memory_settings_id, eval_context.evaluation_set + ) + ) runtime = await runtime_factory.new_runtime( entrypoint=eval_context.entrypoint or "", runtime_id=eval_context.execution_id, settings=settings_override, + agent_memory_settings=agent_memory_settings_override, ) eval_context.runtime_schema = await runtime.get_schema() diff --git a/packages/uipath/src/uipath/eval/helpers.py b/packages/uipath/src/uipath/eval/helpers.py index 8405e4a7a..167858813 100644 --- a/packages/uipath/src/uipath/eval/helpers.py +++ b/packages/uipath/src/uipath/eval/helpers.py @@ -212,6 +212,8 @@ def migrate_evaluation_item( for evaluation in eval_set.evaluations ], model_settings=eval_set.model_settings, + agent_memory_enabled=eval_set.agent_memory_enabled, + agent_memory_settings=eval_set.agent_memory_settings, ) except ValidationError as e: raise ValueError( diff --git a/packages/uipath/src/uipath/eval/models/evaluation_set.py b/packages/uipath/src/uipath/eval/models/evaluation_set.py index c80da8e14..889f0f0e9 100644 --- a/packages/uipath/src/uipath/eval/models/evaluation_set.py +++ b/packages/uipath/src/uipath/eval/models/evaluation_set.py @@ -89,6 +89,22 @@ class EvaluationSetModelSettings(BaseModel): temperature: float | str | None = Field(default=None, alias="temperature") +class EvaluationSetAgentMemorySettings(BaseModel): + """Agent memory setting overrides within evaluation sets with ID. + + Values are stored as strings; the literal "same-as-agent" preserves the + agent's own memory configuration for that field. Matches the Agents + eval-set storage schema (agentMemorySettings). + """ + + model_config = ConfigDict(populate_by_name=True) + + id: str = Field(..., alias="id") + result_count: str = Field(default="same-as-agent", alias="resultCount") + search_mode: str = Field(default="same-as-agent", alias="searchMode") + threshold: str = Field(default="same-as-agent", alias="threshold") + + class EvaluationItem(BaseModel): """Individual evaluation item within an evaluation set.""" @@ -177,6 +193,10 @@ class EvaluationSet(BaseModel): model_settings: list[EvaluationSetModelSettings] = Field( default_factory=list, alias="modelSettings" ) + agent_memory_enabled: bool = Field(default=False, alias="agentMemoryEnabled") + agent_memory_settings: list[EvaluationSetAgentMemorySettings] = Field( + default_factory=list, alias="agentMemorySettings" + ) def extract_selected_evals(self, eval_ids) -> None: """Filter evaluations to only include those with specified IDs.""" @@ -209,6 +229,10 @@ class LegacyEvaluationSet(BaseModel): model_settings: list[EvaluationSetModelSettings] = Field( default_factory=list, alias="modelSettings" ) + agent_memory_enabled: bool = Field(default=False, alias="agentMemoryEnabled") + agent_memory_settings: list[EvaluationSetAgentMemorySettings] = Field( + default_factory=list, alias="agentMemorySettings" + ) created_at: str = Field(alias="createdAt") updated_at: str = Field(alias="updatedAt") diff --git a/packages/uipath/tests/cli/eval/test_agent_memory_settings.py b/packages/uipath/tests/cli/eval/test_agent_memory_settings.py new file mode 100644 index 000000000..fa550327a --- /dev/null +++ b/packages/uipath/tests/cli/eval/test_agent_memory_settings.py @@ -0,0 +1,207 @@ +"""Tests for agent memory settings in evaluation sets and the eval CLI override.""" + +import json +import tempfile +from pathlib import Path + +from uipath._cli.cli_eval import _resolve_agent_memory_settings_override +from uipath.eval.helpers import EvalHelpers +from uipath.eval.models.evaluation_set import ( + EvaluationSet, + EvaluationSetAgentMemorySettings, +) + + +def make_eval_set(**overrides) -> EvaluationSet: + data = { + "id": "eval-set-1", + "name": "Test Eval Set", + "version": "1.0", + "evaluatorConfigs": [], + "evaluations": [], + **overrides, + } + return EvaluationSet.model_validate(data) + + +class TestEvaluationSetSchema: + def test_parses_agent_memory_fields(self): + eval_set = make_eval_set( + agentMemoryEnabled=True, + agentMemorySettings=[ + { + "id": "primary-memory", + "resultCount": "5", + "searchMode": "hybrid", + "threshold": "0.8", + } + ], + ) + + assert eval_set.agent_memory_enabled is True + assert len(eval_set.agent_memory_settings) == 1 + setting = eval_set.agent_memory_settings[0] + assert setting.id == "primary-memory" + assert setting.result_count == "5" + assert setting.search_mode == "hybrid" + assert setting.threshold == "0.8" + + def test_defaults_preserve_existing_eval_sets(self): + eval_set = make_eval_set() + + assert eval_set.agent_memory_enabled is False + assert eval_set.agent_memory_settings == [] + + def test_memory_settings_default_to_same_as_agent(self): + setting = EvaluationSetAgentMemorySettings(id="s1") + + assert setting.result_count == "same-as-agent" + assert setting.search_mode == "same-as-agent" + assert setting.threshold == "same-as-agent" + + def test_serializes_with_camel_case_aliases(self): + eval_set = make_eval_set( + agentMemoryEnabled=True, + agentMemorySettings=[{"id": "s1", "searchMode": "semantic"}], + ) + + dumped = eval_set.model_dump(by_alias=True) + assert dumped["agentMemoryEnabled"] is True + assert dumped["agentMemorySettings"][0]["searchMode"] == "semantic" + + +class TestLegacyEvalSetMigration: + def test_legacy_migration_preserves_agent_memory_fields(self): + legacy_eval_set = { + "fileName": "test-eval.json", + "id": "test-eval-set-id", + "name": "Test Eval Set", + "batchSize": 10, + "evaluatorRefs": ["evaluator1"], + "evaluations": [], + "modelSettings": [], + "agentMemoryEnabled": True, + "agentMemorySettings": [ + { + "id": "primary-memory", + "resultCount": "5", + "searchMode": "hybrid", + "threshold": "0.8", + } + ], + "createdAt": "2025-01-26T00:00:00.000Z", + "updatedAt": "2025-01-26T00:00:00.000Z", + } + + with tempfile.TemporaryDirectory() as tmpdir: + eval_file = Path(tmpdir) / "eval-set.json" + eval_file.write_text(json.dumps(legacy_eval_set)) + + loaded_eval_set, _ = EvalHelpers.load_eval_set(str(eval_file)) + + assert loaded_eval_set.agent_memory_enabled is True + assert len(loaded_eval_set.agent_memory_settings) == 1 + assert loaded_eval_set.agent_memory_settings[0].id == "primary-memory" + + +class TestResolveAgentMemorySettingsOverride: + def test_memory_disabled_returns_disabled_override(self): + eval_set = make_eval_set(agentMemoryEnabled=False) + + override = _resolve_agent_memory_settings_override("default", eval_set) + + assert override == {"enabled": False} + + def test_memory_enabled_default_id_uses_first_setting(self): + eval_set = make_eval_set( + agentMemoryEnabled=True, + agentMemorySettings=[ + { + "id": "s1", + "resultCount": "5", + "searchMode": "hybrid", + "threshold": "0.8", + }, + { + "id": "s2", + "resultCount": "10", + "searchMode": "semantic", + "threshold": "0.5", + }, + ], + ) + + override = _resolve_agent_memory_settings_override("default", eval_set) + + assert override == { + "enabled": True, + "resultCount": "5", + "searchMode": "hybrid", + "threshold": "0.8", + } + + def test_memory_enabled_selects_setting_by_id(self): + eval_set = make_eval_set( + agentMemoryEnabled=True, + agentMemorySettings=[ + {"id": "s1", "searchMode": "hybrid"}, + { + "id": "s2", + "resultCount": "10", + "searchMode": "semantic", + "threshold": "0.5", + }, + ], + ) + + override = _resolve_agent_memory_settings_override("s2", eval_set) + + assert override == { + "enabled": True, + "resultCount": "10", + "searchMode": "semantic", + "threshold": "0.5", + } + + def test_default_id_matches_persisted_default_entry(self): + # The eval-set editor persists a "default" entry (all same-as-agent) + # alongside user-defined settings; it may not be first in the list. + eval_set = make_eval_set( + agentMemoryEnabled=True, + agentMemorySettings=[ + { + "id": "s1", + "resultCount": "10", + "searchMode": "semantic", + "threshold": "0.5", + }, + {"id": "default"}, + ], + ) + + override = _resolve_agent_memory_settings_override("default", eval_set) + + assert override == { + "enabled": True, + "resultCount": "same-as-agent", + "searchMode": "same-as-agent", + "threshold": "same-as-agent", + } + + def test_unknown_id_falls_back_to_first_setting(self): + eval_set = make_eval_set( + agentMemoryEnabled=True, + agentMemorySettings=[{"id": "s1", "searchMode": "hybrid"}], + ) + + override = _resolve_agent_memory_settings_override("missing", eval_set) + + assert override["enabled"] is True + assert override["searchMode"] == "hybrid" + + def test_memory_enabled_without_settings_keeps_agent_configuration(self): + eval_set = make_eval_set(agentMemoryEnabled=True) + + override = _resolve_agent_memory_settings_override("default", eval_set) + + assert override == {"enabled": True} diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 19081c3f3..631a1a610 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2598,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.7" +version = "2.13.8" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 68ac610a43324ace3632ed36759f6981a0dd0f53 Mon Sep 17 00:00:00 2001 From: Andrei Petraru Date: Thu, 9 Jul 2026 17:48:06 +0300 Subject: [PATCH 160/191] docs: document LLM-as-judge guardrail validator [AL-469] (#1805) Co-authored-by: Claude Opus 4.8 (1M context) --- packages/uipath/docs/core/guardrails.md | 41 ++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/uipath/docs/core/guardrails.md b/packages/uipath/docs/core/guardrails.md index 4c380c20c..6a220bfe2 100644 --- a/packages/uipath/docs/core/guardrails.md +++ b/packages/uipath/docs/core/guardrails.md @@ -95,7 +95,7 @@ The `stage` parameter controls when the guardrail evaluates. Not all validators |-------|---------------|--------------| | `PRE` | Before the function runs | All validators | | `POST` | After the function runs | All except `UserPromptAttacksValidator` | -| `PRE_AND_POST` | Both before and after | `PIIValidator`, `HarmfulContentValidator`, `CustomValidator` | +| `PRE_AND_POST` | Both before and after | `PIIValidator`, `HarmfulContentValidator`, `LLMAsJudgeValidator`, `CustomValidator` | ## Built-in Validators @@ -208,6 +208,40 @@ def generate_code(spec: str) -> str: ... ``` +### LLM-as-judge + +Evaluates content against a rule written in plain language, using a judge LLM to decide whether the payload complies. Use it for policy checks that are hard to express as fixed entities or rules — tone, topicality, disclaimers, domain-specific policies. + +```python +from uipath.platform.guardrails import ( + BlockAction, + GuardrailExecutionStage, + LLMAsJudgeValidator, + guardrail, +) + +@guardrail( + validator=LLMAsJudgeValidator( + guardrail_text=( + "The response must remain professional and must not contain " + "financial or investment advice." + ), + model="gpt-4o-2024-08-06", + threshold=2, + ), + action=BlockAction(), + name="No financial advice", + stage=GuardrailExecutionStage.POST, +) +def answer_question(question: str) -> str: + ... +``` + +- `guardrail_text` (required) — the rule the judge evaluates against, at most 4000 characters. +- `model` (required) — the judge model id, e.g. `"gpt-4o-2024-08-06"`. It must be a model your organization's governance policy allows for the LLM-as-judge guardrail — LLM Gateway enforces the permitted list, so a model that isn't authorized for judging is rejected. +- `threshold` — strictness from `0` (strictest) to `6` (most lenient), defaulting to `2`. Higher values flag only clear violations. +- `positive_examples` / `negative_examples` — optional example payloads (not descriptions) that comply with / violate the rule, used to calibrate the judge. At most 2 entries per list, each at most 1000 characters. + ## Actions Actions define what happens when a violation is detected. @@ -433,6 +467,11 @@ print(result.result, result.reason) members: - IntellectualPropertyValidator +::: uipath.platform.guardrails.decorators.validators.llm_as_judge + options: + members: + - LLMAsJudgeValidator + ::: uipath.platform.guardrails.decorators.validators.user_prompt_attacks options: members: From 62081f594bac56bfa51413180c8e2c217ef7f5dd Mon Sep 17 00:00:00 2001 From: JosephMar Date: Thu, 9 Jul 2026 12:51:00 -0700 Subject: [PATCH 161/191] fix(tracing): stop fabricating EndTime for live spans (#1806) Co-authored-by: Claude Fable 5 --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/common/_span_utils.py | 11 +++++-- .../tests/services/test_span_utils.py | 31 +++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 5 files changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 72b0d0b3a..7fcf797e7 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.5" +version = "0.2.6" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py index cd0d9d709..111dd59ec 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -237,7 +237,11 @@ class UiPathSpan: attributes: str | Dict[str, Any] # Support both str (legacy) and dict (optimized) parent_id: Optional[str] = None # 16-char hex (OTEL span ID format) start_time: str = field(default_factory=lambda: datetime.now().isoformat()) - end_time: str = field(default_factory=lambda: datetime.now().isoformat()) + # None means the span has not ended yet. Serialized as null — the LLMOps v3 + # ingest contract (SpanV3Req.EndTime) is nullable, and downstream consumers + # (traceview UI, Insights OTLP export) rely on "EndTime set" meaning "span + # ended": fabricating a value here makes in-progress snapshots look terminal. + end_time: Optional[str] = None status: SpanStatus = SpanStatus.OK created_at: str = field(default_factory=lambda: datetime.now().isoformat() + "Z") updated_at: str = field(default_factory=lambda: datetime.now().isoformat() + "Z") @@ -542,13 +546,14 @@ def otel_span_to_uipath_span( (otel_span.start_time or 0) / 1e9 ).isoformat() + # A live (not-yet-ended) OTel span has end_time None — preserve that rather + # than stamping now(): a fabricated EndTime makes in-progress upserts + # (upsert_span RUNNING → OK lifecycle) indistinguishable from ended spans. end_time_str = None if otel_span.end_time is not None: end_time_str = datetime.fromtimestamp( (otel_span.end_time or 0) / 1e9 ).isoformat() - else: - end_time_str = datetime.now().isoformat() return UiPathSpan( id=span_id, diff --git a/packages/uipath-platform/tests/services/test_span_utils.py b/packages/uipath-platform/tests/services/test_span_utils.py index f45351864..b80d24e9d 100644 --- a/packages/uipath-platform/tests/services/test_span_utils.py +++ b/packages/uipath-platform/tests/services/test_span_utils.py @@ -1214,3 +1214,34 @@ def test_on_start_stamps_full_hierarchy(self) -> None: assert "version" not in hierarchy[1] finally: ReferenceContextAccessor.reset(token) + + +class TestLiveSpanEndTime: + """A live (not-yet-ended) OTEL span must convert with end_time=None. + + Fabricating EndTime=now() for in-progress upserts (the RUNNING -> OK + lifecycle used by upsert_span) makes open snapshots indistinguishable + from ended spans downstream: the traceview UI shows phantom sub-ms + durations and the Insights OTLP export's unclosed-span filter never + fires, so every span exports twice. + """ + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_live_span_converts_with_none_end_time(self) -> None: + mock_span = _make_otel_span({}) + mock_span.end_time = None + + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + span_dict = uipath_span.to_dict() + + assert uipath_span.end_time is None + assert span_dict["EndTime"] is None + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_ended_span_keeps_real_end_time(self) -> None: + mock_span = _make_otel_span({}) + + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + + assert uipath_span.end_time is not None + assert uipath_span.to_dict()["EndTime"] == uipath_span.end_time diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 2144530f3..cee6cf645 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.5" +version = "0.2.6" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 631a1a610..800e2b56a 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2741,7 +2741,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.5" +version = "0.2.6" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 22f5687ddbe68ad07ffde1c7ed2f81479175e727 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Thu, 9 Jul 2026 17:41:56 -0700 Subject: [PATCH 162/191] fix: classify invalid function inputs as user errors instead of runtime crashes (#1804) Co-authored-by: Claude Fable 5 --- packages/uipath/pyproject.toml | 2 +- .../src/uipath/_cli/_evals/_telemetry.py | 13 +- .../uipath/src/uipath/eval/runtime/runtime.py | 29 ++- .../uipath/src/uipath/functions/runtime.py | 33 +++- .../tests/cli/eval/test_eval_telemetry.py | 39 ++++ .../eval/test_runtime_error_classification.py | 171 ++++++++++++++++++ .../tests/functions/test_input_validation.py | 106 +++++++++++ packages/uipath/uv.lock | 2 +- 8 files changed, 378 insertions(+), 17 deletions(-) create mode 100644 packages/uipath/tests/cli/eval/test_runtime_error_classification.py create mode 100644 packages/uipath/tests/functions/test_input_validation.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 1732eb0ea..9d5f87757 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.13.8" +version = "2.13.9" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/_evals/_telemetry.py b/packages/uipath/src/uipath/_cli/_evals/_telemetry.py index b3dfb160e..64843972b 100644 --- a/packages/uipath/src/uipath/_cli/_evals/_telemetry.py +++ b/packages/uipath/src/uipath/_cli/_evals/_telemetry.py @@ -20,6 +20,7 @@ ) from uipath.platform.common import UiPathConfig from uipath.platform.constants import ENV_TENANT_ID +from uipath.runtime.errors import UiPathBaseRuntimeError from uipath.telemetry._track import is_telemetry_enabled, track_event logger = logging.getLogger(__name__) @@ -238,15 +239,15 @@ async def _on_eval_run_updated(self, event: EvalRunUpdatedEvent) -> None: ) if event.exception_details: - properties["ErrorType"] = type( - event.exception_details.exception - ).__name__ - properties["ErrorMessage"] = str(event.exception_details.exception)[ - :500 - ] + exception = event.exception_details.exception + properties["ErrorType"] = type(exception).__name__ + properties["ErrorMessage"] = str(exception)[:500] properties["IsRuntimeException"] = ( event.exception_details.runtime_exception ) + if isinstance(exception, UiPathBaseRuntimeError): + properties["ErrorCode"] = exception.error_info.code + properties["ErrorCategory"] = exception.error_info.category.value self._enrich_properties(properties) diff --git a/packages/uipath/src/uipath/eval/runtime/runtime.py b/packages/uipath/src/uipath/eval/runtime/runtime.py index 124eedf47..f4e3cbbb8 100644 --- a/packages/uipath/src/uipath/eval/runtime/runtime.py +++ b/packages/uipath/src/uipath/eval/runtime/runtime.py @@ -38,6 +38,7 @@ UiPathRuntimeStorageProtocol, ) from uipath.runtime.errors import ( + UiPathBaseRuntimeError, UiPathErrorCategory, UiPathErrorContract, ) @@ -96,6 +97,19 @@ logger = logging.getLogger(__name__) +def _is_user_facing_error(exception: BaseException) -> bool: + """Whether an exception is a correctly-reported workload failure. + + User-category errors (e.g. invalid input, business logic failures) are + failures of the agent under evaluation, not eval infrastructure crashes, + so they must not be flagged as runtime exceptions. + """ + return ( + isinstance(exception, UiPathBaseRuntimeError) + and exception.error_info.category == UiPathErrorCategory.USER + ) + + def compute_evaluator_scores( evaluation_set_results: list[UiPathEvalRunResult], evaluators: Iterable[GenericBaseEvaluator[Any, Any, Any]], @@ -768,7 +782,13 @@ async def _execute_eval( ) except Exception as e: - exception_details = EvalItemExceptionDetails(exception=e) + root_exception: Exception = ( + e.root_exception if isinstance(e, EvaluationRuntimeException) else e + ) + exception_details = EvalItemExceptionDetails( + exception=root_exception, + runtime_exception=not _is_user_facing_error(root_exception), + ) for evaluator in evaluators: evaluation_run_results.evaluation_run_results.append( @@ -793,13 +813,6 @@ async def _execute_eval( if isinstance(e, EvaluationRuntimeException): eval_run_updated_event.spans = e.spans eval_run_updated_event.logs = e.logs - if eval_run_updated_event.exception_details: - eval_run_updated_event.exception_details.exception = ( - e.root_exception - ) - eval_run_updated_event.exception_details.runtime_exception = ( - True - ) await self.event_bus.publish( EvaluationEvents.UPDATE_EVAL_RUN, diff --git a/packages/uipath/src/uipath/functions/runtime.py b/packages/uipath/src/uipath/functions/runtime.py index 3467d72b5..2e4b1045b 100644 --- a/packages/uipath/src/uipath/functions/runtime.py +++ b/packages/uipath/src/uipath/functions/runtime.py @@ -10,6 +10,8 @@ from types import ModuleType from typing import Any, AsyncGenerator, Callable, Type, cast, get_type_hints +from pydantic import ValidationError + from uipath.runtime import ( UiPathExecuteOptions, UiPathRuntimeEvent, @@ -35,6 +37,24 @@ logger = logging.getLogger(__name__) +def _format_input_validation_error( + function_name: str, input_type: Type[Any], error: Exception +) -> str: + """Build a concise, human-readable message for an input conversion failure.""" + type_name = getattr(input_type, "__name__", str(input_type)) + if isinstance(error, ValidationError): + issues = "; ".join( + f"{'.'.join(str(loc) for loc in err['loc']) or type_name}: {err['msg']}" + for err in error.errors() + ) + else: + issues = str(error) + return ( + f"Input does not match the expected schema for " + f"'{function_name}' ({type_name}): {issues}" + ) + + class UiPathFunctionsRuntime: """Runtime wrapper for a single Python function with full script executor compatibility.""" @@ -141,7 +161,18 @@ async def _execute_function( or is_pydantic_model(input_type) or (inspect.isclass(input_type) and hasattr(input_type, "__annotations__")) ): - typed_input = convert_to_class(input_data, cast(Type[Any], input_type)) + try: + typed_input = convert_to_class(input_data, cast(Type[Any], input_type)) + except (ValidationError, TypeError, ValueError) as e: + raise UiPathRuntimeError( + # Closest available code; uipath-runtime 0.12.x has no + # dedicated input-validation error code yet. + UiPathErrorCode.INPUT_INVALID_JSON, + "Invalid input", + _format_input_validation_error(self.function_name, input_type, e), + UiPathErrorCategory.USER, + include_traceback=False, + ) from e result = await func(typed_input) if is_async else func(typed_input) else: # Dict/untyped parameter diff --git a/packages/uipath/tests/cli/eval/test_eval_telemetry.py b/packages/uipath/tests/cli/eval/test_eval_telemetry.py index c0e57490c..fdcaf4a85 100644 --- a/packages/uipath/tests/cli/eval/test_eval_telemetry.py +++ b/packages/uipath/tests/cli/eval/test_eval_telemetry.py @@ -337,6 +337,45 @@ async def test_on_eval_run_updated_failure(self, mock_track_event): assert "Test error" in properties["ErrorMessage"] assert properties["IsRuntimeException"] is True + @pytest.mark.asyncio + @patch("uipath._cli._evals._telemetry.track_event") + async def test_on_eval_run_updated_user_error_includes_classification( + self, mock_track_event + ): + """User-category runtime errors emit ErrorCode/ErrorCategory and are not runtime exceptions.""" + from uipath.runtime.errors import ( + UiPathErrorCategory, + UiPathErrorCode, + UiPathRuntimeError, + ) + + subscriber = EvalTelemetrySubscriber() + error = UiPathRuntimeError( + UiPathErrorCode.INPUT_INVALID_JSON, + "Invalid input", + "Input does not match the expected schema", + UiPathErrorCategory.USER, + include_traceback=False, + ) + exception_details = EvalItemExceptionDetails( + exception=error, + runtime_exception=False, + ) + event = self._create_eval_run_updated_event( + success=False, + exception_details=exception_details, + ) + + await subscriber._on_eval_run_updated(event) + + call_args = mock_track_event.call_args + assert call_args[0][0] == EVAL_RUN_FAILED + properties = call_args[0][1] + assert properties["ErrorType"] == "UiPathRuntimeError" + assert properties["ErrorCode"] == "Python.INPUT_INVALID_JSON" + assert properties["ErrorCategory"] == "User" + assert properties["IsRuntimeException"] is False + @pytest.mark.asyncio @patch("uipath._cli._evals._telemetry.track_event") async def test_on_eval_run_updated_with_scores(self, mock_track_event): diff --git a/packages/uipath/tests/cli/eval/test_runtime_error_classification.py b/packages/uipath/tests/cli/eval/test_runtime_error_classification.py new file mode 100644 index 000000000..721092b4c --- /dev/null +++ b/packages/uipath/tests/cli/eval/test_runtime_error_classification.py @@ -0,0 +1,171 @@ +"""Tests for classifying eval execution errors as user vs runtime failures.""" + +import uuid +from pathlib import Path +from typing import Any, AsyncGenerator + +from uipath.core.events import EventBus +from uipath.core.tracing import UiPathTraceManager +from uipath.eval.helpers import EvalHelpers +from uipath.eval.runtime import UiPathEvalContext, evaluate +from uipath.eval.runtime.events import EvalRunUpdatedEvent, EvaluationEvents +from uipath.eval.runtime.runtime import _is_user_facing_error +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeEvent, + UiPathRuntimeFactorySettings, + UiPathRuntimeProtocol, + UiPathRuntimeResult, + UiPathRuntimeStorageProtocol, + UiPathStreamOptions, +) +from uipath.runtime.errors import ( + UiPathErrorCategory, + UiPathErrorCode, + UiPathRuntimeError, +) +from uipath.runtime.schema import UiPathRuntimeSchema + + +def _make_error(category: UiPathErrorCategory) -> UiPathRuntimeError: + return UiPathRuntimeError( + UiPathErrorCode.FUNCTION_EXECUTION_ERROR, + "Some failure", + "details", + category, + include_traceback=False, + ) + + +def test_user_category_error_is_user_facing(): + assert _is_user_facing_error(_make_error(UiPathErrorCategory.USER)) is True + + +def test_system_category_error_is_not_user_facing(): + assert _is_user_facing_error(_make_error(UiPathErrorCategory.SYSTEM)) is False + + +def test_unknown_category_error_is_not_user_facing(): + assert _is_user_facing_error(_make_error(UiPathErrorCategory.UNKNOWN)) is False + + +def test_plain_exception_is_not_user_facing(): + assert _is_user_facing_error(ValueError("boom")) is False + + +class _FailingRuntime: + """Runtime whose execution always raises the configured error.""" + + def __init__(self, error: Exception): + self._error = error + + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + raise self._error + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + raise self._error + yield # unreachable; makes this an async generator + + async def get_schema(self) -> UiPathRuntimeSchema: + return UiPathRuntimeSchema( + filePath="test.py", + uniqueId="test", + type="workflow", + input={"type": "object", "properties": {}}, + output={"type": "object", "properties": {}}, + ) + + async def dispose(self) -> None: + pass + + +class _FailingFactory: + def __init__(self, error: Exception): + self._error = error + + def discover_entrypoints(self) -> list[str]: + return ["test"] + + async def get_storage(self) -> UiPathRuntimeStorageProtocol | None: + return None + + async def get_settings(self) -> UiPathRuntimeFactorySettings | None: + return None + + async def new_runtime( + self, entrypoint: str, runtime_id: str, **kwargs + ) -> UiPathRuntimeProtocol: + return _FailingRuntime(self._error) + + async def dispose(self) -> None: + pass + + +async def _run_failing_eval(error: Exception) -> list[EvalRunUpdatedEvent]: + """Run an eval whose execution raises, returning captured run-updated events.""" + event_bus = EventBus() + trace_manager = UiPathTraceManager() + captured: list[EvalRunUpdatedEvent] = [] + + async def capture(event: EvalRunUpdatedEvent) -> None: + captured.append(event) + + event_bus.subscribe(EvaluationEvents.UPDATE_EVAL_RUN, capture) + + factory = _FailingFactory(error) + eval_set_path = str(Path(__file__).parent / "evals" / "eval-sets" / "default.json") + evaluation_set, _ = EvalHelpers.load_eval_set(eval_set_path) + runtime = await factory.new_runtime("test", "test-runtime-id") + runtime_schema = await runtime.get_schema() + evaluators = await EvalHelpers.load_evaluators( + eval_set_path, evaluation_set, agent_model=None + ) + + context = UiPathEvalContext() + context.execution_id = str(uuid.uuid4()) + context.evaluation_set = evaluation_set + context.runtime_schema = runtime_schema + context.evaluators = evaluators + + await evaluate(factory, trace_manager, context, event_bus) + await event_bus.wait_for_all() + return captured + + +async def test_user_error_execution_is_not_a_runtime_exception(): + """A USER-category failure is reported unwrapped with runtime_exception=False.""" + error = UiPathRuntimeError( + UiPathErrorCode.INPUT_INVALID_JSON, + "Invalid input", + "Input does not match the expected schema", + UiPathErrorCategory.USER, + include_traceback=False, + ) + events = await _run_failing_eval(error) + + failed = [e for e in events if not e.success] + assert failed + details = failed[0].exception_details + assert details is not None + assert details.exception is error + assert details.runtime_exception is False + + +async def test_unexpected_error_execution_is_a_runtime_exception(): + """A non-user failure in the execution path is flagged as a runtime exception.""" + events = await _run_failing_eval(RuntimeError("infrastructure broke")) + + failed = [e for e in events if not e.success] + assert failed + details = failed[0].exception_details + assert details is not None + assert isinstance(details.exception, RuntimeError) + assert details.runtime_exception is True diff --git a/packages/uipath/tests/functions/test_input_validation.py b/packages/uipath/tests/functions/test_input_validation.py new file mode 100644 index 000000000..e76ca922e --- /dev/null +++ b/packages/uipath/tests/functions/test_input_validation.py @@ -0,0 +1,106 @@ +"""Tests that invalid inputs surface as classified user errors, not crashes.""" + +import textwrap + +import pytest + +from uipath.functions.runtime import UiPathFunctionsRuntime +from uipath.runtime.errors import UiPathErrorCategory, UiPathRuntimeError + + +@pytest.fixture +def calculator_module(tmp_path): + """Create a module with a Pydantic-typed entrypoint.""" + (tmp_path / "calculator.py").write_text( + textwrap.dedent("""\ + from pydantic import BaseModel + + + class CalculatorInput(BaseModel): + a: int + b: int + + + class CalculatorOutput(BaseModel): + result: int + + + async def main(input: CalculatorInput) -> CalculatorOutput: + return CalculatorOutput(result=input.a + input.b) + """) + ) + return tmp_path / "calculator.py" + + +@pytest.mark.asyncio +async def test_valid_input_executes(calculator_module): + """Sanity check: well-formed input still executes normally.""" + runtime = UiPathFunctionsRuntime(str(calculator_module), "main", "calculator") + result = await runtime.execute({"a": 1, "b": 2}) + assert result.output == {"result": 3} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "bad_input", + [ + {"a": "hello", "b": 2}, + {"a": None, "b": 2}, + {"a": [1, 2], "b": 2}, + {"a": {"x": 1}, "b": 2}, + ], +) +async def test_invalid_input_raises_user_error(calculator_module, bad_input): + """Schema-mismatched input yields a USER-category error without a traceback.""" + runtime = UiPathFunctionsRuntime(str(calculator_module), "main", "calculator") + with pytest.raises(UiPathRuntimeError) as exc_info: + await runtime.execute(bad_input) + + error_info = exc_info.value.error_info + assert error_info.category == UiPathErrorCategory.USER + assert error_info.code == "Python.INPUT_INVALID_JSON" + assert error_info.title == "Invalid input" + assert "CalculatorInput" in error_info.detail + assert "main" in error_info.detail + assert "Traceback" not in error_info.detail + + +@pytest.mark.asyncio +async def test_missing_dataclass_field_raises_user_error(tmp_path): + """Non-Pydantic conversion failures (TypeError) are classified the same way.""" + (tmp_path / "shipping.py").write_text( + textwrap.dedent("""\ + from dataclasses import dataclass + + + @dataclass + class ShippingInput: + address: str + zip_code: str + + + async def main(input: ShippingInput) -> dict: + return {"ok": True} + """) + ) + runtime = UiPathFunctionsRuntime(str(tmp_path / "shipping.py"), "main", "shipping") + with pytest.raises(UiPathRuntimeError) as exc_info: + await runtime.execute({"address": "1 Main St"}) + + error_info = exc_info.value.error_info + assert error_info.category == UiPathErrorCategory.USER + assert error_info.title == "Invalid input" + assert "ShippingInput" in error_info.detail + assert "zip_code" in error_info.detail + + +@pytest.mark.asyncio +async def test_invalid_input_error_lists_offending_fields(calculator_module): + """The error detail names each invalid field with the validation message.""" + runtime = UiPathFunctionsRuntime(str(calculator_module), "main", "calculator") + with pytest.raises(UiPathRuntimeError) as exc_info: + await runtime.execute({"a": "hello", "b": "world"}) + + detail = exc_info.value.error_info.detail + assert "a:" in detail + assert "b:" in detail diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 800e2b56a..b14ed59dd 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2598,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.8" +version = "2.13.9" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From e1eac9c552ce90bc99c8e6946164bcc5b759f992 Mon Sep 17 00:00:00 2001 From: Chibi Vikramathithan Date: Fri, 10 Jul 2026 09:48:22 -0700 Subject: [PATCH 163/191] fix(eval): stop stripping -community-agents model suffix before calling LLM Gateway (#1803) Co-authored-by: Claude Sonnet 5 --- packages/uipath/pyproject.toml | 2 +- .../legacy_context_precision_evaluator.py | 5 +- .../eval/evaluators/legacy_evaluator_utils.py | 16 -- .../legacy_faithfulness_evaluator.py | 4 +- .../legacy_llm_as_judge_evaluator.py | 4 - .../evaluators/legacy_trajectory_evaluator.py | 3 - .../eval/evaluators/llm_as_judge_evaluator.py | 4 - .../evaluators/test_llm_judge_model_suffix.py | 219 ++++++++++++++++++ packages/uipath/uv.lock | 2 +- 9 files changed, 224 insertions(+), 35 deletions(-) create mode 100644 packages/uipath/tests/evaluators/test_llm_judge_model_suffix.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 9d5f87757..4628d4707 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.13.9" +version = "2.13.10" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/eval/evaluators/legacy_context_precision_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_context_precision_evaluator.py index f0ea9c3a5..e13776950 100644 --- a/packages/uipath/src/uipath/eval/evaluators/legacy_context_precision_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/legacy_context_precision_evaluator.py @@ -18,7 +18,7 @@ LegacyEvaluatorConfig, track_evaluation_metrics, ) -from .legacy_evaluator_utils import clean_model_name, serialize_object +from .legacy_evaluator_utils import serialize_object class LegacyContextPrecisionEvaluatorConfig(LegacyEvaluatorConfig): @@ -326,8 +326,7 @@ async def _get_structured_llm_response( ToolParametersDefinition, ) - # Remove community-agents suffix from llm model name - model = clean_model_name(self.model) + model = self.model # Create tool definition for context precision evaluation # Note: We pass the array schema as a raw dict because ToolPropertyDefinition diff --git a/packages/uipath/src/uipath/eval/evaluators/legacy_evaluator_utils.py b/packages/uipath/src/uipath/eval/evaluators/legacy_evaluator_utils.py index 76d517cea..b8ee81906 100644 --- a/packages/uipath/src/uipath/eval/evaluators/legacy_evaluator_utils.py +++ b/packages/uipath/src/uipath/eval/evaluators/legacy_evaluator_utils.py @@ -3,22 +3,6 @@ import json from typing import Any, Optional -from uipath.platform.constants import COMMUNITY_agents_SUFFIX - - -def clean_model_name(model: str) -> str: - """Remove community-agents suffix from model name. - - Args: - model: Model name that may have the community suffix - - Returns: - Model name without the community suffix - """ - if model.endswith(COMMUNITY_agents_SUFFIX): - return model.replace(COMMUNITY_agents_SUFFIX, "") - return model - def serialize_object( content: Any, diff --git a/packages/uipath/src/uipath/eval/evaluators/legacy_faithfulness_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_faithfulness_evaluator.py index 20d33a87e..b17ec7f9f 100644 --- a/packages/uipath/src/uipath/eval/evaluators/legacy_faithfulness_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/legacy_faithfulness_evaluator.py @@ -18,7 +18,6 @@ track_evaluation_metrics, ) from .legacy_evaluator_utils import ( - clean_model_name, serialize_object, ) @@ -513,8 +512,7 @@ async def _get_structured_llm_response( ToolParametersDefinition, ) - # Remove community-agents suffix from llm model name - model = clean_model_name(self.model) + model = self.model # Create a dynamic tool definition based on the schema tool = ToolDefinition( diff --git a/packages/uipath/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py index 280e3ee98..ef2ab8a34 100644 --- a/packages/uipath/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py @@ -8,7 +8,6 @@ from uipath.platform import UiPath from uipath.platform.chat import UiPathLlmChatService from uipath.platform.chat.llm_gateway import RequiredToolChoice -from uipath.platform.constants import COMMUNITY_agents_SUFFIX from .._execution_context import eval_set_run_id_context from .._helpers.helpers import is_empty_value @@ -193,10 +192,7 @@ async def _get_llm_response(self, evaluation_prompt: str) -> LLMResponse: Returns: LLMResponse with score and justification """ - # remove community-agents suffix from llm model name model = self.model - if model.endswith(COMMUNITY_agents_SUFFIX): - model = model.replace(COMMUNITY_agents_SUFFIX, "") # Create evaluation tool for function calling (works across all models) evaluation_tool = create_evaluation_tool() diff --git a/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py index 680c41be0..1368d9339 100644 --- a/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py @@ -9,7 +9,6 @@ from uipath.platform import UiPath from uipath.platform.chat import UiPathLlmChatService from uipath.platform.chat.llm_gateway import RequiredToolChoice -from uipath.platform.constants import COMMUNITY_agents_SUFFIX from .._execution_context import eval_set_run_id_context from .._helpers.evaluators_helpers import trace_to_str @@ -163,8 +162,6 @@ async def _get_llm_response(self, evaluation_prompt: str) -> LLMResponse: assert self.llm, "LLM should be initialized before calling this method." model = self.model - if model.endswith(COMMUNITY_agents_SUFFIX): - model = model.replace(COMMUNITY_agents_SUFFIX, "") # Create evaluation tool for function calling (works across all models) evaluation_tool = create_evaluation_tool() diff --git a/packages/uipath/src/uipath/eval/evaluators/llm_as_judge_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/llm_as_judge_evaluator.py index 4285f7965..a38bbde64 100644 --- a/packages/uipath/src/uipath/eval/evaluators/llm_as_judge_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/llm_as_judge_evaluator.py @@ -11,7 +11,6 @@ from uipath.platform import UiPath from uipath.platform.chat import UiPathLlmChatService -from uipath.platform.constants import COMMUNITY_agents_SUFFIX from .._execution_context import eval_set_run_id_context from ..models import ( @@ -211,10 +210,7 @@ async def _get_llm_response(self, evaluation_prompt: str) -> LLMResponse: ToolPropertyDefinition, ) - # Remove community-agents suffix from llm model name model = self.evaluator_config.model - if model.endswith(COMMUNITY_agents_SUFFIX): - model = model.replace(COMMUNITY_agents_SUFFIX, "") # Define function/tool for structured output (works for ALL models via Normalized API) evaluation_tool = ToolDefinition( diff --git a/packages/uipath/tests/evaluators/test_llm_judge_model_suffix.py b/packages/uipath/tests/evaluators/test_llm_judge_model_suffix.py new file mode 100644 index 000000000..de533a963 --- /dev/null +++ b/packages/uipath/tests/evaluators/test_llm_judge_model_suffix.py @@ -0,0 +1,219 @@ +"""Regression tests: LLM-judge evaluators must send the model name to the LLM +Gateway exactly as configured, including a "-community-agents" suffix. + +Community/EU tenants' LLM Gateway routing rules are keyed on the suffixed +model id -- the same id AgentHub sends when it runs the agent itself. +Stripping the suffix before calling the Gateway causes a 417 "No llm routing +rule found for product agentsplaygroundfallback in EU using model ..." for +every Community/EU evaluation run, even though the identical model id works +fine for the agent. +""" + +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from uipath.eval.evaluators.base_legacy_evaluator import LegacyEvaluationCriteria +from uipath.eval.evaluators.legacy_context_precision_evaluator import ( + LegacyContextPrecisionEvaluator, + LegacyContextPrecisionEvaluatorConfig, +) +from uipath.eval.evaluators.legacy_faithfulness_evaluator import ( + LegacyFaithfulnessEvaluator, + LegacyFaithfulnessEvaluatorConfig, +) +from uipath.eval.evaluators.legacy_llm_as_judge_evaluator import ( + LegacyLlmAsAJudgeEvaluator, + LegacyLlmAsAJudgeEvaluatorConfig, +) +from uipath.eval.evaluators.legacy_trajectory_evaluator import ( + LegacyTrajectoryEvaluator, + LegacyTrajectoryEvaluatorConfig, +) +from uipath.eval.evaluators.llm_judge_output_evaluator import LLMJudgeOutputEvaluator +from uipath.eval.evaluators.llm_judge_trajectory_evaluator import ( + LLMJudgeTrajectoryEvaluator, +) +from uipath.eval.models.models import LegacyEvaluatorCategory, LegacyEvaluatorType + +COMMUNITY_MODEL = "gpt-5.4-2026-03-05-community-agents" + + +def _fake_tool_call_response(score: float = 90, justification: str = "ok"): + tool_call = SimpleNamespace( + arguments={"score": score, "justification": justification} + ) + message = SimpleNamespace(tool_calls=[tool_call]) + choice = SimpleNamespace(message=message) + return SimpleNamespace(choices=[choice]) + + +def _legacy_context_precision_evaluator() -> LegacyContextPrecisionEvaluator: + return LegacyContextPrecisionEvaluator( + id="context-precision", + config_type=LegacyContextPrecisionEvaluatorConfig, + evaluation_criteria_type=LegacyEvaluationCriteria, + justification_type=str, + category=LegacyEvaluatorCategory.LlmAsAJudge, + type=LegacyEvaluatorType.ContextPrecision, + name="Context Precision", + description="Evaluates context chunk relevance", + createdAt="2025-01-01T00:00:00Z", + updatedAt="2025-01-01T00:00:00Z", + targetOutputKey="*", + model=COMMUNITY_MODEL, + ) + + +class TestLegacyContextPrecisionEvaluatorSendsConfiguredModel: + @pytest.mark.asyncio + async def test_get_structured_llm_response_sends_full_model_name(self): + evaluator = _legacy_context_precision_evaluator() + mock_chat_completions = AsyncMock(return_value=_fake_tool_call_response()) + evaluator.llm = AsyncMock(chat_completions=mock_chat_completions) + + await evaluator._get_structured_llm_response("some evaluation prompt") + + sent_model = mock_chat_completions.call_args.kwargs["model"] + assert sent_model == COMMUNITY_MODEL + + +def _legacy_faithfulness_evaluator() -> LegacyFaithfulnessEvaluator: + return LegacyFaithfulnessEvaluator( + id="faithfulness", + config_type=LegacyFaithfulnessEvaluatorConfig, + evaluation_criteria_type=LegacyEvaluationCriteria, + justification_type=str, + category=LegacyEvaluatorCategory.LlmAsAJudge, + type=LegacyEvaluatorType.Faithfulness, + name="Faithfulness", + description="Evaluates faithfulness of claims against context", + createdAt="2025-01-01T00:00:00Z", + updatedAt="2025-01-01T00:00:00Z", + targetOutputKey="*", + model=COMMUNITY_MODEL, + ) + + +class TestLegacyFaithfulnessEvaluatorSendsConfiguredModel: + @pytest.mark.asyncio + async def test_get_structured_llm_response_sends_full_model_name(self): + evaluator = _legacy_faithfulness_evaluator() + mock_chat_completions = AsyncMock(return_value=_fake_tool_call_response()) + evaluator.llm = AsyncMock(chat_completions=mock_chat_completions) + + await evaluator._get_structured_llm_response( + "some evaluation prompt", "submit_result", {"type": "object"} + ) + + sent_model = mock_chat_completions.call_args.kwargs["model"] + assert sent_model == COMMUNITY_MODEL + + +class TestLLMJudgeOutputEvaluatorSendsConfiguredModel: + """Covers LLMJudgeMixin._get_llm_response -- the code path hit by + 'uipath-llm-judge-output-semantic-similarity' in production.""" + + @pytest.mark.asyncio + async def test_get_llm_response_sends_full_model_name_to_gateway(self): + config = { + "name": "TestEvaluator", + "prompt": "Evaluate {{ActualOutput}} against {{ExpectedOutput}}", + "model": COMMUNITY_MODEL, + } + with patch("uipath.platform.UiPath"): + evaluator = LLMJudgeOutputEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + mock_llm_service = AsyncMock(return_value=_fake_tool_call_response()) + evaluator.llm_service = mock_llm_service + + await evaluator._get_llm_response("some evaluation prompt") + + sent_model = mock_llm_service.call_args.kwargs["model"] + assert sent_model == COMMUNITY_MODEL + + +class TestLLMJudgeTrajectoryEvaluatorSendsConfiguredModel: + """Covers the same LLMJudgeMixin._get_llm_response via the trajectory + evaluator -- this is the exact evaluator type from the reported + production trace ('uipath-llm-judge-trajectory-similarity').""" + + @pytest.mark.asyncio + async def test_get_llm_response_sends_full_model_name_to_gateway(self): + config = { + "name": "TestEvaluator", + "prompt": "Judge {{AgentRunHistory}} against {{ExpectedAgentBehavior}}", + "model": COMMUNITY_MODEL, + } + with patch("uipath.platform.UiPath"): + evaluator = LLMJudgeTrajectoryEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + mock_llm_service = AsyncMock(return_value=_fake_tool_call_response()) + evaluator.llm_service = mock_llm_service + + await evaluator._get_llm_response("some evaluation prompt") + + sent_model = mock_llm_service.call_args.kwargs["model"] + assert sent_model == COMMUNITY_MODEL + + +def _legacy_trajectory_evaluator() -> LegacyTrajectoryEvaluator: + return LegacyTrajectoryEvaluator( + id=str(uuid.uuid4()), + name="Legacy trajectory", + config_type=LegacyTrajectoryEvaluatorConfig, + evaluation_criteria_type=LegacyEvaluationCriteria, + justification_type=str, + category=LegacyEvaluatorCategory.Trajectory, + type=LegacyEvaluatorType.Trajectory, + prompt="History:\n{{AgentRunHistory}}\nExpected:\n{{ExpectedAgentBehavior}}", + model=COMMUNITY_MODEL, + createdAt="2026-05-14T00:00:00Z", + updatedAt="2026-05-14T00:00:00Z", + ) + + +class TestLegacyTrajectoryEvaluatorSendsConfiguredModel: + @pytest.mark.asyncio + async def test_get_llm_response_sends_full_model_name_to_gateway(self): + evaluator = _legacy_trajectory_evaluator() + mock_chat_completions = AsyncMock(return_value=_fake_tool_call_response()) + evaluator.llm = AsyncMock(chat_completions=mock_chat_completions) + + await evaluator._get_llm_response("some evaluation prompt") + + sent_model = mock_chat_completions.call_args.kwargs["model"] + assert sent_model == COMMUNITY_MODEL + + +def _legacy_llm_as_judge_evaluator() -> LegacyLlmAsAJudgeEvaluator: + return LegacyLlmAsAJudgeEvaluator( + id=str(uuid.uuid4()), + name="Legacy LLM judge", + config_type=LegacyLlmAsAJudgeEvaluatorConfig, + evaluation_criteria_type=LegacyEvaluationCriteria, + justification_type=str, + category=LegacyEvaluatorCategory.LlmAsAJudge, + type=LegacyEvaluatorType.Factuality, + prompt="Compare {{ActualOutput}} to {{ExpectedOutput}}", + model=COMMUNITY_MODEL, + createdAt="2026-05-14T00:00:00Z", + updatedAt="2026-05-14T00:00:00Z", + ) + + +class TestLegacyLlmAsAJudgeEvaluatorSendsConfiguredModel: + @pytest.mark.asyncio + async def test_get_llm_response_sends_full_model_name_to_gateway(self): + evaluator = _legacy_llm_as_judge_evaluator() + mock_chat_completions = AsyncMock(return_value=_fake_tool_call_response()) + evaluator.llm = AsyncMock(chat_completions=mock_chat_completions) + + await evaluator._get_llm_response("some evaluation prompt") + + sent_model = mock_chat_completions.call_args.kwargs["model"] + assert sent_model == COMMUNITY_MODEL diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index b14ed59dd..c02e3cbd3 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2598,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.9" +version = "2.13.10" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 8124543f29bbbe42f37e38bf01f560655eb09910 Mon Sep 17 00:00:00 2001 From: Runnan Jia Date: Fri, 10 Jul 2026 11:06:24 -0700 Subject: [PATCH 164/191] feat(platform): include jobKey in LLM Gateway trace baggage (#1731) Co-authored-by: Claude Opus 4.8 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/chat/llm_trace_context.py | 12 ++++++++++-- .../tests/services/test_llm_trace_context.py | 13 +++++++++---- packages/uipath-platform/uv.lock | 4 ++-- packages/uipath/uv.lock | 4 ++-- 5 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 7fcf797e7..40955931c 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.6" +version = "0.2.7" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py b/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py index 916ced322..c10047669 100644 --- a/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py +++ b/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py @@ -34,8 +34,14 @@ def build_trace_context_headers( ctx = span.get_span_context() if config_trace_id and ctx and ctx.span_id: trace_id = _SpanUtils.normalize_trace_id(config_trace_id) - span_id = format(ctx.span_id, "032x") - headers["x-uipath-traceparent-id"] = f"00-{trace_id}-{span_id}" + # An OTEL span id is 64-bit => 16 lowercase hex chars, and the W3C traceparent + # requires the trailing trace-flags segment. The LLM Gateway's strict parser + # (UiPath.Tracing.TraceParent.TryParse) rejects the header unless it is exactly + # {version}-{32-hex trace}-{16-hex span}-{2-hex flags}; on rejection the gateway + # synthesizes a fresh root trace and drops inbound baggage, orphaning the audit + # span from the caller's trace. Emitting 32-hex span / no flags was the bug. + span_id = format(ctx.span_id, "016x") + headers["x-uipath-traceparent-id"] = f"00-{trace_id}-{span_id}-01" baggage_parts: list[str] = list(extra_baggage) if extra_baggage else [] if folder_key := UiPathConfig.folder_key: @@ -44,6 +50,8 @@ def build_trace_context_headers( baggage_parts.append(f"agentId={agent_id}") if process_uuid := UiPathConfig.process_uuid: baggage_parts.append(f"processKey={process_uuid}") + if job_key := UiPathConfig.job_key: + baggage_parts.append(f"jobKey={job_key}") if baggage_parts: headers["x-uipath-tracebaggage"] = ",".join(baggage_parts) diff --git a/packages/uipath-platform/tests/services/test_llm_trace_context.py b/packages/uipath-platform/tests/services/test_llm_trace_context.py index b8aa164dc..a05032113 100644 --- a/packages/uipath-platform/tests/services/test_llm_trace_context.py +++ b/packages/uipath-platform/tests/services/test_llm_trace_context.py @@ -44,7 +44,9 @@ def setup_method(self) -> None: def test_traceparent_from_config_and_span(self) -> None: span = _make_span() ctx = span.get_span_context() - expected_span_id = format(ctx.span_id, "032x") + # OTEL span id is 64-bit => 16 hex chars; traceparent carries the trailing flags + # segment. The gateway's strict W3C parser requires exactly this shape. + expected_span_id = format(ctx.span_id, "016x") config_trace = "abcdef1234567890abcdef1234567890" env = {"UIPATH_TRACE_ID": config_trace} with ( @@ -58,12 +60,13 @@ def test_traceparent_from_config_and_span(self) -> None: assert "x-uipath-traceparent-id" in headers value = headers["x-uipath-traceparent-id"] - assert value == f"00-{config_trace}-{expected_span_id}" + assert value == f"00-{config_trace}-{expected_span_id}-01" parts = value.split("-") - assert len(parts) == 3 + assert len(parts) == 4 assert parts[0] == "00" assert len(parts[1]) == 32 - assert len(parts[2]) == 32 + assert len(parts[2]) == 16 + assert parts[3] == "01" def test_no_traceparent_without_config_trace_id(self) -> None: headers = build_trace_context_headers() @@ -122,6 +125,7 @@ def test_all_env_vars_present(self) -> None: "UIPATH_FOLDER_KEY": "folder-abc", ENV_PROJECT_KEY: "agent-123", "UIPATH_PROCESS_UUID": "process-789", + "UIPATH_JOB_KEY": "job-456", } with patch.dict(os.environ, env, clear=True): headers = build_trace_context_headers() @@ -130,6 +134,7 @@ def test_all_env_vars_present(self) -> None: assert "folderKey=folder-abc" in baggage assert "agentId=agent-123" in baggage assert "processKey=process-789" in baggage + assert "jobKey=job-456" in baggage def test_partial_env_vars(self) -> None: env = {"UIPATH_FOLDER_KEY": "folder-only"} diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index cee6cf645..29a1abba1 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-07-08T17:52:02.959384Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.6" +version = "0.2.7" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index c02e3cbd3..730276088 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-07-08T17:52:11.22969Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2741,7 +2741,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.6" +version = "0.2.7" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 03a6dec3a02811455b30831734357e97bd023c0f Mon Sep 17 00:00:00 2001 From: milind-jain-uipath Date: Mon, 13 Jul 2026 18:20:20 +0530 Subject: [PATCH 165/191] feat(entities): optional source header on datafabric query/execute (#1808) Co-authored-by: Claude Opus 4.8 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/constants/__init__.py | 1 + .../platform/entities/_entities_service.py | 18 +++++++++--- .../platform/entities/_entity_data_service.py | 28 +++++++++++++------ .../tests/services/test_entities_service.py | 28 +++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 7 files changed, 66 insertions(+), 15 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 40955931c..01353417d 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.7" +version = "0.2.8" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/constants/__init__.py b/packages/uipath-platform/src/uipath/platform/constants/__init__.py index a42cf7c84..fc28a10c8 100644 --- a/packages/uipath-platform/src/uipath/platform/constants/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/constants/__init__.py @@ -41,6 +41,7 @@ HEADER_FOLDER_PATH = "x-uipath-folderpath" HEADER_FOLDER_PATH_ENCODED = "x-uipath-folderpath-encoded" HEADER_USER_AGENT = "x-uipath-user-agent" +HEADER_SOURCE = "x-uipath-source" HEADER_TENANT_ID = "x-uipath-tenantid" HEADER_INTERNAL_TENANT_ID = "x-uipath-internal-tenantid" HEADER_INTERNAL_ACCOUNT_ID = "x-uipath-internal-accountid" diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py index 8ab08f2f1..8b346cf0c 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py @@ -1820,7 +1820,9 @@ async def retrieve_records_async( @attach_datafabric_error_mapping("query_entity_records") @traced(name="entity_query_records", run_type="uipath") - def query_entity_records(self, sql_query: str) -> List[Dict[str, Any]]: + def query_entity_records( + self, sql_query: str, source: Optional[str] = None + ) -> List[Dict[str, Any]]: """Query entity records using a validated SQL query. PREVIEW: This method is in preview and may change in future releases. @@ -1829,6 +1831,9 @@ def query_entity_records(self, sql_query: str) -> List[Dict[str, Any]]: sql_query (str): A SQL SELECT query to execute against Data Service entities. Only SELECT statements are allowed. Queries without WHERE must include a LIMIT clause. Subqueries and multi-statement queries are not permitted. + source (str, optional): Value for the ``x-uipath-source`` header on the + query/execute request, identifying the calling surface (e.g. + ``"LOW_CODE_AGENT"``). Omitted from the request when not set. Notes: A routing context is always derived from the configured ``folders_map`` @@ -1841,11 +1846,13 @@ def query_entity_records(self, sql_query: str) -> List[Dict[str, Any]]: ValueError: If the SQL query fails validation (e.g., non-SELECT, missing WHERE/LIMIT, forbidden keywords, subqueries). """ - return self._data.query_entity_records(sql_query) + return self._data.query_entity_records(sql_query, source) @attach_datafabric_error_mapping("query_entity_records_async") @traced(name="entity_query_records", run_type="uipath") - async def query_entity_records_async(self, sql_query: str) -> List[Dict[str, Any]]: + async def query_entity_records_async( + self, sql_query: str, source: Optional[str] = None + ) -> List[Dict[str, Any]]: """Asynchronously query entity records using a validated SQL query. PREVIEW: This method is in preview and may change in future releases. @@ -1854,6 +1861,9 @@ async def query_entity_records_async(self, sql_query: str) -> List[Dict[str, Any sql_query (str): A SQL SELECT query to execute against Data Service entities. Only SELECT statements are allowed. Queries without WHERE must include a LIMIT clause. Subqueries and multi-statement queries are not permitted. + source (str, optional): Value for the ``x-uipath-source`` header on the + query/execute request, identifying the calling surface (e.g. + ``"LOW_CODE_AGENT"``). Omitted from the request when not set. Notes: A routing context is always derived from the configured ``folders_map`` @@ -1866,7 +1876,7 @@ async def query_entity_records_async(self, sql_query: str) -> List[Dict[str, Any ValueError: If the SQL query fails validation (e.g., non-SELECT, missing WHERE/LIMIT, forbidden keywords, subqueries). """ - return await self._data.query_entity_records_async(sql_query) + return await self._data.query_entity_records_async(sql_query, source) @traced(name="entity_upload_attachment", run_type="uipath") def upload_attachment( diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py index 60cfb99b4..5a98fd7dd 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py @@ -22,6 +22,7 @@ from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext from ..common._models import Endpoint, RequestSpec +from ..constants import HEADER_SOURCE from ..errors._enriched_exception import EnrichedException from ..orchestrator._folder_service import FolderService from ._entity_resolution import RoutingStrategy, create_routing_strategy @@ -518,16 +519,18 @@ async def retrieve_records_async( def query_entity_records( self, sql_query: str, + source: Optional[str] = None, ) -> List[Dict[str, Any]]: """Internal implementation; see :meth:`EntitiesService.query_entity_records`.""" - return self._query_entities_for_records(sql_query) + return self._query_entities_for_records(sql_query, source) async def query_entity_records_async( self, sql_query: str, + source: Optional[str] = None, ) -> List[Dict[str, Any]]: """Async variant of :meth:`query_entity_records`.""" - return await self._query_entities_for_records_async(sql_query) + return await self._query_entities_for_records_async(sql_query, source) # ------------------------------------------------------------------ # Attachments @@ -680,22 +683,28 @@ def validate_entity_batch( # Internal helpers — request specs # ------------------------------------------------------------------ - def _query_entities_for_records(self, sql_query: str) -> List[Dict[str, Any]]: + def _query_entities_for_records( + self, sql_query: str, source: Optional[str] = None + ) -> List[Dict[str, Any]]: """Synchronously run a validated SQL query through the federated query engine.""" self._validate_sql_query(sql_query) routing_context = self._routing_strategy.resolve() - spec = self._query_entity_records_spec(sql_query, routing_context) - response = self.request(spec.method, spec.endpoint, json=spec.json) + spec = self._query_entity_records_spec(sql_query, routing_context, source) + response = self.request( + spec.method, spec.endpoint, json=spec.json, headers=spec.headers + ) return response.json().get("results", []) async def _query_entities_for_records_async( - self, sql_query: str + self, sql_query: str, source: Optional[str] = None ) -> List[Dict[str, Any]]: """Asynchronously run a validated SQL query through the federated query engine.""" self._validate_sql_query(sql_query) routing_context = await self._routing_strategy.resolve_async() - spec = self._query_entity_records_spec(sql_query, routing_context) - response = await self.request_async(spec.method, spec.endpoint, json=spec.json) + spec = self._query_entity_records_spec(sql_query, routing_context, source) + response = await self.request_async( + spec.method, spec.endpoint, json=spec.json, headers=spec.headers + ) return response.json().get("results", []) @staticmethod @@ -956,6 +965,7 @@ def _retrieve_records_spec( def _query_entity_records_spec( sql_query: str, routing_context: Optional[QueryRoutingOverrideContext] = None, + source: Optional[str] = None, ) -> RequestSpec: """Build the POST spec for the federated SQL query endpoint.""" body: Dict[str, Any] = {"query": sql_query} @@ -963,10 +973,12 @@ def _query_entity_records_spec( body["routingContext"] = routing_context.model_dump( by_alias=True, exclude_none=True ) + headers = {HEADER_SOURCE: source} if source else {} return RequestSpec( method="POST", endpoint=Endpoint("datafabric_/api/v1/query/execute"), json=body, + headers=headers, ) @staticmethod diff --git a/packages/uipath-platform/tests/services/test_entities_service.py b/packages/uipath-platform/tests/services/test_entities_service.py index 8ea33b618..547ec5335 100644 --- a/packages/uipath-platform/tests/services/test_entities_service.py +++ b/packages/uipath-platform/tests/services/test_entities_service.py @@ -459,6 +459,34 @@ def test_query_entity_records_calls_request_for_valid_sql( assert result == [{"id": 1}, {"id": 2}] service._data.request.assert_called_once() + def test_query_entity_records_sets_source_header_when_provided( + self, + service: EntitiesService, + ) -> None: + response = MagicMock() + response.json.return_value = {"results": []} + service._data.request = MagicMock(return_value=response) # type: ignore[method-assign] + + service.query_entity_records( + "SELECT id FROM Customers WHERE id > 0", source="LOW_CODE_AGENT" + ) + + headers = service._data.request.call_args.kwargs.get("headers") or {} + assert headers.get("x-uipath-source") == "LOW_CODE_AGENT" + + def test_query_entity_records_omits_source_header_by_default( + self, + service: EntitiesService, + ) -> None: + response = MagicMock() + response.json.return_value = {"results": []} + service._data.request = MagicMock(return_value=response) # type: ignore[method-assign] + + service.query_entity_records("SELECT id FROM Customers WHERE id > 0") + + headers = service._data.request.call_args.kwargs.get("headers") or {} + assert "x-uipath-source" not in headers + @pytest.mark.anyio async def test_query_entity_records_async_rejects_invalid_sql_before_network_call( self, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 29a1abba1..779a6d1e1 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.7" +version = "0.2.8" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 730276088..fc5139669 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2741,7 +2741,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.7" +version = "0.2.8" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 9e44d4bbcacef8a3815b495bb45be4cf34790ca7 Mon Sep 17 00:00:00 2001 From: Valentina Bojan Date: Mon, 13 Jul 2026 21:07:07 +0300 Subject: [PATCH 166/191] feat(guardrails): forward byoValidatorName in evaluation for BYOG (#1810) --- packages/uipath-platform/pyproject.toml | 2 +- .../guardrails/_guardrails_service.py | 10 +- .../uipath/platform/guardrails/guardrails.py | 6 + .../tests/services/test_guardrails_service.py | 167 ++++++++++++++++++ packages/uipath-platform/uv.lock | 4 +- packages/uipath/uv.lock | 4 +- 6 files changed, 186 insertions(+), 7 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 01353417d..42178758a 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.8" +version = "0.2.9" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py index f4c7d832e..b73d810e7 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py @@ -17,7 +17,7 @@ from ..common._job_context import header_job_key from ..common._models import Endpoint, RequestSpec from ..errors import EnrichedException -from .guardrails import BuiltInValidatorGuardrail +from .guardrails import BYO_VALIDATOR_TYPE, BuiltInValidatorGuardrail # x-uipath-traceparent-id header format: {version}-{trace_id}-{span_id}[-{trace_flags}] # Based on W3C traceparent but allows 16- or 32-hex span IDs. @@ -115,12 +115,18 @@ def evaluate_guardrail( parameters = [ param.model_dump(by_alias=True) for param in guardrail.validator_parameters ] - payload = { + payload: dict[str, Any] = { "validator": guardrail.validator_type, "input": input_data if isinstance(input_data, str) else str(input_data), "parameters": parameters, "guardrailName": guardrail.name, } + if guardrail.validator_type == BYO_VALIDATOR_TYPE: + if not guardrail.byo_validator_name: + raise ValueError( + "BYO (Bring Your Own) guardrails require byo_validator_name." + ) + payload["byoValidatorName"] = guardrail.byo_validator_name spec = RequestSpec( method="POST", endpoint=Endpoint("/agentsruntime_/api/execution/guardrails/validate"), diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py b/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py index 16262ca5e..dace18019 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py @@ -78,6 +78,11 @@ class TextListParameterValue(BaseModel): ] +#: Sentinel ``validator_type`` for Bring Your Own Guardrail (BYOG) guardrails; the +#: connector-backed configuration is referenced by ``byo_validator_name`` instead. +BYO_VALIDATOR_TYPE = "byo" + + class BuiltInValidatorGuardrail(BaseGuardrail): """Built-in validator guardrail model.""" @@ -86,6 +91,7 @@ class BuiltInValidatorGuardrail(BaseGuardrail): validator_parameters: list[ValidatorParameter] = Field( default_factory=list, alias="validatorParameters" ) + byo_validator_name: str | None = Field(default=None, alias="byoValidatorName") model_config = ConfigDict(populate_by_name=True, extra="allow") diff --git a/packages/uipath-platform/tests/services/test_guardrails_service.py b/packages/uipath-platform/tests/services/test_guardrails_service.py index 87fb7bb8c..6fd20bbc5 100644 --- a/packages/uipath-platform/tests/services/test_guardrails_service.py +++ b/packages/uipath-platform/tests/services/test_guardrails_service.py @@ -303,6 +303,173 @@ def capture_request(request): assert result.result == GuardrailValidationResultType.PASSED assert result.reason == "Validation passed" + def test_evaluate_guardrail_byog_forwards_byo_validator_name( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """A BYOG guardrail forwards byoValidatorName so the guardrails service + can resolve the connector-backed configuration.""" + captured_request = None + + def capture_request(request): + nonlocal captured_request + captured_request = request + return httpx.Response( + status_code=200, + json={"result": "PASSED", "details": "Validation passed"}, + ) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", + callback=capture_request, + ) + + # BYOG persisted shape: "byo" sentinel + byoValidatorName reference. + byog_guardrail = BuiltInValidatorGuardrail( + id="byog-id", + name="Databricks PII (BYOG)", + description="Customer-provided PII validator", + enabled_for_evals=True, + selector=GuardrailSelector(scopes=[GuardrailScope.LLM]), + guardrail_type="builtInValidator", + validator_type="byo", + byo_validator_name="my_databricks_pii", + validator_parameters=[], + ) + + result = service.evaluate_guardrail("some input", byog_guardrail) + + assert captured_request is not None + request_payload = json.loads(captured_request.content) + assert request_payload["validator"] == "byo" + assert request_payload["byoValidatorName"] == "my_databricks_pii" + assert result.result == GuardrailValidationResultType.PASSED + + def test_evaluate_guardrail_byo_without_name_raises( + self, + service: GuardrailsService, + ) -> None: + """A "byo" guardrail missing its byoValidatorName reference fails fast + rather than sending an unresolvable request.""" + guardrail = BuiltInValidatorGuardrail( + id="byog-id", + name="Broken BYOG", + enabled_for_evals=True, + selector=GuardrailSelector(scopes=[GuardrailScope.LLM]), + guardrail_type="builtInValidator", + validator_type="byo", + validator_parameters=[], + ) + + with pytest.raises(ValueError, match="byo_validator_name"): + service.evaluate_guardrail("some input", guardrail) + + def test_evaluate_guardrail_byo_validator_name_from_alias(self) -> None: + """byoValidatorName parses into the typed field via its camelCase alias.""" + guardrail = BuiltInValidatorGuardrail.model_validate( + { + "$guardrailType": "builtInValidator", + "id": "byog-id", + "name": "BYOG", + "validatorType": "byo", + "byoValidatorName": "my_databricks_pii", + "validatorParameters": [], + } + ) + assert guardrail.byo_validator_name == "my_databricks_pii" + + def test_evaluate_guardrail_non_byo_type_does_not_forward_name( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """byoValidatorName is only forwarded for the "byo" sentinel, never leaked + into a non-BYOG validator payload even if the field happens to be set.""" + captured_request = None + + def capture_request(request): + nonlocal captured_request + captured_request = request + return httpx.Response( + status_code=200, + json={"result": "PASSED", "details": "Validation passed"}, + ) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", + callback=capture_request, + ) + + guardrail = BuiltInValidatorGuardrail( + id="test-id", + name="PII detection guardrail", + enabled_for_evals=True, + selector=GuardrailSelector(scopes=[GuardrailScope.LLM]), + guardrail_type="builtInValidator", + validator_type="pii_detection", + byo_validator_name="stray_name", + validator_parameters=[], + ) + + service.evaluate_guardrail("some input", guardrail) + + assert captured_request is not None + request_payload = json.loads(captured_request.content) + assert "byoValidatorName" not in request_payload + + def test_evaluate_guardrail_ootb_omits_byo_validator_name( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """OOTB validators (no byo_validator_name) keep their payload unchanged — + byoValidatorName is not sent.""" + captured_request = None + + def capture_request(request): + nonlocal captured_request + captured_request = request + return httpx.Response( + status_code=200, + json={"result": "PASSED", "details": "Validation passed"}, + ) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", + callback=capture_request, + ) + + pii_guardrail = BuiltInValidatorGuardrail( + id="test-id", + name="PII detection guardrail", + description="Test PII detection", + enabled_for_evals=True, + selector=GuardrailSelector(scopes=[GuardrailScope.LLM]), + guardrail_type="builtInValidator", + validator_type="pii_detection", + validator_parameters=[], + ) + + service.evaluate_guardrail("some input", pii_guardrail) + + assert captured_request is not None + request_payload = json.loads(captured_request.content) + assert "byoValidatorName" not in request_payload + assert pii_guardrail.byo_validator_name is None + def test_evaluate_guardrail_sends_trace_context_headers( self, httpx_mock: HTTPXMock, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 779a6d1e1..802e19ac8 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-07-08T17:52:02.959384Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.8" +version = "0.2.9" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index fc5139669..da12487fe 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-07-08T17:52:11.22969Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2741,7 +2741,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.8" +version = "0.2.9" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 7b41bc945dd011f4399332838e1438504165d9e0 Mon Sep 17 00:00:00 2001 From: Vlad Cimpeanu Date: Tue, 14 Jul 2026 12:51:28 +0300 Subject: [PATCH 167/191] test: add protocol conformance pins for runtime implementations (#1811) Co-authored-by: Claude Fable 5 --- .../unit/test_runtime_protocol_conformance.py | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 packages/uipath/tests/cli/unit/test_runtime_protocol_conformance.py diff --git a/packages/uipath/tests/cli/unit/test_runtime_protocol_conformance.py b/packages/uipath/tests/cli/unit/test_runtime_protocol_conformance.py new file mode 100644 index 000000000..05697dbb4 --- /dev/null +++ b/packages/uipath/tests/cli/unit/test_runtime_protocol_conformance.py @@ -0,0 +1,79 @@ +"""Protocol conformance for uipath-runtime protocol implementations. + +Every protocol-annotated assignment below is a typed boundary: mypy verifies +the implementation against the protocol surface of the installed +uipath-runtime version. A dependency bump that adds or changes protocol +members fails typecheck on these lines until the implementations catch up. + +Deliberately construction-only: no runtime behavior is exercised here. +""" + +import uuid + +from uipath.core.events import EventBus +from uipath.core.tracing import UiPathTraceManager +from uipath.eval.runtime import UiPathEvalContext, UiPathEvalRuntime +from uipath.functions import ( + UiPathDebugFunctionsRuntime, + UiPathFunctionsRuntime, + UiPathFunctionsRuntimeFactory, +) +from uipath.platform.resume_triggers import ( + UiPathResumeTriggerCreator, + UiPathResumeTriggerReader, +) +from uipath.runtime import ( + UiPathRuntimeFactoryProtocol, + UiPathRuntimeProtocol, +) +from uipath.runtime.resumable.protocols import ( + UiPathResumeTriggerCreatorProtocol, + UiPathResumeTriggerReaderProtocol, +) + + +def test_functions_runtime_satisfies_runtime_protocol() -> None: + runtime: UiPathRuntimeProtocol = UiPathFunctionsRuntime( + file_path="main.py", + function_name="main", + entrypoint_name="main", + ) + + # the debug wrapper's `delegate` parameter is protocol-typed: passing the + # real functions runtime through it is a second typed boundary, and the + # wrapper itself must satisfy the protocol too + debug_runtime: UiPathRuntimeProtocol = UiPathDebugFunctionsRuntime( + delegate=runtime, + entrypoint_path="main.py", + function_name="main", + ) + + assert debug_runtime is not None + + +def test_functions_factory_satisfies_factory_protocol() -> None: + factory: UiPathRuntimeFactoryProtocol = UiPathFunctionsRuntimeFactory() + + # the eval runtime's `factory` parameter is protocol-typed: the real + # functions factory flows through it. UiPathEvalRuntime itself is a + # specialized runtime (no stream, argument-less execute) and is + # deliberately NOT pinned against UiPathRuntimeProtocol. + context = UiPathEvalContext() + context.execution_id = str(uuid.uuid4()) + eval_runtime = UiPathEvalRuntime( + context=context, + factory=factory, + trace_manager=UiPathTraceManager(), + event_bus=EventBus(), + ) + + assert eval_runtime is not None + + +def test_standalone_trigger_classes_satisfy_protocols() -> None: + # cover direct consumers of the standalone creator/reader classes + creator: UiPathResumeTriggerCreatorProtocol = UiPathResumeTriggerCreator() + reader: UiPathResumeTriggerReaderProtocol = UiPathResumeTriggerReader() + + assert creator is not None + assert reader is not None From 2bea397292ca7265b83d28ecc76289ea8efda97d Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Tue, 14 Jul 2026 15:07:28 -0700 Subject: [PATCH 168/191] fix(eval): apply resource overwrites and NoMemory override correctly (#1815) Co-authored-by: Claude Fable 5 --- packages/uipath/pyproject.toml | 2 +- packages/uipath/src/uipath/_cli/cli_eval.py | 79 +++++--- .../cli/eval/test_agent_memory_settings.py | 50 +++++ .../cli/eval/test_eval_resource_overwrites.py | 185 ++++++++++++++++++ packages/uipath/uv.lock | 2 +- 5 files changed, 285 insertions(+), 33 deletions(-) create mode 100644 packages/uipath/tests/cli/eval/test_eval_resource_overwrites.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 4628d4707..ea18bed82 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.13.10" +version = "2.13.11" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/cli_eval.py b/packages/uipath/src/uipath/_cli/cli_eval.py index 547266da7..66bdfad10 100644 --- a/packages/uipath/src/uipath/_cli/cli_eval.py +++ b/packages/uipath/src/uipath/_cli/cli_eval.py @@ -3,6 +3,7 @@ import logging import os import uuid +from contextlib import AsyncExitStack from pathlib import Path from typing import Any @@ -138,6 +139,13 @@ def _resolve_agent_memory_settings_override( if not evaluation_set.agent_memory_enabled: return {"enabled": False} + # "NoMemory" is a sentinel id meaning "run without memory", matching the + # Agents backend (ApplyAgentMemorySettingsOverride removes the memorySpace + # feature for a null or "NoMemory" setting). Its entry's field values are + # also "NoMemory" strings, so it must never be applied as real settings. + if agent_memory_settings_id == "NoMemory": + return {"enabled": False} + memory_settings = evaluation_set.agent_memory_settings target = None if agent_memory_settings_id: @@ -154,6 +162,9 @@ def _resolve_agent_memory_settings_override( if target is None: target = memory_settings[0] if memory_settings else None + if target is not None and target.id == "NoMemory": + return {"enabled": False} + if target is None: # Memory enabled but no settings configured: keep the agent's own configuration return {"enabled": True} @@ -468,40 +479,46 @@ async def execute_eval(): ) ) - runtime = await runtime_factory.new_runtime( - entrypoint=eval_context.entrypoint or "", - runtime_id=eval_context.execution_id, - settings=settings_override, - agent_memory_settings=agent_memory_settings_override, - ) - - eval_context.runtime_schema = await runtime.get_schema() - - eval_context.evaluators = await EvalHelpers.load_evaluators( - resolved_eval_set_path, - eval_context.evaluation_set, - get_agent_model(eval_context.runtime_schema), - ) - - # Runtime is not required anymore. - await runtime.dispose() + # Resource overwrites must be in scope before any runtime is + # created: building the agent graph resolves folder-scoped + # resources (e.g. escalation memory spaces) at tool-creation + # time, and those lookups need the overwritten folder paths. + async with AsyncExitStack() as stack: + if project_id: + studio_client = StudioClient(project_id) + + await stack.enter_async_context( + ResourceOverwritesContext( + lambda: studio_client.get_resource_overwrites() + ) + ) + else: + logger.debug( + "No UIPATH_PROJECT_ID configured, executing evaluation without resource overwrites" + ) - if project_id: - studio_client = StudioClient(project_id) + runtime = await runtime_factory.new_runtime( + entrypoint=eval_context.entrypoint or "", + runtime_id=eval_context.execution_id, + settings=settings_override, + agent_memory_settings=agent_memory_settings_override, + ) - async with ResourceOverwritesContext( - lambda: studio_client.get_resource_overwrites() - ): - ctx.result = await evaluate( - runtime_factory, - trace_manager, - eval_context, - event_bus, + # The runtime is only needed for schema/evaluator + # loading; dispose it before evaluation starts. + try: + eval_context.runtime_schema = await runtime.get_schema() + + eval_context.evaluators = ( + await EvalHelpers.load_evaluators( + resolved_eval_set_path, + eval_context.evaluation_set, + get_agent_model(eval_context.runtime_schema), + ) ) - else: - logger.debug( - "No UIPATH_PROJECT_ID configured, executing evaluation without resource overwrites" - ) + finally: + await runtime.dispose() + ctx.result = await evaluate( runtime_factory, trace_manager, diff --git a/packages/uipath/tests/cli/eval/test_agent_memory_settings.py b/packages/uipath/tests/cli/eval/test_agent_memory_settings.py index fa550327a..11a71c58e 100644 --- a/packages/uipath/tests/cli/eval/test_agent_memory_settings.py +++ b/packages/uipath/tests/cli/eval/test_agent_memory_settings.py @@ -205,3 +205,53 @@ def test_memory_enabled_without_settings_keeps_agent_configuration(self): override = _resolve_agent_memory_settings_override("default", eval_set) assert override == {"enabled": True} + + def test_no_memory_id_disables_memory(self): + # Selecting "No memory" in the eval settings passes the "NoMemory" + # sentinel id; its entry stores "NoMemory" field values that must not + # be applied as real settings. + eval_set = make_eval_set( + agentMemoryEnabled=True, + agentMemorySettings=[ + {"id": "s1", "searchMode": "hybrid"}, + { + "id": "NoMemory", + "resultCount": "NoMemory", + "searchMode": "semantic", + "threshold": "NoMemory", + }, + ], + ) + + override = _resolve_agent_memory_settings_override("NoMemory", eval_set) + + assert override == {"enabled": False} + + def test_no_memory_id_disables_memory_without_matching_entry(self): + # The sentinel disables memory even when the eval set has no + # "NoMemory" entry; it must not fall back to the first setting. + eval_set = make_eval_set( + agentMemoryEnabled=True, + agentMemorySettings=[{"id": "s1", "searchMode": "hybrid"}], + ) + + override = _resolve_agent_memory_settings_override("NoMemory", eval_set) + + assert override == {"enabled": False} + + def test_fallback_to_no_memory_entry_disables_memory(self): + eval_set = make_eval_set( + agentMemoryEnabled=True, + agentMemorySettings=[ + { + "id": "NoMemory", + "resultCount": "NoMemory", + "searchMode": "semantic", + "threshold": "NoMemory", + } + ], + ) + + override = _resolve_agent_memory_settings_override("default", eval_set) + + assert override == {"enabled": False} diff --git a/packages/uipath/tests/cli/eval/test_eval_resource_overwrites.py b/packages/uipath/tests/cli/eval/test_eval_resource_overwrites.py new file mode 100644 index 000000000..71589fb9a --- /dev/null +++ b/packages/uipath/tests/cli/eval/test_eval_resource_overwrites.py @@ -0,0 +1,185 @@ +"""Tests for resource overwrites context ordering in the eval CLI. + +The overwrites context must be entered before the runtime is created: +building the agent graph resolves folder-scoped resources (e.g. escalation +memory spaces) at tool-creation time, and those lookups need the +overwritten folder paths. +""" + +import json +import os +from contextlib import ExitStack +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from click.testing import CliRunner + +from uipath._cli import cli +from uipath._cli.middlewares import MiddlewareResult +from uipath.platform.common._bindings import _resource_overwrites + + +def _middleware_continue() -> MiddlewareResult: + return MiddlewareResult( + should_continue=True, + error_message=None, + should_include_stacktrace=False, + ) + + +def _write_project_files() -> None: + with open("uipath.json", "w") as f: + json.dump({"functions": {"agent": "main.py:main"}}, f) + + os.makedirs("evaluations/eval-sets", exist_ok=True) + eval_set = { + "version": "1.0", + "id": "test-set", + "name": "Test Set", + "evaluatorRefs": [], + "evaluations": [], + } + with open("evaluations/eval-sets/test-set.json", "w") as f: + json.dump(eval_set, f) + + +def _make_mock_runtime() -> Mock: + mock_runtime = Mock() + mock_runtime.get_schema = AsyncMock( + return_value=Mock(metadata=None, input_schema=None, output_schema=None) + ) + mock_runtime.dispose = AsyncMock() + return mock_runtime + + +def _make_mock_factory(mock_runtime: Mock) -> Mock: + mock_factory = Mock() + mock_factory.discover_entrypoints.return_value = ["agent"] + mock_factory.get_settings = AsyncMock(return_value=None) + mock_factory.dispose = AsyncMock() + mock_factory.new_runtime = AsyncMock(return_value=mock_runtime) + return mock_factory + + +def _enter_base_patches(stack: ExitStack, mock_factory: Mock) -> None: + stack.enter_context( + patch( + "uipath._cli.cli_eval.Middlewares.next", + return_value=_middleware_continue(), + ) + ) + stack.enter_context( + patch( + "uipath._cli.cli_eval.UiPathRuntimeFactoryRegistry.get", + return_value=mock_factory, + ) + ) + stack.enter_context( + patch("uipath._cli.cli_eval.setup_reporting_prereq", return_value=False) + ) + stack.enter_context( + patch( + "uipath._cli.cli_eval.EvalHelpers.load_evaluators", + new=AsyncMock(return_value=[]), + ) + ) + stack.enter_context( + patch("uipath._cli.cli_eval.evaluate", new=AsyncMock(return_value=None)) + ) + + +class TestEvalResourceOverwritesOrdering: + def test_overwrites_context_active_when_runtime_is_created( + self, runner: CliRunner, temp_dir: str, monkeypatch: pytest.MonkeyPatch + ) -> None: + """new_runtime must run inside the resource overwrites context.""" + monkeypatch.setenv("UIPATH_PROJECT_ID", "project-123") + + with runner.isolated_filesystem(temp_dir=temp_dir): + _write_project_files() + + overwrite = Mock() + overwrites = {"memorySpace.MemorySpace": overwrite} + overwrites_seen_by_new_runtime: list[Any] = [] + + mock_runtime = _make_mock_runtime() + + async def new_runtime(*args: Any, **kwargs: Any) -> Mock: + overwrites_seen_by_new_runtime.append(_resource_overwrites.get()) + return mock_runtime + + mock_factory = _make_mock_factory(mock_runtime) + mock_factory.new_runtime = AsyncMock(side_effect=new_runtime) + + mock_studio_client = Mock() + mock_studio_client.get_resource_overwrites = AsyncMock( + return_value=overwrites + ) + + with ExitStack() as stack: + _enter_base_patches(stack, mock_factory) + stack.enter_context( + patch( + "uipath._cli.cli_eval.StudioClient", + return_value=mock_studio_client, + ) + ) + result = runner.invoke(cli, ["eval"]) + + assert result.exit_code == 0 + mock_studio_client.get_resource_overwrites.assert_awaited_once() + assert overwrites_seen_by_new_runtime == [overwrites] + mock_runtime.dispose.assert_awaited_once() + + def test_no_project_id_runs_without_overwrites( + self, runner: CliRunner, temp_dir: str, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("UIPATH_PROJECT_ID", raising=False) + + with runner.isolated_filesystem(temp_dir=temp_dir): + _write_project_files() + + overwrites_seen_by_new_runtime: list[Any] = [] + mock_runtime = _make_mock_runtime() + + async def new_runtime(*args: Any, **kwargs: Any) -> Mock: + overwrites_seen_by_new_runtime.append(_resource_overwrites.get()) + return mock_runtime + + mock_factory = _make_mock_factory(mock_runtime) + mock_factory.new_runtime = AsyncMock(side_effect=new_runtime) + + with ExitStack() as stack: + _enter_base_patches(stack, mock_factory) + mock_studio_client_cls = stack.enter_context( + patch("uipath._cli.cli_eval.StudioClient") + ) + result = runner.invoke(cli, ["eval"]) + + assert result.exit_code == 0 + mock_studio_client_cls.assert_not_called() + assert overwrites_seen_by_new_runtime == [None] + mock_runtime.dispose.assert_awaited_once() + + def test_runtime_disposed_when_schema_loading_fails( + self, runner: CliRunner, temp_dir: str, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("UIPATH_PROJECT_ID", raising=False) + + with runner.isolated_filesystem(temp_dir=temp_dir): + _write_project_files() + + mock_runtime = _make_mock_runtime() + mock_runtime.get_schema = AsyncMock( + side_effect=RuntimeError("schema loading failed") + ) + mock_factory = _make_mock_factory(mock_runtime) + + with ExitStack() as stack: + _enter_base_patches(stack, mock_factory) + stack.enter_context(patch("uipath._cli.cli_eval.StudioClient")) + result = runner.invoke(cli, ["eval"]) + + assert result.exit_code != 0 + mock_runtime.dispose.assert_awaited_once() diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index da12487fe..9d4b3d776 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2598,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.10" +version = "2.13.11" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From c5924eccc62db61ae85662e1dffd99824ca0ccf5 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Wed, 15 Jul 2026 08:55:15 +0200 Subject: [PATCH 169/191] feat: preserve conversation content-part metadata (#1813) --- packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/chat/content.py | 1 + .../uipath-core/tests/chat/test_message.py | 28 +++++++++++++++++++ packages/uipath-core/uv.lock | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 6 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index fdc902287..dc2570992 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.30" +version = "0.5.31" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/chat/content.py b/packages/uipath-core/src/uipath/core/chat/content.py index 4ae8b169c..38ad2bbd6 100644 --- a/packages/uipath-core/src/uipath/core/chat/content.py +++ b/packages/uipath-core/src/uipath/core/chat/content.py @@ -96,6 +96,7 @@ class UiPathConversationContentPartData(BaseModel): mime_type: str = Field(..., alias="mimeType") data: InlineOrExternal + metadata: dict[str, Any] | None = Field(None, alias="metaData") citations: Sequence[UiPathConversationCitationData] = Field(default_factory=list) is_transcript: bool | None = Field(None, alias="isTranscript") is_incomplete: bool | None = Field(None, alias="isIncomplete") diff --git a/packages/uipath-core/tests/chat/test_message.py b/packages/uipath-core/tests/chat/test_message.py index 113d34a66..f56178fc5 100644 --- a/packages/uipath-core/tests/chat/test_message.py +++ b/packages/uipath-core/tests/chat/test_message.py @@ -57,3 +57,31 @@ def test_explicit_ids_are_preserved() -> None: assert ( msg.content_parts[0].content_part_id == "00000000-0000-0000-0000-000000000002" ) + + +def test_content_part_metadata_is_preserved() -> None: + msg = UiPathConversationMessage.model_validate( + { + "role": "assistant", + "contentParts": [ + { + "mimeType": "text/markdown", + "name": "plan.md", + "data": { + "uri": "urn:uipath:cas:file:orchestrator:" + "00000000-0000-0000-0000-000000000003" + }, + "metaData": {"fileKind": "workspace", "sha256": "abc123"}, + } + ], + } + ) + + assert msg.content_parts[0].metadata == { + "fileKind": "workspace", + "sha256": "abc123", + } + assert msg.model_dump(by_alias=True)["contentParts"][0]["metaData"] == { + "fileKind": "workspace", + "sha256": "abc123", + } diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index 66a9c4ebe..96df7523f 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -1011,7 +1011,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.30" +version = "0.5.31" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 802e19ac8..ea1f85ad3 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1063,7 +1063,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.30" +version = "0.5.31" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 9d4b3d776..1c38c3b33 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2709,7 +2709,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.30" +version = "0.5.31" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, From e2ab6f772fa5dda3a8c0c0f26b6f34cb3458b19b Mon Sep 17 00:00:00 2001 From: Valentina Bojan Date: Wed, 15 Jul 2026 16:22:07 +0300 Subject: [PATCH 170/191] feat(guardrails): forward byoConnectionId for BYO guardrails (#1817) Co-authored-by: Valentina Bojan Co-authored-by: Claude Opus 4.8 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../guardrails/_guardrails_service.py | 2 + .../uipath/platform/guardrails/guardrails.py | 1 + .../tests/services/test_guardrails_service.py | 146 ++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 6 files changed, 152 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 42178758a..f5c7522b9 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.9" +version = "0.2.10" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py index b73d810e7..fbeb1f027 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py @@ -127,6 +127,8 @@ def evaluate_guardrail( "BYO (Bring Your Own) guardrails require byo_validator_name." ) payload["byoValidatorName"] = guardrail.byo_validator_name + if guardrail.byo_connection_id: + payload["byoConnectionId"] = guardrail.byo_connection_id spec = RequestSpec( method="POST", endpoint=Endpoint("/agentsruntime_/api/execution/guardrails/validate"), diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py b/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py index dace18019..c7a968a61 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py @@ -92,6 +92,7 @@ class BuiltInValidatorGuardrail(BaseGuardrail): default_factory=list, alias="validatorParameters" ) byo_validator_name: str | None = Field(default=None, alias="byoValidatorName") + byo_connection_id: str | None = Field(default=None, alias="byoConnectionId") model_config = ConfigDict(populate_by_name=True, extra="allow") diff --git a/packages/uipath-platform/tests/services/test_guardrails_service.py b/packages/uipath-platform/tests/services/test_guardrails_service.py index 6fd20bbc5..c94bf8be3 100644 --- a/packages/uipath-platform/tests/services/test_guardrails_service.py +++ b/packages/uipath-platform/tests/services/test_guardrails_service.py @@ -350,6 +350,109 @@ def capture_request(request): assert request_payload["byoValidatorName"] == "my_databricks_pii" assert result.result == GuardrailValidationResultType.PASSED + def test_evaluate_guardrail_byog_forwards_byo_connection_id( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """A BYOG guardrail forwards byoConnectionId so the backend can narrow + configuration resolution to the specific connection.""" + captured_request = None + + def capture_request(request): + nonlocal captured_request + captured_request = request + return httpx.Response( + status_code=200, + json={"result": "PASSED", "details": "Validation passed"}, + ) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", + callback=capture_request, + ) + + byog_guardrail = BuiltInValidatorGuardrail( + id="byog-id", + name="Databricks PII (BYOG)", + enabled_for_evals=True, + selector=GuardrailSelector(scopes=[GuardrailScope.LLM]), + guardrail_type="builtInValidator", + validator_type="byo", + byo_validator_name="my_databricks_pii", + byo_connection_id="byog-conn-1", + validator_parameters=[], + ) + + service.evaluate_guardrail("some input", byog_guardrail) + + assert captured_request is not None + request_payload = json.loads(captured_request.content) + assert request_payload["byoValidatorName"] == "my_databricks_pii" + assert request_payload["byoConnectionId"] == "byog-conn-1" + + def test_evaluate_guardrail_byog_omits_connection_id_when_absent( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """byoConnectionId is only forwarded when present; a BYOG guardrail without + one resolves by validator name alone.""" + captured_request = None + + def capture_request(request): + nonlocal captured_request + captured_request = request + return httpx.Response( + status_code=200, + json={"result": "PASSED", "details": "Validation passed"}, + ) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", + callback=capture_request, + ) + + byog_guardrail = BuiltInValidatorGuardrail( + id="byog-id", + name="Databricks PII (BYOG)", + enabled_for_evals=True, + selector=GuardrailSelector(scopes=[GuardrailScope.LLM]), + guardrail_type="builtInValidator", + validator_type="byo", + byo_validator_name="my_databricks_pii", + validator_parameters=[], + ) + + service.evaluate_guardrail("some input", byog_guardrail) + + assert captured_request is not None + request_payload = json.loads(captured_request.content) + assert "byoConnectionId" not in request_payload + + def test_evaluate_guardrail_byo_connection_id_from_alias(self) -> None: + """byoConnectionId parses into the typed field via its camelCase alias.""" + guardrail = BuiltInValidatorGuardrail.model_validate( + { + "$guardrailType": "builtInValidator", + "id": "byog-id", + "name": "BYOG", + "validatorType": "byo", + "byoValidatorName": "my_databricks_pii", + "byoConnectionId": "byog-conn-1", + "validatorParameters": [], + } + ) + assert guardrail.byo_connection_id == "byog-conn-1" + def test_evaluate_guardrail_byo_without_name_raises( self, service: GuardrailsService, @@ -426,6 +529,49 @@ def capture_request(request): request_payload = json.loads(captured_request.content) assert "byoValidatorName" not in request_payload + def test_evaluate_guardrail_non_byo_type_does_not_forward_connection_id( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """byoConnectionId is only forwarded for the "byo" sentinel, never leaked + into a non-BYOG validator payload even if the field happens to be set.""" + captured_request = None + + def capture_request(request): + nonlocal captured_request + captured_request = request + return httpx.Response( + status_code=200, + json={"result": "PASSED", "details": "Validation passed"}, + ) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", + callback=capture_request, + ) + + guardrail = BuiltInValidatorGuardrail( + id="test-id", + name="PII detection guardrail", + enabled_for_evals=True, + selector=GuardrailSelector(scopes=[GuardrailScope.LLM]), + guardrail_type="builtInValidator", + validator_type="pii_detection", + byo_connection_id="stray-conn", + validator_parameters=[], + ) + + service.evaluate_guardrail("some input", guardrail) + + assert captured_request is not None + request_payload = json.loads(captured_request.content) + assert "byoConnectionId" not in request_payload + def test_evaluate_guardrail_ootb_omits_byo_validator_name( self, httpx_mock: HTTPXMock, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index ea1f85ad3..744c292d3 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.9" +version = "0.2.10" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 1c38c3b33..aea72e7a1 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2741,7 +2741,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.9" +version = "0.2.10" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 487bf0384006cb1c093fc2e2166998f4f5653618 Mon Sep 17 00:00:00 2001 From: Cristi Pufu Date: Fri, 17 Jul 2026 12:08:34 +0300 Subject: [PATCH 171/191] fix: allow first push to a project with uninitialized file system (#1820) --- packages/uipath/pyproject.toml | 2 +- .../src/uipath/_cli/_utils/_studio_project.py | 26 +++++-- packages/uipath/tests/cli/test_push.py | 74 +++++++++++++++++++ packages/uipath/uv.lock | 2 +- 4 files changed, 95 insertions(+), 9 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index ea18bed82..0df94ca0c 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.13.11" +version = "2.13.12" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/_utils/_studio_project.py b/packages/uipath/src/uipath/_cli/_utils/_studio_project.py index 3a60fc3ca..10eccdf5d 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_studio_project.py +++ b/packages/uipath/src/uipath/_cli/_utils/_studio_project.py @@ -513,6 +513,9 @@ async def _get_solution_id(self) -> str: async def ensure_coded_agent_project_async(self): structure = await self.get_project_structure_async() + # An empty structure means a never-pushed project: allow the first push. + if not structure.files and not structure.folders: + return if not any(file.name == PYTHON_CONFIGURATION_FILE for file in structure.files): raise NonCodedAgentProjectException() @@ -710,13 +713,22 @@ async def get_project_structure_async( if not force and self._project_structure_cache is not None: return self._project_structure_cache - response = await self.uipath.api_client.request_async( - "GET", - url=f"{self.file_operations_base_url}/Structure", - scoped="org", - ) - - self._project_structure_cache = ProjectStructure.model_validate(response.json()) + try: + response = await self.uipath.api_client.request_async( + "GET", + url=f"{self.file_operations_base_url}/Structure", + scoped="org", + ) + structure = ProjectStructure.model_validate(response.json()) + except EnrichedException as e: + # The backend returns 404 for projects whose file system was never + # initialized (e.g. a freshly created Function project): treat it + # as an empty structure so the first push can bootstrap the files. + if e.status_code != 404: + raise + structure = ProjectStructure(name="root", folders=[], files=[]) + + self._project_structure_cache = structure return self._project_structure_cache @traced(name="create_folder", run_type="uipath") diff --git a/packages/uipath/tests/cli/test_push.py b/packages/uipath/tests/cli/test_push.py index d4d30a166..a9af55f14 100644 --- a/packages/uipath/tests/cli/test_push.py +++ b/packages/uipath/tests/cli/test_push.py @@ -712,6 +712,80 @@ def test_push_non_coded_agent_project( in result.output ) + def test_first_push_to_uninitialized_project( + self, + runner: CliRunner, + temp_dir: str, + project_details: ProjectDetails, + mock_env_vars: dict[str, str], + httpx_mock: HTTPXMock, + ) -> None: + """Test push when the remote file system was never initialized. + + The backend returns 404 from FileOperations/Structure for projects + whose file system does not exist yet (e.g. a freshly created Function + project). The first push should bootstrap the files instead of failing + the coded-agent validation. + """ + base_url = "https://cloud.uipath.com/organization" + project_id = "test-project-id" + + # Uninitialized file system: Structure returns 404 + httpx_mock.add_response( + url=f"{base_url}/studio_/backend/api/Project/{project_id}/FileOperations/Structure", + status_code=404, + json={ + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5", + "title": "Not Found", + "status": 404, + }, + ) + + self._mock_lock_retrieval(httpx_mock, base_url, project_id, times=1) + + httpx_mock.add_response( + method="POST", + url=f"{base_url}/studio_/backend/api/Project/{project_id}/FileOperations/StructuralMigration", + status_code=200, + json={"success": True}, + ) + + # Empty folder cleanup - get structure again after migration + httpx_mock.add_response( + url=f"{base_url}/studio_/backend/api/Project/{project_id}/FileOperations/Structure", + json={ + "id": "root", + "name": "root", + "folders": [], + "files": [], + "folderType": "0", + }, + ) + + with runner.isolated_filesystem(temp_dir=temp_dir): + self._create_required_files() + + with open("pyproject.toml", "w") as f: + f.write(project_details.to_toml()) + + with open("main.py", "w") as f: + f.write("print('Hello World')") + + with open("uv.lock", "w") as f: + f.write('version = 1 \n requires-python = ">=3.11"') + + configure_env_vars(mock_env_vars) + os.environ["UIPATH_PROJECT_ID"] = project_id + + result = runner.invoke(cli, ["push", "./", "--ignore-resources"]) + assert result.exit_code == 0 + assert "not of type coded agent" not in result.output + assert "Uploading 'main.py'" in result.output + assert "Uploading 'pyproject.toml'" in result.output + assert "Uploading 'uipath.json'" in result.output + assert "Uploading 'uv.lock'" in result.output + assert "Uploading '.uipath/studio_metadata.json'" in result.output + def test_push_with_nolock_flag( self, runner: CliRunner, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index aea72e7a1..f103aeec0 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2598,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.11" +version = "2.13.12" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From d190868f671e63cd2fd048bf5c533ded970d171d Mon Sep 17 00:00:00 2001 From: AAgnihotry <95259907+AAgnihotry@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:16:02 -0700 Subject: [PATCH 172/191] fix(llm): exclude sampling params for reasoning models (#1822) Co-authored-by: Claude Opus 4.6 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../platform/chat/_llm_gateway_service.py | 26 +- .../tests/services/test_llm_service.py | 223 ++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 2 +- packages/uipath/uv.lock | 4 +- 6 files changed, 246 insertions(+), 13 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index f5c7522b9..cf773124f 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.10" +version = "0.2.11" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py b/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py index e07ce68aa..00e373d60 100644 --- a/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py +++ b/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py @@ -327,12 +327,17 @@ class Country(BaseModel): ) endpoint = Endpoint("/" + endpoint) - request_body = { + is_reasoning_model = model.lower().startswith(("o1", "o3", "o4")) + + request_body: dict[str, Any] = { "messages": messages, "max_tokens": max_tokens, - "temperature": temperature, } + # Reasoning models (o1, o3, o4) don't support temperature + if not is_reasoning_model: + request_body["temperature"] = temperature + # Handle response_format - convert BaseModel to schema if needed if response_format: if isinstance(response_format, type) and issubclass( @@ -548,17 +553,22 @@ class Country(BaseModel): ) endpoint = Endpoint("/" + endpoint) - # Build request body - Claude models don't support some OpenAI-specific parameters - is_claude_model = "claude" in model.lower() + # Build request body - some models don't support certain parameters + model_lower = model.lower() + is_claude_model = "claude" in model_lower + is_reasoning_model = model_lower.startswith(("o1", "o3", "o4")) - request_body = { + request_body: dict[str, Any] = { "messages": converted_messages, "max_tokens": max_tokens, - "temperature": temperature, } - # Only add OpenAI-specific parameters for non-Claude models - if not is_claude_model: + # Reasoning models (o1, o3, o4) don't support temperature and sampling parameters + if not is_reasoning_model: + request_body["temperature"] = temperature + + # Only add OpenAI-specific parameters for non-Claude and non-reasoning models + if not is_claude_model and not is_reasoning_model: request_body["n"] = n request_body["frequency_penalty"] = frequency_penalty request_body["presence_penalty"] = presence_penalty diff --git a/packages/uipath-platform/tests/services/test_llm_service.py b/packages/uipath-platform/tests/services/test_llm_service.py index e1ab8299b..4a6da2789 100644 --- a/packages/uipath-platform/tests/services/test_llm_service.py +++ b/packages/uipath-platform/tests/services/test_llm_service.py @@ -384,6 +384,104 @@ class Article(BaseModel): assert article_instance.title is None +class TestOpenAIServiceReasoningModelFiltering: + """Test that UiPathOpenAIService correctly filters out temperature for reasoning models.""" + + @pytest.fixture + def config(self): + return UiPathApiConfig(base_url="https://example.com", secret="test_secret") + + @pytest.fixture + def execution_context(self): + return UiPathExecutionContext() + + @pytest.fixture + def openai_service(self, config, execution_context): + return UiPathOpenAIService(config=config, execution_context=execution_context) + + @pytest.mark.parametrize( + "model", + [ + "o3-mini-2025-01-31", + "o4-mini-2025-04-16", + "o1-2024-12-17", + "o1-mini-2024-09-12", + "o3-2025-04-16", + ], + ) + @patch.object(UiPathOpenAIService, "request_async") + @pytest.mark.asyncio + async def test_reasoning_model_excludes_temperature( + self, mock_request, openai_service, model + ): + """Test that reasoning models do not include temperature in the request body.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1234567890, + "model": model, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hello"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + } + mock_request.return_value = mock_response + + await openai_service.chat_completions( + messages=[{"role": "user", "content": "Hello"}], + model=model, + max_tokens=1000, + ) + + call_kwargs = mock_request.call_args[1] + request_body = call_kwargs["json"] + + assert "temperature" not in request_body, ( + f"Reasoning model {model} request must not include 'temperature'" + ) + assert request_body["max_tokens"] == 1000 + + @patch.object(UiPathOpenAIService, "request_async") + @pytest.mark.asyncio + async def test_non_reasoning_model_includes_temperature( + self, mock_request, openai_service + ): + """Test that non-reasoning models still include temperature.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1234567890, + "model": "gpt-4.1-mini-2025-04-14", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hello"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + } + mock_request.return_value = mock_response + + await openai_service.chat_completions( + messages=[{"role": "user", "content": "Hello"}], + model="gpt-4.1-mini-2025-04-14", + max_tokens=1000, + temperature=0.7, + ) + + call_kwargs = mock_request.call_args[1] + request_body = call_kwargs["json"] + + assert request_body["temperature"] == 0.7 + + class TestNormalizedLlmServiceClaudeFiltering: """Test that Claude models correctly filter out OpenAI-specific parameters. @@ -543,3 +641,128 @@ async def test_claude_sonnet_45_excluded_params(self, mock_request, llm_service) assert "presence_penalty" not in request_body assert "top_p" not in request_body assert request_body["max_tokens"] == 8000 + + +class TestNormalizedLlmServiceReasoningModelFiltering: + """Test that reasoning models (o1, o3, o4) correctly filter out unsupported sampling parameters. + + OpenAI reasoning models do NOT support temperature, top_p, frequency_penalty, + presence_penalty, or n, and sending them causes 400 errors. + """ + + @pytest.fixture + def config(self): + return UiPathApiConfig(base_url="https://example.com", secret="test_secret") + + @pytest.fixture + def execution_context(self): + return UiPathExecutionContext() + + @pytest.fixture + def llm_service(self, config, execution_context): + from uipath.platform.chat._llm_gateway_service import UiPathLlmChatService + + return UiPathLlmChatService(config=config, execution_context=execution_context) + + @pytest.mark.parametrize( + "model", + [ + "o3-mini-2025-01-31", + "o4-mini-2025-04-16", + "o1-2024-12-17", + "o1-mini-2024-09-12", + "o3-2025-04-16", + ], + ) + @patch( + "uipath.platform.chat._llm_gateway_service.UiPathLlmChatService.request_async" + ) + @pytest.mark.asyncio + async def test_reasoning_model_excludes_sampling_params( + self, mock_request, llm_service, model + ): + """Test that reasoning models do not include temperature or other sampling params.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1234567890, + "model": model, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hello"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + } + mock_request.return_value = mock_response + + await llm_service.chat_completions( + messages=[{"role": "user", "content": "Hello"}], + model=model, + max_tokens=1000, + ) + + call_kwargs = mock_request.call_args[1] + request_body = call_kwargs["json"] + + assert "temperature" not in request_body, ( + f"Reasoning model {model} request must not include 'temperature'" + ) + assert "n" not in request_body, ( + f"Reasoning model {model} request must not include 'n'" + ) + assert "frequency_penalty" not in request_body, ( + f"Reasoning model {model} request must not include 'frequency_penalty'" + ) + assert "presence_penalty" not in request_body, ( + f"Reasoning model {model} request must not include 'presence_penalty'" + ) + assert "top_p" not in request_body, ( + f"Reasoning model {model} request must not include 'top_p'" + ) + assert request_body["max_tokens"] == 1000 + + @patch( + "uipath.platform.chat._llm_gateway_service.UiPathLlmChatService.request_async" + ) + @pytest.mark.asyncio + async def test_non_reasoning_model_includes_temperature( + self, mock_request, llm_service + ): + """Test that non-reasoning models still include temperature and sampling params.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1234567890, + "model": "gpt-4.1-mini-2025-04-14", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hello"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + } + mock_request.return_value = mock_response + + await llm_service.chat_completions( + messages=[{"role": "user", "content": "Hello"}], + model="gpt-4.1-mini-2025-04-14", + max_tokens=1000, + temperature=0.5, + ) + + call_kwargs = mock_request.call_args[1] + request_body = call_kwargs["json"] + + assert request_body["temperature"] == 0.5 + assert "n" in request_body + assert "frequency_penalty" in request_body + assert "presence_penalty" in request_body diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 744c292d3..327bd226d 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.10" +version = "0.2.11" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 0df94ca0c..2e5404478 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.13.12" +version = "2.13.13" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index f103aeec0..9a413a7cb 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2598,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.12" +version = "2.13.13" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2741,7 +2741,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.10" +version = "0.2.11" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 684009f192c77d8bfa556ec24da2c72ad12a62cc Mon Sep 17 00:00:00 2001 From: milind-jain-uipath Date: Tue, 21 Jul 2026 14:54:48 +0530 Subject: [PATCH 173/191] feat(entities): relationships_as_scalar query option for FK joins (#1818) Co-authored-by: Claude Opus 4.8 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../platform/entities/_entities_service.py | 26 +++++++----- .../platform/entities/_entity_data_service.py | 33 ++++++++------- .../tests/services/test_entities_service.py | 42 +++++++++++++++---- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 6 files changed, 71 insertions(+), 36 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index cf773124f..265bcfd52 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.11" +version = "0.2.12" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py index 8b346cf0c..0c8f98d92 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py @@ -1821,7 +1821,7 @@ async def retrieve_records_async( @attach_datafabric_error_mapping("query_entity_records") @traced(name="entity_query_records", run_type="uipath") def query_entity_records( - self, sql_query: str, source: Optional[str] = None + self, sql_query: str, *, relationships_as_scalar: bool = False ) -> List[Dict[str, Any]]: """Query entity records using a validated SQL query. @@ -1831,9 +1831,11 @@ def query_entity_records( sql_query (str): A SQL SELECT query to execute against Data Service entities. Only SELECT statements are allowed. Queries without WHERE must include a LIMIT clause. Subqueries and multi-statement queries are not permitted. - source (str, optional): Value for the ``x-uipath-source`` header on the - query/execute request, identifying the calling surface (e.g. - ``"LOW_CODE_AGENT"``). Omitted from the request when not set. + relationships_as_scalar (bool, optional): When ``True``, relationship (FK) + fields are typed as their underlying scalar id instead of ``VARIANT``, + so a query can join on ``relationshipField = Other.Id``. Sent as + ``queryOptions.relationshipsAsScalar`` in the request body. Defaults to + ``False`` (unchanged behaviour). Notes: A routing context is always derived from the configured ``folders_map`` @@ -1846,12 +1848,12 @@ def query_entity_records( ValueError: If the SQL query fails validation (e.g., non-SELECT, missing WHERE/LIMIT, forbidden keywords, subqueries). """ - return self._data.query_entity_records(sql_query, source) + return self._data.query_entity_records(sql_query, relationships_as_scalar) @attach_datafabric_error_mapping("query_entity_records_async") @traced(name="entity_query_records", run_type="uipath") async def query_entity_records_async( - self, sql_query: str, source: Optional[str] = None + self, sql_query: str, *, relationships_as_scalar: bool = False ) -> List[Dict[str, Any]]: """Asynchronously query entity records using a validated SQL query. @@ -1861,9 +1863,11 @@ async def query_entity_records_async( sql_query (str): A SQL SELECT query to execute against Data Service entities. Only SELECT statements are allowed. Queries without WHERE must include a LIMIT clause. Subqueries and multi-statement queries are not permitted. - source (str, optional): Value for the ``x-uipath-source`` header on the - query/execute request, identifying the calling surface (e.g. - ``"LOW_CODE_AGENT"``). Omitted from the request when not set. + relationships_as_scalar (bool, optional): When ``True``, relationship (FK) + fields are typed as their underlying scalar id instead of ``VARIANT``, + so a query can join on ``relationshipField = Other.Id``. Sent as + ``queryOptions.relationshipsAsScalar`` in the request body. Defaults to + ``False`` (unchanged behaviour). Notes: A routing context is always derived from the configured ``folders_map`` @@ -1876,7 +1880,9 @@ async def query_entity_records_async( ValueError: If the SQL query fails validation (e.g., non-SELECT, missing WHERE/LIMIT, forbidden keywords, subqueries). """ - return await self._data.query_entity_records_async(sql_query, source) + return await self._data.query_entity_records_async( + sql_query, relationships_as_scalar + ) @traced(name="entity_upload_attachment", run_type="uipath") def upload_attachment( diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py index 5a98fd7dd..4dc09855d 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py @@ -22,7 +22,6 @@ from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext from ..common._models import Endpoint, RequestSpec -from ..constants import HEADER_SOURCE from ..errors._enriched_exception import EnrichedException from ..orchestrator._folder_service import FolderService from ._entity_resolution import RoutingStrategy, create_routing_strategy @@ -519,18 +518,20 @@ async def retrieve_records_async( def query_entity_records( self, sql_query: str, - source: Optional[str] = None, + relationships_as_scalar: bool = False, ) -> List[Dict[str, Any]]: """Internal implementation; see :meth:`EntitiesService.query_entity_records`.""" - return self._query_entities_for_records(sql_query, source) + return self._query_entities_for_records(sql_query, relationships_as_scalar) async def query_entity_records_async( self, sql_query: str, - source: Optional[str] = None, + relationships_as_scalar: bool = False, ) -> List[Dict[str, Any]]: """Async variant of :meth:`query_entity_records`.""" - return await self._query_entities_for_records_async(sql_query, source) + return await self._query_entities_for_records_async( + sql_query, relationships_as_scalar + ) # ------------------------------------------------------------------ # Attachments @@ -684,27 +685,27 @@ def validate_entity_batch( # ------------------------------------------------------------------ def _query_entities_for_records( - self, sql_query: str, source: Optional[str] = None + self, sql_query: str, relationships_as_scalar: bool = False ) -> List[Dict[str, Any]]: """Synchronously run a validated SQL query through the federated query engine.""" self._validate_sql_query(sql_query) routing_context = self._routing_strategy.resolve() - spec = self._query_entity_records_spec(sql_query, routing_context, source) - response = self.request( - spec.method, spec.endpoint, json=spec.json, headers=spec.headers + spec = self._query_entity_records_spec( + sql_query, routing_context, relationships_as_scalar ) + response = self.request(spec.method, spec.endpoint, json=spec.json) return response.json().get("results", []) async def _query_entities_for_records_async( - self, sql_query: str, source: Optional[str] = None + self, sql_query: str, relationships_as_scalar: bool = False ) -> List[Dict[str, Any]]: """Asynchronously run a validated SQL query through the federated query engine.""" self._validate_sql_query(sql_query) routing_context = await self._routing_strategy.resolve_async() - spec = self._query_entity_records_spec(sql_query, routing_context, source) - response = await self.request_async( - spec.method, spec.endpoint, json=spec.json, headers=spec.headers + spec = self._query_entity_records_spec( + sql_query, routing_context, relationships_as_scalar ) + response = await self.request_async(spec.method, spec.endpoint, json=spec.json) return response.json().get("results", []) @staticmethod @@ -965,7 +966,7 @@ def _retrieve_records_spec( def _query_entity_records_spec( sql_query: str, routing_context: Optional[QueryRoutingOverrideContext] = None, - source: Optional[str] = None, + relationships_as_scalar: bool = False, ) -> RequestSpec: """Build the POST spec for the federated SQL query endpoint.""" body: Dict[str, Any] = {"query": sql_query} @@ -973,12 +974,12 @@ def _query_entity_records_spec( body["routingContext"] = routing_context.model_dump( by_alias=True, exclude_none=True ) - headers = {HEADER_SOURCE: source} if source else {} + if relationships_as_scalar: + body["queryOptions"] = {"relationshipsAsScalar": True} return RequestSpec( method="POST", endpoint=Endpoint("datafabric_/api/v1/query/execute"), json=body, - headers=headers, ) @staticmethod diff --git a/packages/uipath-platform/tests/services/test_entities_service.py b/packages/uipath-platform/tests/services/test_entities_service.py index 547ec5335..f72ff04e8 100644 --- a/packages/uipath-platform/tests/services/test_entities_service.py +++ b/packages/uipath-platform/tests/services/test_entities_service.py @@ -459,7 +459,7 @@ def test_query_entity_records_calls_request_for_valid_sql( assert result == [{"id": 1}, {"id": 2}] service._data.request.assert_called_once() - def test_query_entity_records_sets_source_header_when_provided( + def test_query_entity_records_sets_relationships_as_scalar_option_when_true( self, service: EntitiesService, ) -> None: @@ -468,13 +468,14 @@ def test_query_entity_records_sets_source_header_when_provided( service._data.request = MagicMock(return_value=response) # type: ignore[method-assign] service.query_entity_records( - "SELECT id FROM Customers WHERE id > 0", source="LOW_CODE_AGENT" + "SELECT id FROM Customers WHERE id > 0", relationships_as_scalar=True ) - headers = service._data.request.call_args.kwargs.get("headers") or {} - assert headers.get("x-uipath-source") == "LOW_CODE_AGENT" + call_kwargs = service._data.request.call_args + body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + assert body["queryOptions"] == {"relationshipsAsScalar": True} - def test_query_entity_records_omits_source_header_by_default( + def test_query_entity_records_omits_query_options_by_default( self, service: EntitiesService, ) -> None: @@ -484,8 +485,18 @@ def test_query_entity_records_omits_source_header_by_default( service.query_entity_records("SELECT id FROM Customers WHERE id > 0") - headers = service._data.request.call_args.kwargs.get("headers") or {} - assert "x-uipath-source" not in headers + call_kwargs = service._data.request.call_args + body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + assert "queryOptions" not in body + + def test_query_entity_records_flag_is_keyword_only( + self, + service: EntitiesService, + ) -> None: + # A second positional arg (as an old ``source`` call would pass) must be + # rejected rather than silently coerced into ``relationships_as_scalar``. + with pytest.raises(TypeError): + service.query_entity_records("SELECT id FROM Customers LIMIT 10", True) @pytest.mark.anyio async def test_query_entity_records_async_rejects_invalid_sql_before_network_call( @@ -518,6 +529,23 @@ async def test_query_entity_records_async_calls_request_for_valid_sql( assert result == [{"id": "c1"}] service._data.request_async.assert_called_once() + @pytest.mark.anyio + async def test_query_entity_records_async_sets_relationships_as_scalar_option( + self, + service: EntitiesService, + ) -> None: + response = MagicMock() + response.json.return_value = {"results": []} + service._data.request_async = AsyncMock(return_value=response) # type: ignore[method-assign] + + await service.query_entity_records_async( + "SELECT id FROM Customers WHERE id > 0", relationships_as_scalar=True + ) + + call_kwargs = service._data.request_async.call_args + body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + assert body["queryOptions"] == {"relationshipsAsScalar": True} + def test_query_entity_records_builds_routing_context_from_folders_map( self, config: UiPathApiConfig, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 327bd226d..af4c96bf6 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.11" +version = "0.2.12" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 9a413a7cb..0f554676b 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2741,7 +2741,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.11" +version = "0.2.12" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 9965d6ebfe391099539a64713b71f3affb54d9a1 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Wed, 22 Jul 2026 19:14:00 +0300 Subject: [PATCH 174/191] fix(platform): close service http clients (#1826) --- packages/uipath-platform/pyproject.toml | 3 ++- .../src/uipath/platform/common/_base_service.py | 13 +++++++++++++ .../uipath/platform/entities/_entities_service.py | 13 +++++++++++++ .../platform/orchestrator/_buckets_service.py | 11 +++++++++++ .../uipath/platform/orchestrator/_jobs_service.py | 7 +++++++ .../tests/services/test_base_service.py | 8 ++++++++ .../tests/services/test_buckets_service.py | 12 ++++++++++++ .../tests/services/test_entities_service.py | 14 ++++++++++++++ .../tests/services/test_jobs_service.py | 9 +++++++++ packages/uipath-platform/uv.lock | 4 +++- packages/uipath/uv.lock | 4 +++- 11 files changed, 95 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 265bcfd52..bbf16c8ca 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,10 +1,11 @@ [project] name = "uipath-platform" -version = "0.2.12" +version = "0.2.13" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ + "anyio>=4.0.0", "httpx>=0.28.1", "tenacity>=9.0.0", "truststore>=0.10.1", diff --git a/packages/uipath-platform/src/uipath/platform/common/_base_service.py b/packages/uipath-platform/src/uipath/platform/common/_base_service.py index 9d4053f93..8db2a51d1 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_base_service.py +++ b/packages/uipath-platform/src/uipath/platform/common/_base_service.py @@ -3,6 +3,7 @@ from logging import getLogger from typing import Any, Literal, Union +from anyio import to_thread from httpx import ( URL, AsyncClient, @@ -171,6 +172,18 @@ def __init__( super().__init__() + async def aclose(self) -> None: + """Close the HTTP clients owned by this service. + + The asynchronous client is closed by the active async backend. Closing the + synchronous client can block while its transport is torn down, so that work + runs in a backend-neutral worker thread. + """ + try: + await self._client_async.aclose() + finally: + await to_thread.run_sync(self._client.close) + @retry( retry=( retry_if_exception(is_retryable_platform_exception) diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py index 0c8f98d92..fa2845e63 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py @@ -112,6 +112,19 @@ def __init__( folders_service=folders_service, ) + async def aclose(self) -> None: + """Close this facade and the services it creates.""" + try: + await self._schema.aclose() + finally: + try: + await self._data.aclose() + finally: + try: + await self._ontology.aclose() + finally: + await super().aclose() + # ------------------------------------------------------------------ # Schema operations — delegate to EntitySchemaService # ------------------------------------------------------------------ diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_buckets_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_buckets_service.py index fc1d84db3..893435adb 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_buckets_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_buckets_service.py @@ -5,6 +5,7 @@ from typing import Any, Dict, Optional, Union import httpx +from anyio import to_thread from uipath.core.tracing import traced from ..common._base_service import BaseService @@ -37,6 +38,16 @@ def __init__( self.custom_client = httpx.Client(**get_httpx_client_kwargs()) self.custom_client_async = httpx.AsyncClient(**get_httpx_client_kwargs()) + async def aclose(self) -> None: + """Close the additional HTTP clients used for bucket transfers.""" + try: + await self.custom_client_async.aclose() + finally: + try: + await to_thread.run_sync(self.custom_client.close) + finally: + await super().aclose() + @traced(name="buckets_list", run_type="uipath") def list( self, diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py index 3c1117ab7..fa0103b2c 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py @@ -43,6 +43,13 @@ def __init__( self._temp_dir = os.path.join(tempfile.gettempdir(), TEMP_ATTACHMENTS_FOLDER) os.makedirs(self._temp_dir, exist_ok=True) + async def aclose(self) -> None: + """Close this service and the attachment service it owns.""" + try: + await self._attachments_service.aclose() + finally: + await super().aclose() + @overload def resume(self, *, inbox_id: str, payload: Any) -> None: ... diff --git a/packages/uipath-platform/tests/services/test_base_service.py b/packages/uipath-platform/tests/services/test_base_service.py index 83e7895c8..db39ed313 100644 --- a/packages/uipath-platform/tests/services/test_base_service.py +++ b/packages/uipath-platform/tests/services/test_base_service.py @@ -18,6 +18,14 @@ class TestBaseService: def test_init_base_service(self, service: BaseService): assert service is not None + @pytest.mark.parametrize("anyio_backend", ["asyncio", "trio"]) + @pytest.mark.anyio + async def test_aclose_closes_owned_http_clients(self, service: BaseService): + await service.aclose() + + assert service._client.is_closed + assert service._client_async.is_closed + def test_base_service_default_headers(self, service: BaseService, secret: str): assert service.default_headers == { "Accept": "application/json", diff --git a/packages/uipath-platform/tests/services/test_buckets_service.py b/packages/uipath-platform/tests/services/test_buckets_service.py index 8cbb9f50c..464d675d2 100644 --- a/packages/uipath-platform/tests/services/test_buckets_service.py +++ b/packages/uipath-platform/tests/services/test_buckets_service.py @@ -27,6 +27,18 @@ def temp_file(tmp_path): class TestBucketsService: + @pytest.mark.parametrize("anyio_backend", ["asyncio", "trio"]) + @pytest.mark.anyio + async def test_aclose_closes_all_owned_http_clients( + self, service: BucketsService + ) -> None: + await service.aclose() + + assert service._client.is_closed + assert service._client_async.is_closed + assert service.custom_client.is_closed + assert service.custom_client_async.is_closed + class TestRetrieve: def test_retrieve_by_key( self, diff --git a/packages/uipath-platform/tests/services/test_entities_service.py b/packages/uipath-platform/tests/services/test_entities_service.py index f72ff04e8..9a63bbc46 100644 --- a/packages/uipath-platform/tests/services/test_entities_service.py +++ b/packages/uipath-platform/tests/services/test_entities_service.py @@ -55,6 +55,20 @@ def record_schema_optional(request): class TestEntitiesService: + @pytest.mark.parametrize("anyio_backend", ["asyncio", "trio"]) + @pytest.mark.anyio + async def test_aclose_closes_owned_services(self, service: EntitiesService): + await service.aclose() + + assert service._client.is_closed + assert service._client_async.is_closed + assert service._schema._client.is_closed + assert service._schema._client_async.is_closed + assert service._data._client.is_closed + assert service._data._client_async.is_closed + assert service._ontology._client.is_closed + assert service._ontology._client_async.is_closed + def test_query_entity_records_has_datafabric_error_mapping(self) -> None: assert ( EntitiesService.query_entity_records.__uipath_datafabric_method__ # type: ignore[attr-defined] diff --git a/packages/uipath-platform/tests/services/test_jobs_service.py b/packages/uipath-platform/tests/services/test_jobs_service.py index 28d24a923..6782855d4 100644 --- a/packages/uipath-platform/tests/services/test_jobs_service.py +++ b/packages/uipath-platform/tests/services/test_jobs_service.py @@ -101,6 +101,15 @@ def local_attachment_file( class TestJobsService: + @pytest.mark.anyio + async def test_aclose_closes_owned_services(self, service: JobsService): + await service.aclose() + + assert service._client.is_closed + assert service._client_async.is_closed + assert service._attachments_service._client.is_closed + assert service._attachments_service._client_async.is_closed + def test_retrieve( self, httpx_mock: HTTPXMock, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index af4c96bf6..295f77671 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,9 +1095,10 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.12" +version = "0.2.13" source = { editable = "." } dependencies = [ + { name = "anyio" }, { name = "httpx" }, { name = "pydantic-function-models" }, { name = "sqlparse" }, @@ -1123,6 +1124,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "anyio", specifier = ">=4.0.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "pydantic-function-models", specifier = ">=0.1.11" }, { name = "sqlparse", specifier = ">=0.5.5" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 0f554676b..1314ef227 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2741,9 +2741,10 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.12" +version = "0.2.13" source = { editable = "../uipath-platform" } dependencies = [ + { name = "anyio" }, { name = "httpx" }, { name = "pydantic-function-models" }, { name = "sqlparse" }, @@ -2754,6 +2755,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "anyio", specifier = ">=4.0.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "pydantic-function-models", specifier = ">=0.1.11" }, { name = "sqlparse", specifier = ">=0.5.5" }, From bb0041202e50857818df34080507ee9583ac5243 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Wed, 22 Jul 2026 11:47:54 -0700 Subject: [PATCH 175/191] fix(eval): reject out-of-range scores from LLM judge tool calls (#1819) Co-authored-by: Claude Fable 5 --- packages/uipath/pyproject.toml | 2 +- .../eval/evaluators/legacy_llm_helpers.py | 26 +++++- .../evaluators/test_legacy_llm_helpers.py | 84 +++++++++++++++++++ packages/uipath/uv.lock | 2 +- 4 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 packages/uipath/tests/evaluators/test_legacy_llm_helpers.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 2e5404478..ec813ed17 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.13.13" +version = "2.13.14" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/eval/evaluators/legacy_llm_helpers.py b/packages/uipath/src/uipath/eval/evaluators/legacy_llm_helpers.py index 67aaee0c3..8cb8da0de 100644 --- a/packages/uipath/src/uipath/eval/evaluators/legacy_llm_helpers.py +++ b/packages/uipath/src/uipath/eval/evaluators/legacy_llm_helpers.py @@ -1,6 +1,7 @@ """Helper functions for legacy LLM evaluators using function calling.""" import logging +import math from typing import Any from uipath.platform.chat.llm_gateway import ( @@ -89,9 +90,32 @@ def extract_tool_call_response(response: Any, model: str) -> LLMResponse: logger.debug(f"Arguments: {arguments}") raise ValueError(error_msg) - score = float(arguments["score"]) + try: + score = float(arguments["score"]) + except (ValueError, TypeError) as e: + error_msg = ( + f"Non-numeric score {arguments['score']!r} in tool call arguments " + f"from model {model}: expected a number between 0 and 100" + ) + logger.error(f"❌ {error_msg}") + logger.debug(f"Arguments: {arguments}") + raise ValueError(error_msg) from e + justification = str(arguments["justification"]) + # Models occasionally emit corrupted numeric tool arguments despite the + # 0-100 range stated in the tool schema (e.g. gemini-2.5-flash returning + # 989898 or 950). Unvalidated, such a value poisons every run-level + # aggregate downstream, so reject it and let the evaluation surface as + # an error instead of recording a fabricated score. + if not math.isfinite(score) or score < 0.0 or score > 100.0: + error_msg = ( + f"Invalid score {score!r} in tool call arguments from model {model}: " + f"expected a number between 0 and 100" + ) + logger.error(f"❌ {error_msg}") + raise ValueError(error_msg) + logger.debug( f"✅ Extracted score: {score}, justification length: {len(justification)} chars" ) diff --git a/packages/uipath/tests/evaluators/test_legacy_llm_helpers.py b/packages/uipath/tests/evaluators/test_legacy_llm_helpers.py new file mode 100644 index 000000000..c44f8d855 --- /dev/null +++ b/packages/uipath/tests/evaluators/test_legacy_llm_helpers.py @@ -0,0 +1,84 @@ +"""Tests for legacy LLM helper functions (submit_evaluation tool-call parsing).""" + +from types import SimpleNamespace +from typing import Any + +import pytest + +from uipath.eval.evaluators.legacy_llm_helpers import extract_tool_call_response + + +def _make_response(arguments: dict[str, Any]) -> Any: + """Build a minimal chat-completions response carrying a submit_evaluation tool call.""" + tool_call = SimpleNamespace(arguments=arguments) + message = SimpleNamespace(tool_calls=[tool_call]) + choice = SimpleNamespace(message=message) + return SimpleNamespace(choices=[choice]) + + +class TestExtractToolCallResponse: + """Test extract_tool_call_response score validation.""" + + def test_valid_score_passes_through(self) -> None: + response = _make_response({"score": 88, "justification": "ok"}) + + result = extract_tool_call_response(response, "gemini-2.5-flash") + + assert result.score == 88.0 + assert result.justification == "ok" + + def test_out_of_range_score_is_rejected(self) -> None: + # Real payload observed in production: gemini-2.5-flash returned + # score=989898 in its submit_evaluation tool call while the justification + # said the outputs "match perfectly". Unvalidated, this single value blew + # a 64-item run-level average up to 15559.13%. The evaluation must surface + # as an error rather than record a fabricated score. + response = _make_response( + {"score": 989898, "justification": "matches perfectly"} + ) + + with pytest.raises(ValueError, match="Invalid score 989898"): + extract_tool_call_response(response, "gemini-2.5-flash") + + def test_out_of_range_950_is_rejected(self) -> None: + # Second production occurrence from the same eval run: score=950 + # (the model most likely intended 95). + response = _make_response({"score": 950, "justification": "equivalent"}) + + with pytest.raises(ValueError, match="Invalid score 950"): + extract_tool_call_response(response, "gemini-2.5-flash") + + def test_negative_score_is_rejected(self) -> None: + response = _make_response({"score": -5, "justification": "bad"}) + + with pytest.raises(ValueError, match="Invalid score -5"): + extract_tool_call_response(response, "gpt-4o") + + @pytest.mark.parametrize("boundary", [0, 100]) + def test_boundary_scores_accepted(self, boundary: int) -> None: + response = _make_response({"score": boundary, "justification": "j"}) + + result = extract_tool_call_response(response, "m") + + assert result.score == float(boundary) + assert result.justification == "j" + + @pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")]) + def test_non_finite_score_is_rejected(self, value: float) -> None: + response = _make_response({"score": value, "justification": "j"}) + + with pytest.raises(ValueError, match="Invalid score"): + extract_tool_call_response(response, "m") + + def test_missing_score_raises(self) -> None: + response = _make_response({"justification": "j"}) + + with pytest.raises(ValueError, match="Missing 'score'"): + extract_tool_call_response(response, "m") + + @pytest.mark.parametrize("value", ["not-a-number", None, [95]]) + def test_non_numeric_score_is_rejected(self, value: Any) -> None: + response = _make_response({"score": value, "justification": "j"}) + + with pytest.raises(ValueError, match="Non-numeric score"): + extract_tool_call_response(response, "m") diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 1314ef227..cca93a5f9 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2598,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.13" +version = "2.13.14" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 7be0e7d7c97dd9b6184e46eea4c28aa5574f2885 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Thu, 23 Jul 2026 18:11:23 +0300 Subject: [PATCH 176/191] feat: add periodic telemetry event flushing (#1829) --- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/telemetry/__init__.py | 6 +- .../uipath/src/uipath/telemetry/_constants.py | 2 + .../uipath/src/uipath/telemetry/_track.py | 281 ++++++++++++------ packages/uipath/tests/telemetry/test_track.py | 246 ++++++++++++++- packages/uipath/uv.lock | 2 +- 6 files changed, 437 insertions(+), 102 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index ec813ed17..b35e51aee 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.13.14" +version = "2.13.15" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/telemetry/__init__.py b/packages/uipath/src/uipath/telemetry/__init__.py index 92b3a354c..77b79bf1d 100644 --- a/packages/uipath/src/uipath/telemetry/__init__.py +++ b/packages/uipath/src/uipath/telemetry/__init__.py @@ -1,4 +1,7 @@ -from ._track import ( # noqa: D104 +"""UiPath telemetry tracking.""" + +from ._constants import PERIODIC_TELEMETRY_FLUSH_FEATURE_FLAG +from ._track import ( flush_events, is_telemetry_enabled, reset_event_client, @@ -8,6 +11,7 @@ ) __all__ = [ + "PERIODIC_TELEMETRY_FLUSH_FEATURE_FLAG", "track", "track_event", "is_telemetry_enabled", diff --git a/packages/uipath/src/uipath/telemetry/_constants.py b/packages/uipath/src/uipath/telemetry/_constants.py index 2583c6715..7a9104deb 100644 --- a/packages/uipath/src/uipath/telemetry/_constants.py +++ b/packages/uipath/src/uipath/telemetry/_constants.py @@ -1,5 +1,7 @@ _CONNECTION_STRING = "$CONNECTION_STRING" +PERIODIC_TELEMETRY_FLUSH_FEATURE_FLAG = "EnablePeriodicTelemetryFlush" + _APP_INSIGHTS_EVENT_MARKER_ATTRIBUTE = "APPLICATION_INSIGHTS_EVENT_MARKER_ATTRIBUTE" _OTEL_RESOURCE_ATTRIBUTES = "OTEL_RESOURCE_ATTRIBUTES" _SDK_VERSION = "SdkVersion" diff --git a/packages/uipath/src/uipath/telemetry/_track.py b/packages/uipath/src/uipath/telemetry/_track.py index efde079c4..8d09b69fc 100644 --- a/packages/uipath/src/uipath/telemetry/_track.py +++ b/packages/uipath/src/uipath/telemetry/_track.py @@ -1,14 +1,16 @@ import atexit import json import os +import threading from functools import wraps from importlib.metadata import version from logging import INFO, WARNING, LogRecord, getLogger -from typing import Any, Callable, ClassVar, Dict, Mapping, Optional, Union +from typing import Any, Callable, ClassVar, Dict, Mapping from opentelemetry.sdk._logs import LoggingHandler from opentelemetry.util.types import AnyValue +from uipath.core.feature_flags import FeatureFlags from uipath.platform.constants import ( ENV_BASE_URL, ENV_ORGANIZATION_ID, @@ -32,6 +34,7 @@ _SDK_VERSION, _TELEMETRY_CONFIG_FILE, _UNKNOWN, + PERIODIC_TELEMETRY_FLUSH_FEATURE_FLAG, ) # Try to import Application Insights client for custom events @@ -59,7 +62,7 @@ def _parse_connection_string( connection_string: str, -) -> Optional[Dict[str, str]]: +) -> Dict[str, str] | None: """Parse Azure Application Insights connection string. Args: @@ -90,6 +93,8 @@ def _parse_connection_string( _logger = getLogger(__name__) _logger.propagate = False +_PERIODIC_TELEMETRY_FLUSH_INTERVAL_SECONDS = 5.0 + def _get_connection_string() -> str | None: """Get the Application Insights connection string. @@ -230,13 +235,19 @@ class _AppInsightsEventClient: """ _initialized = False - _client: Optional[Any] = None + _client: Any | None = None _atexit_registered = False - _connection_string_provider: ClassVar[Optional[Callable[[], Optional[str]]]] = None + _connection_string_provider: ClassVar[Callable[[], str | None] | None] = None + _lifecycle_lock = threading.RLock() + _flush_lock = threading.Lock() + _flush_thread: threading.Thread | None = None + _flush_stop_event: threading.Event | None = None + _pending_events = threading.Event() + _shutdown_requested = False @staticmethod def set_connection_string_provider( - provider: Callable[[], Optional[str]], + provider: Callable[[], str | None], ) -> None: """Override how the connection string is resolved. @@ -248,59 +259,101 @@ def set_connection_string_provider( @staticmethod def _initialize() -> None: """Initialize Application Insights client for custom events.""" - if _AppInsightsEventClient._initialized: - return - - _AppInsightsEventClient._initialized = True + with _AppInsightsEventClient._lifecycle_lock: + if _AppInsightsEventClient._shutdown_requested: + return + if _AppInsightsEventClient._initialized: + return - # Suppress verbose logging from Application Insights SDK - # The SDK logs telemetry ingestion details which should not be user-facing - getLogger("applicationinsights").setLevel(WARNING) - getLogger("applicationinsights.channel").setLevel(WARNING) + _AppInsightsEventClient._initialized = True - if not _HAS_APPINSIGHTS: - return + # Suppress verbose logging from Application Insights SDK + # The SDK logs telemetry ingestion details which should not be user-facing + getLogger("applicationinsights").setLevel(WARNING) + getLogger("applicationinsights.channel").setLevel(WARNING) - if _AppInsightsEventClient._connection_string_provider: - connection_string = _AppInsightsEventClient._connection_string_provider() - else: - connection_string = _get_connection_string() - if not connection_string: - return + if not _HAS_APPINSIGHTS: + return - try: - parsed = _parse_connection_string(connection_string) - if not parsed: + if _AppInsightsEventClient._connection_string_provider: + connection_string = ( + _AppInsightsEventClient._connection_string_provider() + ) + else: + connection_string = _get_connection_string() + if not connection_string: return - instrumentation_key = parsed["InstrumentationKey"] - ingestion_endpoint = parsed.get("IngestionEndpoint") + try: + parsed = _parse_connection_string(connection_string) + if not parsed: + return - # Build custom channel: DiagnosticSender → SynchronousQueue → TelemetryChannel - if ingestion_endpoint: - endpoint_url = ingestion_endpoint.rstrip("/") + "/v2/track" - else: - endpoint_url = None # SDK default + instrumentation_key = parsed["InstrumentationKey"] + ingestion_endpoint = parsed.get("IngestionEndpoint") - sender = _DiagnosticSender(service_endpoint_uri=endpoint_url) - queue = SynchronousQueue(sender) - channel = TelemetryChannel(queue=queue) + # Build custom channel: DiagnosticSender → SynchronousQueue → TelemetryChannel + if ingestion_endpoint: + endpoint_url = ingestion_endpoint.rstrip("/") + "/v2/track" + else: + endpoint_url = None # SDK default + + sender = _DiagnosticSender(service_endpoint_uri=endpoint_url) + queue = SynchronousQueue(sender) + channel = TelemetryChannel(queue=queue) + + _AppInsightsEventClient._client = AppInsightsTelemetryClient( + instrumentation_key, telemetry_channel=channel + ) + + # Set application version + _AppInsightsEventClient._client.context.application.ver = version( + "uipath" + ) + except Exception as e: + # Log but don't raise - telemetry should never break the main application + _logger.warning( + f"Failed to initialize Application Insights client: {e}" + ) + _logger.debug( + "Application Insights initialization error", exc_info=True + ) + + @staticmethod + def _ensure_periodic_flush_worker() -> None: + """Start the feature-gated periodic flush worker once.""" + if not FeatureFlags.is_flag_enabled(PERIODIC_TELEMETRY_FLUSH_FEATURE_FLAG): + return - _AppInsightsEventClient._client = AppInsightsTelemetryClient( - instrumentation_key, telemetry_channel=channel + with _AppInsightsEventClient._lifecycle_lock: + if _AppInsightsEventClient._shutdown_requested: + return + current = _AppInsightsEventClient._flush_thread + if current and current.is_alive(): + return + + stop_event = threading.Event() + worker = threading.Thread( + target=_AppInsightsEventClient._periodic_flush_worker, + args=(stop_event,), + name="uipath-appinsights-flush", + daemon=True, ) + _AppInsightsEventClient._flush_stop_event = stop_event + _AppInsightsEventClient._flush_thread = worker + worker.start() - # Set application version - _AppInsightsEventClient._client.context.application.ver = version("uipath") - except Exception as e: - # Log but don't raise - telemetry should never break the main application - _logger.warning(f"Failed to initialize Application Insights client: {e}") - _logger.debug("Application Insights initialization error", exc_info=True) + @staticmethod + def _periodic_flush_worker(stop_event: threading.Event) -> None: + """Flush periodically until shutdown is requested.""" + while not stop_event.wait(_PERIODIC_TELEMETRY_FLUSH_INTERVAL_SECONDS): + if _AppInsightsEventClient._pending_events.is_set(): + _AppInsightsEventClient.flush() @staticmethod def track_event( name: str, - properties: Optional[Dict[str, Any]] = None, + properties: Dict[str, Any] | None = None, ) -> None: """Track a custom event to Application Insights customEvents table. @@ -310,62 +363,100 @@ def track_event( """ _AppInsightsEventClient._initialize() - if not _AppInsightsEventClient._client: - return + with _AppInsightsEventClient._lifecycle_lock: + if _AppInsightsEventClient._shutdown_requested: + return - try: - safe_properties: Dict[str, str] = {} - if properties: - for key, value in properties.items(): - if value is not None: - safe_properties[key] = str(value) - - _AppInsightsEventClient._client.track_event( - name=name, properties=safe_properties, measurements={} - ) - # Note: We don't flush after every event to avoid blocking. - # Events will be sent in batches by the SDK. - except Exception as e: - # Log but don't raise - telemetry should never break the main application - _logger.warning(f"Failed to track event '{name}': {e}") - _logger.debug(f"Event tracking error for '{name}'", exc_info=True) + if not _AppInsightsEventClient._client: + return + + _AppInsightsEventClient._ensure_periodic_flush_worker() + + try: + safe_properties: Dict[str, str] = {} + if properties: + for key, value in properties.items(): + if value is not None: + safe_properties[key] = str(value) + + client = _AppInsightsEventClient._client + if not client: + return + client.track_event( + name=name, properties=safe_properties, measurements={} + ) + _AppInsightsEventClient._pending_events.set() + # Note: We don't flush after every event to avoid blocking. + # Events are sent when the queue fills, on explicit/shutdown flush, + # or periodically when the periodic flush feature is enabled. + except Exception as e: + # Log but don't raise - telemetry should never break the main application + _logger.warning(f"Failed to track event '{name}': {e}") + _logger.debug(f"Event tracking error for '{name}'", exc_info=True) @staticmethod def flush() -> None: """Flush any pending telemetry events.""" - if _AppInsightsEventClient._client: - try: - _AppInsightsEventClient._client.flush() - # Check if items remain after flush (indicates send failure) + with _AppInsightsEventClient._flush_lock: + client = _AppInsightsEventClient._client + if client: + _AppInsightsEventClient._pending_events.clear() try: - remaining = ( - _AppInsightsEventClient._client.channel.queue._queue.qsize() - ) - if remaining > 0: - _logger.warning( - "AppInsights flush: %d items still in queue after flush", - remaining, - ) - except Exception: - pass - except Exception as e: - # Log but don't raise - telemetry should never break the main application - _logger.warning(f"Failed to flush telemetry events: {e}") - _logger.debug("Telemetry flush error", exc_info=True) + client.flush() + # Check if items remain after flush (indicates send failure) + try: + remaining = client.channel.queue._queue.qsize() + if remaining > 0: + _AppInsightsEventClient._pending_events.set() + _logger.warning( + "AppInsights flush: %d items still in queue after flush", + remaining, + ) + except Exception: + pass + except Exception as e: + _AppInsightsEventClient._pending_events.set() + # Log but don't raise - telemetry should never break the main application + _logger.warning(f"Failed to flush telemetry events: {e}") + _logger.debug("Telemetry flush error", exc_info=True) + + @staticmethod + def _shutdown(*, reset_client: bool = False) -> None: + """Stop the periodic worker and perform a synchronized final flush.""" + with _AppInsightsEventClient._lifecycle_lock: + _AppInsightsEventClient._shutdown_requested = True + stop_event = _AppInsightsEventClient._flush_stop_event + worker = _AppInsightsEventClient._flush_thread + if stop_event: + stop_event.set() + + if worker and worker is not threading.current_thread(): + worker.join() + + _AppInsightsEventClient.flush() + + if _AppInsightsEventClient._flush_thread is worker: + _AppInsightsEventClient._flush_thread = None + _AppInsightsEventClient._flush_stop_event = None + + if reset_client: + _AppInsightsEventClient._client = None + _AppInsightsEventClient._initialized = False + _AppInsightsEventClient._pending_events.clear() + _AppInsightsEventClient._shutdown_requested = False @staticmethod def register_atexit_flush() -> None: - """Register an atexit handler to flush events on process exit.""" - if not _AppInsightsEventClient._atexit_registered: - atexit.register(_AppInsightsEventClient.flush) - _AppInsightsEventClient._atexit_registered = True + """Register an atexit handler to stop the worker and flush events.""" + with _AppInsightsEventClient._lifecycle_lock: + if not _AppInsightsEventClient._atexit_registered: + atexit.register(_AppInsightsEventClient._shutdown) + _AppInsightsEventClient._atexit_registered = True @staticmethod def reset() -> None: - """Flush pending events and reset so the next call re-initializes.""" - _AppInsightsEventClient.flush() - _AppInsightsEventClient._client = None - _AppInsightsEventClient._initialized = False + """Flush pending events, stop the worker, and reset client state.""" + _AppInsightsEventClient._shutdown(reset_client=True) class _TelemetryClient: @@ -405,7 +496,7 @@ def _initialize(): _logger.debug("Telemetry initialization error", exc_info=True) @staticmethod - def _track_method(name: str, attrs: Optional[Dict[str, Any]] = None): + def _track_method(name: str, attrs: Dict[str, Any] | None = None): """Track function invocations using OpenTelemetry.""" if not _TelemetryClient._is_enabled(): return @@ -417,7 +508,7 @@ def _track_method(name: str, attrs: Optional[Dict[str, Any]] = None): @staticmethod def track_event( name: str, - properties: Optional[Dict[str, Any]] = None, + properties: Dict[str, Any] | None = None, ) -> None: """Track a custom event to Application Insights customEvents table. @@ -453,7 +544,7 @@ def track_event( def track_event( name: str, - properties: Optional[Dict[str, Any]] = None, + properties: Dict[str, Any] | None = None, ) -> None: """Track a custom event. @@ -494,7 +585,7 @@ def flush_events() -> None: def set_event_connection_string_provider( - provider: Callable[[], Optional[str]], + provider: Callable[[], str | None], ) -> None: """Override how the Application Insights connection string is resolved. @@ -511,7 +602,7 @@ def reset_event_client() -> None: def track_cli_event( name: str, - properties: Optional[Dict[str, Any]] = None, + properties: Dict[str, Any] | None = None, ) -> None: """Track a CLI event. @@ -527,10 +618,10 @@ def track_cli_event( def track( - name_or_func: Optional[Union[str, Callable[..., Any]]] = None, + name_or_func: str | Callable[..., Any] | None = None, *, - when: Optional[Union[bool, Callable[..., bool]]] = True, - extra: Optional[Dict[str, Any]] = None, + when: bool | Callable[..., bool] | None = True, + extra: Dict[str, Any] | None = None, ): """Decorator that will trace function invocations. diff --git a/packages/uipath/tests/telemetry/test_track.py b/packages/uipath/tests/telemetry/test_track.py index ed44846b9..738c4ea2b 100644 --- a/packages/uipath/tests/telemetry/test_track.py +++ b/packages/uipath/tests/telemetry/test_track.py @@ -2,15 +2,20 @@ import json import os +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from queue import Empty, Queue from unittest.mock import MagicMock, patch import pytest +from uipath.core.feature_flags import FeatureFlags from uipath.platform.constants import ( ENV_PROJECT_KEY, ENV_UIPATH_AGENT_ID, ENV_UIPATH_PROJECT_ID, ) +from uipath.telemetry import PERIODIC_TELEMETRY_FLUSH_FEATURE_FLAG from uipath.telemetry._track import ( _AppInsightsEventClient, _DiagnosticSender, @@ -26,6 +31,49 @@ ) +@pytest.fixture +def appinsights_ingestion_server(): + """Capture App Insights ingestion requests on a local HTTP server.""" + received: Queue[list[dict[str, object]]] = Queue() + + class Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: + content_length = int(self.headers.get("Content-Length", "0")) + payload = json.loads(self.rfile.read(content_length)) + received.put(payload) + self.send_response(200) + self.end_headers() + + def log_message(self, format: str, *args: object) -> None: + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}", received + finally: + server.shutdown() + server.server_close() + server_thread.join() + + +def _event_names(payload: list[dict[str, object]]) -> list[str]: + """Extract custom-event names from an App Insights ingestion payload.""" + names: list[str] = [] + for envelope in payload: + data = envelope.get("data") + if not isinstance(data, dict): + continue + base_data = data.get("baseData") + if not isinstance(base_data, dict): + continue + name = base_data.get("name") + if isinstance(name, str): + names.append(name) + return names + + class TestGetProjectKey: """`_get_project_key` resolution: uipath.json#id, then legacy telemetry file.""" @@ -128,15 +176,15 @@ class TestAppInsightsEventClient: def setup_method(self): """Reset AppInsightsEventClient state before each test.""" - _AppInsightsEventClient._initialized = False - _AppInsightsEventClient._client = None + _AppInsightsEventClient.reset() _AppInsightsEventClient._connection_string_provider = None + FeatureFlags.reset_flags() def teardown_method(self): """Clean up after each test.""" - _AppInsightsEventClient._initialized = False - _AppInsightsEventClient._client = None + _AppInsightsEventClient.reset() _AppInsightsEventClient._connection_string_provider = None + FeatureFlags.reset_flags() @patch("uipath.telemetry._track._CONNECTION_STRING", "$CONNECTION_STRING") def test_initialize_no_connection_string(self): @@ -428,6 +476,196 @@ def test_reset_allows_reinitialization_with_new_connection_string( assert _AppInsightsEventClient._client is mock_client_2 assert mock_client_class.call_count == 2 + def test_single_event_remains_buffered_when_periodic_flush_disabled( + self, monkeypatch, appinsights_ingestion_server + ): + """Default behavior buffers one event until an explicit flush.""" + endpoint, received = appinsights_ingestion_server + monkeypatch.setenv( + "TELEMETRY_CONNECTION_STRING", + f"InstrumentationKey=test-key;IngestionEndpoint={endpoint}", + ) + monkeypatch.setattr( + "uipath.telemetry._track._PERIODIC_TELEMETRY_FLUSH_INTERVAL_SECONDS", + 0.05, + ) + + track_event("single-buffered-event") + + with pytest.raises(Empty): + received.get(timeout=0.15) + + flush_events() + payload = received.get(timeout=1) + assert _event_names(payload) == ["single-buffered-event"] + + def test_single_event_is_sent_by_periodic_flush( + self, monkeypatch, appinsights_ingestion_server + ): + """Enabled periodic flushing sends one event without an explicit flush.""" + endpoint, received = appinsights_ingestion_server + monkeypatch.setenv( + "TELEMETRY_CONNECTION_STRING", + f"InstrumentationKey=test-key;IngestionEndpoint={endpoint}", + ) + monkeypatch.setattr( + "uipath.telemetry._track._PERIODIC_TELEMETRY_FLUSH_INTERVAL_SECONDS", + 0.05, + ) + monkeypatch.setenv( + f"UIPATH_FEATURE_{PERIODIC_TELEMETRY_FLUSH_FEATURE_FLAG}", "true" + ) + + track_event("single-periodic-event") + + payload = received.get(timeout=1) + assert _event_names(payload) == ["single-periodic-event"] + + def test_periodic_worker_skips_flush_without_pending_events(self, monkeypatch): + """Periodic ticks do not flush while no event is queued.""" + monkeypatch.setattr( + "uipath.telemetry._track._PERIODIC_TELEMETRY_FLUSH_INTERVAL_SECONDS", + 0.01, + ) + FeatureFlags.configure_flags({PERIODIC_TELEMETRY_FLUSH_FEATURE_FLAG: True}) + mock_client = MagicMock() + monkeypatch.setattr(_AppInsightsEventClient, "_initialized", True) + monkeypatch.setattr(_AppInsightsEventClient, "_client", mock_client) + + _AppInsightsEventClient._ensure_periodic_flush_worker() + threading.Event().wait(0.05) + + mock_client.flush.assert_not_called() + + def test_shutdown_waits_for_worker_and_serializes_final_flush(self, monkeypatch): + """Shutdown joins an active worker before performing its final flush.""" + monkeypatch.setattr( + "uipath.telemetry._track._PERIODIC_TELEMETRY_FLUSH_INTERVAL_SECONDS", + 0.01, + ) + FeatureFlags.configure_flags({PERIODIC_TELEMETRY_FLUSH_FEATURE_FLAG: True}) + + flush_entered = threading.Event() + release_flush = threading.Event() + counter_lock = threading.Lock() + active_flushes = 0 + max_active_flushes = 0 + + def blocking_flush() -> None: + nonlocal active_flushes, max_active_flushes + with counter_lock: + active_flushes += 1 + max_active_flushes = max(max_active_flushes, active_flushes) + flush_entered.set() + release_flush.wait(timeout=1) + with counter_lock: + active_flushes -= 1 + + mock_client = MagicMock() + mock_client.flush.side_effect = blocking_flush + monkeypatch.setattr(_AppInsightsEventClient, "_initialized", True) + monkeypatch.setattr(_AppInsightsEventClient, "_client", mock_client) + _AppInsightsEventClient.track_event("test-event") + assert flush_entered.wait(timeout=1) + + shutdown_thread = threading.Thread(target=_AppInsightsEventClient._shutdown) + shutdown_thread.start() + shutdown_thread.join(timeout=0.05) + assert shutdown_thread.is_alive() + + release_flush.set() + shutdown_thread.join(timeout=1) + + assert not shutdown_thread.is_alive() + assert max_active_flushes == 1 + assert mock_client.flush.call_count == 2 + assert _AppInsightsEventClient._flush_thread is None + + def test_periodic_flush_does_not_block_event_tracking(self, monkeypatch): + """A slow flush must not block the event-producing thread.""" + FeatureFlags.configure_flags({PERIODIC_TELEMETRY_FLUSH_FEATURE_FLAG: True}) + flush_entered = threading.Event() + release_flush = threading.Event() + + def blocking_flush() -> None: + flush_entered.set() + release_flush.wait(timeout=1) + + mock_client = MagicMock() + mock_client.flush.side_effect = blocking_flush + monkeypatch.setattr(_AppInsightsEventClient, "_initialized", True) + monkeypatch.setattr(_AppInsightsEventClient, "_client", mock_client) + + flush_thread = threading.Thread(target=_AppInsightsEventClient.flush) + flush_thread.start() + assert flush_entered.wait(timeout=1) + + track_thread = threading.Thread( + target=_AppInsightsEventClient.track_event, + args=("event-during-flush",), + ) + track_thread.start() + track_thread.join(timeout=0.2) + + assert not track_thread.is_alive() + mock_client.track_event.assert_called_once() + + release_flush.set() + flush_thread.join(timeout=1) + assert not flush_thread.is_alive() + + def test_reset_prevents_worker_restart_during_shutdown(self, monkeypatch): + """Reset keeps worker lifecycle serialized until client state is cleared.""" + monkeypatch.setattr( + "uipath.telemetry._track._PERIODIC_TELEMETRY_FLUSH_INTERVAL_SECONDS", + 0.01, + ) + FeatureFlags.configure_flags({PERIODIC_TELEMETRY_FLUSH_FEATURE_FLAG: True}) + flush_entered = threading.Event() + release_flush = threading.Event() + + def blocking_flush() -> None: + flush_entered.set() + release_flush.wait(timeout=1) + + mock_client = MagicMock() + mock_client.flush.side_effect = blocking_flush + monkeypatch.setattr(_AppInsightsEventClient, "_initialized", True) + monkeypatch.setattr(_AppInsightsEventClient, "_client", mock_client) + _AppInsightsEventClient.track_event("start-worker") + assert flush_entered.wait(timeout=1) + + reset_thread = threading.Thread(target=_AppInsightsEventClient.reset) + reset_thread.start() + reset_thread.join(timeout=0.05) + assert reset_thread.is_alive() + + track_thread = threading.Thread( + target=_AppInsightsEventClient.track_event, + args=("event-during-reset",), + ) + track_thread.start() + track_thread.join(timeout=0.05) + assert track_thread.is_alive() + + release_flush.set() + reset_thread.join(timeout=1) + track_thread.join(timeout=1) + + assert not reset_thread.is_alive() + assert not track_thread.is_alive() + assert _AppInsightsEventClient._client is None + assert _AppInsightsEventClient._flush_thread is None + + def test_atexit_registration_uses_synchronized_shutdown(self, monkeypatch): + """The idempotent atexit callback owns worker shutdown and final flush.""" + monkeypatch.setattr(_AppInsightsEventClient, "_atexit_registered", False) + with patch("uipath.telemetry._track.atexit.register") as register: + _AppInsightsEventClient.register_atexit_flush() + _AppInsightsEventClient.register_atexit_flush() + + register.assert_called_once_with(_AppInsightsEventClient._shutdown) + class TestPublicProviderAndResetFunctions: """Test the public set_event_connection_string_provider and reset_event_client.""" diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index cca93a5f9..796ffb972 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2598,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.14" +version = "2.13.15" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 71c96fec571ec0fcd1590fdf99dd096291d8c06b Mon Sep 17 00:00:00 2001 From: ajay-kesavan Date: Thu, 23 Jul 2026 15:04:51 -0700 Subject: [PATCH 177/191] feat(eval): dataset-level evaluators + classification schemas, samples, e2e (#1663) Co-authored-by: Claude Opus 4.7 (1M context) --- .../eval/evaluators/_aggregator_specs.py | 62 ++ .../eval/evaluators/base_dataset_evaluator.py | 45 ++ .../uipath/eval/evaluators/base_evaluator.py | 16 + .../classification_dataset_evaluators.py | 270 ++++++++ .../evaluators/dataset_evaluator_factory.py | 65 ++ .../eval/evaluators/exact_match_evaluator.py | 64 ++ .../evaluators_types/ExactMatchEvaluator.json | 192 +++++- .../uipath/src/uipath/eval/runtime/_types.py | 5 +- .../uipath/src/uipath/eval/runtime/runtime.py | 108 ++- .../test_dataset_classification_evaluators.py | 648 ++++++++++++++++++ .../test_exact_match_aggregators.py | 195 ++++++ 11 files changed, 1665 insertions(+), 5 deletions(-) create mode 100644 packages/uipath/src/uipath/eval/evaluators/_aggregator_specs.py create mode 100644 packages/uipath/src/uipath/eval/evaluators/base_dataset_evaluator.py create mode 100644 packages/uipath/src/uipath/eval/evaluators/classification_dataset_evaluators.py create mode 100644 packages/uipath/src/uipath/eval/evaluators/dataset_evaluator_factory.py create mode 100644 packages/uipath/tests/evaluators/test_dataset_classification_evaluators.py create mode 100644 packages/uipath/tests/evaluators/test_exact_match_aggregators.py diff --git a/packages/uipath/src/uipath/eval/evaluators/_aggregator_specs.py b/packages/uipath/src/uipath/eval/evaluators/_aggregator_specs.py new file mode 100644 index 000000000..d3d44cc36 --- /dev/null +++ b/packages/uipath/src/uipath/eval/evaluators/_aggregator_specs.py @@ -0,0 +1,62 @@ +"""Aggregator specs embedded in per-datapoint classification evaluator configs. + +Each aggregator is a run-level metric (precision / recall / f-score) attached +to a classification evaluator. Classes are declared once on the parent evaluator +config — every aggregator on the same evaluator operates on the same class +vocabulary, so the field is not repeated per spec. Only the metric-shape fields +(``averaging`` and, for fscore, ``f_value``) live on the spec itself. +""" + +from __future__ import annotations + +from typing import Annotated, Literal, Union + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + + +class _AggregatorSpecBase(BaseModel): + """Shared pydantic config for every aggregator variant.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class PrecisionAggregatorSpec(_AggregatorSpecBase): + """Run-level precision aggregator (multiclass, micro or macro averaged).""" + + type: Literal["precision"] = "precision" + averaging: Literal["macro", "micro"] + + +class RecallAggregatorSpec(_AggregatorSpecBase): + """Run-level recall aggregator (multiclass, micro or macro averaged).""" + + type: Literal["recall"] = "recall" + averaging: Literal["macro", "micro"] + + +class FScoreAggregatorSpec(_AggregatorSpecBase): + """Run-level F-beta aggregator (multiclass, micro or macro averaged).""" + + type: Literal["fscore"] = "fscore" + averaging: Literal["macro", "micro"] + # Upper bound keeps beta² finite — a huge beta overflows to inf and the + # F-score becomes NaN, which is not representable in JSON. + f_value: float = Field(default=1.0, gt=0, le=1000) + + +class ConfusionMatrixAggregatorSpec(_AggregatorSpecBase): + """Run-level raw k×k confusion matrix — no scalar headline, no averaging.""" + + type: Literal["confusion_matrix"] = "confusion_matrix" + + +AggregatorSpec = Annotated[ + Union[ + PrecisionAggregatorSpec, + RecallAggregatorSpec, + FScoreAggregatorSpec, + ConfusionMatrixAggregatorSpec, + ], + Field(discriminator="type"), +] diff --git a/packages/uipath/src/uipath/eval/evaluators/base_dataset_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/base_dataset_evaluator.py new file mode 100644 index 000000000..5f302b076 --- /dev/null +++ b/packages/uipath/src/uipath/eval/evaluators/base_dataset_evaluator.py @@ -0,0 +1,45 @@ +"""Base abstractions for dataset-level evaluators. + +A dataset-level evaluator runs once per evaluation set, after all per-datapoint +evaluators have produced their results. It consumes the per-datapoint +EvaluationResultDto values from one named source evaluator and emits a single +EvaluationResult that summarizes the dataset. + +Concretely distinct from GenericBaseEvaluator: different evaluate() signature, +different lifecycle. Kept as a parallel hierarchy rather than a subclass so the +runtime cannot accidentally dispatch a dataset evaluator through the +per-datapoint loop. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from ..models.models import EvaluationResult, EvaluationResultDto +from ._aggregator_specs import AggregatorSpec + + +class BaseDatasetEvaluator(ABC): + """Abstract base for dataset-level evaluators. + + Constructed from an :class:`AggregatorSpec`, the source evaluator's name, + and the class vocabulary of the parent per-datapoint evaluator. Classes + live on the evaluator config (not the spec) — every aggregator on the same + evaluator operates on the same vocabulary. + """ + + spec: AggregatorSpec + source_evaluator: str + classes: list[str] + + def __init__( + self, spec: AggregatorSpec, source_evaluator: str, classes: list[str] + ) -> None: + """Store the aggregator spec, source evaluator name, and shared classes.""" + self.spec = spec + self.source_evaluator = source_evaluator + self.classes = classes + + @abstractmethod + def evaluate(self, results: list[EvaluationResultDto]) -> EvaluationResult: + """Reduce per-datapoint results into a single run-level EvaluationResult.""" diff --git a/packages/uipath/src/uipath/eval/evaluators/base_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/base_evaluator.py index ec4e0a7e6..9bb4a4a6e 100644 --- a/packages/uipath/src/uipath/eval/evaluators/base_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/base_evaluator.py @@ -47,6 +47,22 @@ class BaseEvaluatorJustification(BaseModel): expected: str actual: str + @classmethod + def try_from(cls, details: object) -> "BaseEvaluatorJustification | None": + """Coerce a free-form details payload into a justification, or return None. + + Accepts either an existing instance or a dict that ``model_validate`` can + parse. Anything else (str, None, malformed dict) yields ``None``. + """ + if isinstance(details, cls): + return details + if isinstance(details, dict): + try: + return cls.model_validate(details) + except Exception: + return None + return None + # Additional type variables for Config and Justification # Note: C must be BaseEvaluatorConfig[T] to ensure type consistency diff --git a/packages/uipath/src/uipath/eval/evaluators/classification_dataset_evaluators.py b/packages/uipath/src/uipath/eval/evaluators/classification_dataset_evaluators.py new file mode 100644 index 000000000..dd8367610 --- /dev/null +++ b/packages/uipath/src/uipath/eval/evaluators/classification_dataset_evaluators.py @@ -0,0 +1,270 @@ +"""Dataset-level classification evaluators: Precision, Recall, F-score, Confusion Matrix. + +All variants share the same internal machinery — a k x k confusion matrix built +from each per-datapoint result's BaseEvaluatorJustification (expected, actual) +strings. The scalar variants (precision / recall / fscore) emit per-class +metrics plus micro/macro averages and pick the headline ``score`` per the +spec's ``averaging``; the ``confusion_matrix`` variant emits only the raw grid +with a 0.0 placeholder score. + +The ``details`` payload is the platform wire contract: the Agents reducer +worker (python-dataset-eval-worker) calls this evaluator and ships +``details.model_dump(by_alias=True, exclude_none=True)`` verbatim to the C# +backend, where the frontend's zod schema +(frontend-sw/src/schemas/evaluations/evals.ts) validates it. Changing field +names or shapes here is a cross-repo breaking change. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + +from ..models.models import ( + EvaluationResult, + EvaluationResultDto, + NumericEvaluationResult, +) +from ._aggregator_specs import ( + ConfusionMatrixAggregatorSpec, + FScoreAggregatorSpec, +) +from .base_dataset_evaluator import BaseDatasetEvaluator +from .base_evaluator import BaseEvaluatorJustification + + +class PerClassMetrics(BaseModel): + """Per-class confusion counts plus all three scalar metrics for that class.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + tp: int + tn: int + fp: int + fn: int + support: int + precision: float + recall: float + f_score: float + + +class AveragedMetrics(BaseModel): + """Micro- or macro-averaged precision / recall / F-score triple.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + precision: float + recall: float + f_score: float + + +class ClassificationDetails(BaseModel): + """Structured details payload emitted by every classification aggregator. + + The scalar metrics (precision / recall / fscore) populate every field; + the ``confusion_matrix`` variant emits only the grid + counts, leaving + ``averaging`` / ``f_value`` / ``per_class`` / ``macro`` / ``micro`` as + None (excluded from the wire dump). + """ + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + metric: str + classes: list[str] + confusion_matrix: list[list[int]] = Field( + ..., + description=( + "k x k confusion matrix indexed as " + "``confusion_matrix[predicted_idx][expected_idx]`` " + "(rows are predicted classes, columns are expected). " + "This is the transpose of sklearn's convention " + "(``[true][predicted]``); UI / consumer code must use the " + "orientation documented here." + ), + ) + n_total: int + n_scored: int + n_skipped: int + averaging: str | None = None + f_value: float | None = None + per_class: dict[str, PerClassMetrics] | None = None + macro: AveragedMetrics | None = None + micro: AveragedMetrics | None = None + + +@dataclass(slots=True) +class _ConfusionData: + """Internal: confusion matrix and per-class counts derived from results.""" + + classes: list[str] + matrix: list[list[int]] + n_total: int + n_scored: int + n_skipped: int + oov_fn: list[int] + + +def _build_confusion( + results: list[EvaluationResultDto], + classes: list[str], +) -> _ConfusionData: + """Build a confusion matrix from per-datapoint results. + + Results without a parseable justification are counted in ``n_skipped`` and + omitted. A datapoint whose *predicted* label is out of vocabulary but whose + *expected* label is in vocabulary is a miss for that true class: it is + counted as a false negative (``oov_fn``) rather than dropped, so recall is + not inflated. A datapoint whose *expected* label is out of vocabulary is + skipped — there is no in-vocab true class to attribute the miss to. Labels + are normalized to lowercase for the lookup index so a classifier returning + "Book" vs configured "book" still matches, but the user-supplied casing is + preserved in the returned ``_ConfusionData.classes``. + """ + index_of = {c.lower(): i for i, c in enumerate(classes)} + k = len(classes) + matrix = [[0] * k for _ in range(k)] + oov_fn = [0] * k + + n_total = len(results) + n_scored = 0 + n_skipped = 0 + + for r in results: + j = BaseEvaluatorJustification.try_from(r.details) + if j is None: + n_skipped += 1 + continue + exp = j.expected.lower() + act = j.actual.lower() + if exp not in index_of: + n_skipped += 1 + continue + if act not in index_of: + oov_fn[index_of[exp]] += 1 + n_scored += 1 + continue + matrix[index_of[act]][index_of[exp]] += 1 + n_scored += 1 + + return _ConfusionData( + classes=list(classes), + matrix=matrix, + n_total=n_total, + n_scored=n_scored, + n_skipped=n_skipped, + oov_fn=oov_fn, + ) + + +def _f_beta(precision: float, recall: float, beta: float) -> float: + b2 = beta * beta + # denom == 0 iff precision == recall == 0 (both terms are non-negative and + # beta > 0), which is exactly the zero-score case. + denom = b2 * precision + recall + if denom == 0: + return 0.0 + return (1 + b2) * precision * recall / denom + + +class ClassificationDatasetEvaluator(BaseDatasetEvaluator): + """One implementation for all classification aggregators. + + Scalar variants (precision / recall / fscore) compute the full per-class + P/R/F report and pick the headline by the spec's ``averaging``; the + ``confusion_matrix`` variant returns only the raw grid. + """ + + def evaluate(self, results: list[EvaluationResultDto]) -> EvaluationResult: + """Compute the configured metric report and return the headline as score.""" + confusion = _build_confusion(results, self.classes) + + if isinstance(self.spec, ConfusionMatrixAggregatorSpec): + # No scalar headline — emit the raw grid and let the UI render it. + details = ClassificationDetails( + metric=self.spec.type, + classes=confusion.classes, + confusion_matrix=confusion.matrix, + n_total=confusion.n_total, + n_scored=confusion.n_scored, + n_skipped=confusion.n_skipped, + ) + return NumericEvaluationResult(score=0.0, details=details) + + f_value = ( + self.spec.f_value if isinstance(self.spec, FScoreAggregatorSpec) else 1.0 + ) + k = len(confusion.classes) + + per_class: dict[str, PerClassMetrics] = {} + precisions: list[float] = [] + recalls: list[float] = [] + f_scores: list[float] = [] + total_tp = total_fp = total_fn = 0 + + for c, label in enumerate(confusion.classes): + tp = confusion.matrix[c][c] + row_sum = sum(confusion.matrix[c]) # predicted as `label` + col_sum = sum(confusion.matrix[j][c] for j in range(k)) # true `label` + fp = row_sum - tp + fn = col_sum - tp + confusion.oov_fn[c] + tn = confusion.n_scored - tp - fp - fn + + precision = tp / row_sum if row_sum > 0 else 0.0 + recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0 + f_score = _f_beta(precision, recall, f_value) + + per_class[label] = PerClassMetrics( + tp=tp, + tn=tn, + fp=fp, + fn=fn, + support=tp + fn, + precision=precision, + recall=recall, + f_score=f_score, + ) + precisions.append(precision) + recalls.append(recall) + f_scores.append(f_score) + total_tp += tp + total_fp += fp + total_fn += fn + + # AggregatorSpec classes come from the ExactMatch config which requires + # a non-empty list, so k >= 1 always. + macro = AveragedMetrics( + precision=sum(precisions) / k, + recall=sum(recalls) / k, + f_score=sum(f_scores) / k, + ) + micro_p = total_tp / (total_tp + total_fp) if (total_tp + total_fp) > 0 else 0.0 + micro_r = total_tp / (total_tp + total_fn) if (total_tp + total_fn) > 0 else 0.0 + micro = AveragedMetrics( + precision=micro_p, + recall=micro_r, + f_score=_f_beta(micro_p, micro_r, f_value), + ) + + averaged = micro if self.spec.averaging == "micro" else macro + headline = { + "precision": averaged.precision, + "recall": averaged.recall, + "fscore": averaged.f_score, + }[self.spec.type] + + details = ClassificationDetails( + metric=self.spec.type, + averaging=self.spec.averaging, + f_value=f_value, + classes=confusion.classes, + confusion_matrix=confusion.matrix, + per_class=per_class, + macro=macro, + micro=micro, + n_total=confusion.n_total, + n_scored=confusion.n_scored, + n_skipped=confusion.n_skipped, + ) + return NumericEvaluationResult(score=headline, details=details) diff --git a/packages/uipath/src/uipath/eval/evaluators/dataset_evaluator_factory.py b/packages/uipath/src/uipath/eval/evaluators/dataset_evaluator_factory.py new file mode 100644 index 000000000..d30a5748b --- /dev/null +++ b/packages/uipath/src/uipath/eval/evaluators/dataset_evaluator_factory.py @@ -0,0 +1,65 @@ +"""Factory that instantiates dataset-level evaluators from aggregator specs. + +Dataset evaluators are built from a self-contained :class:`AggregatorSpec` +embedded in a per-datapoint classification evaluator's config, plus the source +evaluator's name (supplied by the runtime when walking those configs). All +three aggregator types share a single :class:`ClassificationDatasetEvaluator` +implementation that dispatches on ``spec.type`` internally. +""" + +from __future__ import annotations + +from typing import Sequence + +from ._aggregator_specs import AggregatorSpec +from .classification_dataset_evaluators import ClassificationDatasetEvaluator + + +def build_dataset_evaluator( + spec: AggregatorSpec, + source_evaluator: str, + classes: list[str], +) -> ClassificationDatasetEvaluator: + """Build a dataset evaluator instance from an aggregator spec. + + Args: + spec: A validated :class:`AggregatorSpec` (precision / recall / fscore). + source_evaluator: Name of the per-datapoint evaluator whose results + this aggregator consumes. + classes: The class vocabulary from the parent evaluator's config. Shared + across all aggregators attached to that evaluator — a spec no longer + carries classes of its own. + """ + return ClassificationDatasetEvaluator(spec, source_evaluator, classes) + + +def unique_aggregator_specs(specs: Sequence[AggregatorSpec]) -> list[AggregatorSpec]: + """Drop exact-duplicate specs (same type and parameters), preserving order.""" + seen: set[str] = set() + unique: list[AggregatorSpec] = [] + for spec in specs: + dumped = spec.model_dump_json() + if dumped not in seen: + seen.add(dumped) + unique.append(spec) + return unique + + +def dataset_result_key( + source_evaluator: str, spec: AggregatorSpec, duplicate_type: bool +) -> str: + """Result-map key shared by `uipath eval` and the platform worker. + + ``{source}::{type}``, extended with ``.{averaging}`` (and ``.fb{f_value}`` + for fscore) when the same type appears more than once on one source. + Callers must dedupe via :func:`unique_aggregator_specs` first — after that, + duplicate types always differ in averaging or f_value. + """ + key = f"{source_evaluator}::{spec.type}" + averaging = getattr(spec, "averaging", None) + if not duplicate_type or averaging is None: + return key + f_value = getattr(spec, "f_value", None) + if f_value is not None: + return f"{key}.{averaging}.fb{f_value}" + return f"{key}.{averaging}" diff --git a/packages/uipath/src/uipath/eval/evaluators/exact_match_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/exact_match_evaluator.py index e3873b01f..2cb7d70f1 100644 --- a/packages/uipath/src/uipath/eval/evaluators/exact_match_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/exact_match_evaluator.py @@ -1,11 +1,14 @@ """Exact match evaluator for workload outputs.""" +from pydantic import Field, model_validator + from ..models import ( EvaluationResult, EvaluatorType, NumericEvaluationResult, WorkloadExecution, ) +from ._aggregator_specs import AggregatorSpec from .base_evaluator import BaseEvaluatorJustification from .output_evaluator import ( OutputEvaluationCriteria, @@ -20,6 +23,67 @@ class ExactMatchEvaluatorConfig(OutputEvaluatorConfig[OutputEvaluationCriteria]) name: str = "ExactMatchEvaluator" case_sensitive: bool = False negated: bool = False + classes: list[str] | None = Field( + default=None, + description=( + "Label vocabulary shared by every aggregator on this evaluator. " + "Labels are matched case-insensitively against the per-datapoint " + "expected/actual outputs." + ), + ) + aggregators: list[AggregatorSpec] | None = Field( + default=None, + description=( + "Dataset-level metrics (precision / recall / F-score / confusion " + "matrix) computed over the per-datapoint match outcomes. Requires " + "``classes``." + ), + ) + + @model_validator(mode="after") + def _validate_aggregators(self) -> "ExactMatchEvaluatorConfig": + """Aggregators need a usable class vocabulary and per-label outcomes.""" + if not self.aggregators: + return self + if not self.classes: + raise ValueError( + f"ExactMatch evaluator '{self.name}' declares aggregators but no " + "``classes`` list. Set ``classes`` to the label vocabulary the " + "aggregators should compute Precision/Recall/F-score over." + ) + if self.line_by_line_evaluator: + raise ValueError( + f"ExactMatch evaluator '{self.name}': aggregators are not " + "supported with line_by_line_evaluator — per-line results carry " + "no expected/actual labels, so every datapoint would be skipped." + ) + if self.case_sensitive: + raise ValueError( + f"ExactMatch evaluator '{self.name}': aggregators are not " + "supported with case_sensitive — the confusion matrix buckets " + "labels case-insensitively, so a datapoint could score 0.0 yet " + "land on the true-positive diagonal." + ) + if self.negated: + raise ValueError( + f"ExactMatch evaluator '{self.name}': aggregators are not " + "supported with negated — negation flips only the per-datapoint " + "score, not the justification's expected/actual labels, so the " + "confusion matrix would put matches on the true-positive diagonal " + "while they scored 0.0 (and vice versa)." + ) + lowered = [c.lower() for c in self.classes] + if any(not c.strip() or c != c.strip() for c in self.classes) or len( + set(lowered) + ) != len(lowered): + raise ValueError( + f"ExactMatch evaluator '{self.name}': ``classes`` must be " + "non-blank, have no leading/trailing whitespace, and be unique " + "case-insensitively — labels are matched case-insensitively, so " + "duplicates would collapse onto one matrix index, and padded " + "labels would never match anything." + ) + return self class ExactMatchEvaluator( diff --git a/packages/uipath/src/uipath/eval/evaluators_types/ExactMatchEvaluator.json b/packages/uipath/src/uipath/eval/evaluators_types/ExactMatchEvaluator.json index 866b06416..5c302ccf8 100644 --- a/packages/uipath/src/uipath/eval/evaluators_types/ExactMatchEvaluator.json +++ b/packages/uipath/src/uipath/eval/evaluators_types/ExactMatchEvaluator.json @@ -2,6 +2,50 @@ "evaluatorTypeId": "uipath-exact-match", "evaluatorConfigSchema": { "$defs": { + "ConfusionMatrixAggregatorSpec": { + "description": "Run-level raw k\u00d7k confusion matrix \u2014 no scalar headline, no averaging.", + "properties": { + "type": { + "const": "confusion_matrix", + "default": "confusion_matrix", + "title": "Type", + "type": "string" + } + }, + "title": "ConfusionMatrixAggregatorSpec", + "type": "object" + }, + "FScoreAggregatorSpec": { + "description": "Run-level F-beta aggregator (multiclass, micro or macro averaged).", + "properties": { + "type": { + "const": "fscore", + "default": "fscore", + "title": "Type", + "type": "string" + }, + "averaging": { + "enum": [ + "macro", + "micro" + ], + "title": "Averaging", + "type": "string" + }, + "f_value": { + "default": 1.0, + "exclusiveMinimum": 0, + "maximum": 1000, + "title": "F Value", + "type": "number" + } + }, + "required": [ + "averaging" + ], + "title": "FScoreAggregatorSpec", + "type": "object" + }, "OutputEvaluationCriteria": { "description": "Base class for all output evaluation criteria.", "properties": { @@ -23,6 +67,54 @@ ], "title": "OutputEvaluationCriteria", "type": "object" + }, + "PrecisionAggregatorSpec": { + "description": "Run-level precision aggregator (multiclass, micro or macro averaged).", + "properties": { + "type": { + "const": "precision", + "default": "precision", + "title": "Type", + "type": "string" + }, + "averaging": { + "enum": [ + "macro", + "micro" + ], + "title": "Averaging", + "type": "string" + } + }, + "required": [ + "averaging" + ], + "title": "PrecisionAggregatorSpec", + "type": "object" + }, + "RecallAggregatorSpec": { + "description": "Run-level recall aggregator (multiclass, micro or macro averaged).", + "properties": { + "type": { + "const": "recall", + "default": "recall", + "title": "Type", + "type": "string" + }, + "averaging": { + "enum": [ + "macro", + "micro" + ], + "title": "Averaging", + "type": "string" + } + }, + "required": [ + "averaging" + ], + "title": "RecallAggregatorSpec", + "type": "object" } }, "description": "Configuration for the exact match evaluator.", @@ -50,9 +142,31 @@ "default": null }, "target_output_key": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "*", - "description": "Key to extract output from agent execution", - "title": "Target Output Key", + "description": "Key or list of keys to extract output from workload execution", + "title": "Target Output Key" + }, + "line_by_line_evaluator": { + "default": false, + "description": "If True, split output by delimiter and evaluate each line separately", + "title": "Line By Line Evaluator", + "type": "boolean" + }, + "line_delimiter": { + "default": "\n", + "description": "Delimiter to split output when line_by_line_evaluator is True", + "title": "Line Delimiter", "type": "string" }, "case_sensitive": { @@ -64,6 +178,60 @@ "default": false, "title": "Negated", "type": "boolean" + }, + "classes": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Label vocabulary shared by every aggregator on this evaluator. Labels are matched case-insensitively against the per-datapoint expected/actual outputs.", + "title": "Classes" + }, + "aggregators": { + "anyOf": [ + { + "items": { + "discriminator": { + "mapping": { + "confusion_matrix": "#/$defs/ConfusionMatrixAggregatorSpec", + "fscore": "#/$defs/FScoreAggregatorSpec", + "precision": "#/$defs/PrecisionAggregatorSpec", + "recall": "#/$defs/RecallAggregatorSpec" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/$defs/PrecisionAggregatorSpec" + }, + { + "$ref": "#/$defs/RecallAggregatorSpec" + }, + { + "$ref": "#/$defs/FScoreAggregatorSpec" + }, + { + "$ref": "#/$defs/ConfusionMatrixAggregatorSpec" + } + ] + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dataset-level metrics (precision / recall / F-score / confusion matrix) computed over the per-datapoint match outcomes. Requires ``classes``.", + "title": "Aggregators" } }, "title": "ExactMatchEvaluatorConfig", @@ -91,5 +259,23 @@ "title": "OutputEvaluationCriteria", "type": "object" }, - "justificationSchema": {} + "justificationSchema": { + "description": "Base class for all evaluator justifications.", + "properties": { + "expected": { + "title": "Expected", + "type": "string" + }, + "actual": { + "title": "Actual", + "type": "string" + } + }, + "required": [ + "expected", + "actual" + ], + "title": "BaseEvaluatorJustification", + "type": "object" + } } \ No newline at end of file diff --git a/packages/uipath/src/uipath/eval/runtime/_types.py b/packages/uipath/src/uipath/eval/runtime/_types.py index 2aee5e599..fa84f0d9e 100644 --- a/packages/uipath/src/uipath/eval/runtime/_types.py +++ b/packages/uipath/src/uipath/eval/runtime/_types.py @@ -1,7 +1,7 @@ import logging from opentelemetry.sdk.trace import ReadableSpan -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel from uipath.runtime import UiPathRuntimeResult @@ -78,6 +78,9 @@ class UiPathEvalOutput(BaseModel): evaluation_set_name: str evaluation_set_results: list[UiPathEvalRunResult] + dataset_evaluator_results: dict[str, EvaluationResultDto] = Field( + default_factory=dict + ) @property def score(self) -> float: diff --git a/packages/uipath/src/uipath/eval/runtime/runtime.py b/packages/uipath/src/uipath/eval/runtime/runtime.py index f4e3cbbb8..2f089cf7b 100644 --- a/packages/uipath/src/uipath/eval/runtime/runtime.py +++ b/packages/uipath/src/uipath/eval/runtime/runtime.py @@ -46,7 +46,16 @@ from uipath.runtime.schema import UiPathRuntimeSchema from .._execution_context import ExecutionSpanCollector -from ..evaluators.base_evaluator import GenericBaseEvaluator +from ..evaluators.base_evaluator import ( + BaseEvaluatorJustification, + GenericBaseEvaluator, +) +from ..evaluators.dataset_evaluator_factory import ( + build_dataset_evaluator, + dataset_result_key, + unique_aggregator_specs, +) +from ..evaluators.exact_match_evaluator import ExactMatchEvaluatorConfig from ..evaluators.output_evaluator import OutputEvaluationCriteria from ..helpers import get_agent_model from ..mocks._cache_manager import CacheManager @@ -216,6 +225,95 @@ def compute_evaluator_scores( return final_score, agg_metrics_per_evaluator +def compute_dataset_evaluator_results( + evaluation_set_results: list[UiPathEvalRunResult], + evaluators: Iterable[GenericBaseEvaluator[Any, Any, Any]], +) -> dict[str, EvaluationResultDto]: + """Run any dataset-level aggregators embedded in per-datapoint evaluator configs. + + Walks ``evaluators`` looking for any whose config carries an ``aggregators`` + list (currently only ExactMatch). For each aggregator spec, builds the + corresponding dataset evaluator via the factory and runs it over the + per-datapoint results that came from that source evaluator. + + Args: + evaluation_set_results: Per-datapoint results from the run. + evaluators: Per-datapoint evaluator instances that ran during this eval + set. Their configs may carry ``aggregators`` lists. + + Returns: + Dict keyed by :func:`dataset_result_key` (same scheme as the platform + worker), with each value's ``details`` dumped to the camelCase wire + shape. Exact-duplicate specs are deduped; aggregators whose source + produced no results still emit a zeroed result. + """ + # Deduplicate by (datapoint, evaluator) before aggregating, mirroring + # compute_evaluator_scores. The partial-failure path (see _execute_eval's + # except block) can emit a zero-score, details-less DTO for an evaluator + # that already produced a real result on the success path; and an external + # retry/resume can re-feed a datapoint. Without dedup those inflate + # n_total/n_skipped and would double-count real matrix pairs. When a + # datapoint has multiple DTOs for one evaluator, prefer the one whose + # details parse into an expected/actual justification. + latest_by_dp_eval: dict[tuple[str, str], EvaluationResultDto] = {} + for eval_run_result in evaluation_set_results: + datapoint_id = eval_run_result.evaluation_name + for eval_run_result_dto in eval_run_result.evaluation_run_results: + if eval_run_result_dto.is_line_result: + continue + dedup_key = (datapoint_id, eval_run_result_dto.evaluator_name) + existing = latest_by_dp_eval.get(dedup_key) + candidate = eval_run_result_dto.result + # Keep the entry with a parseable justification over one without. + if existing is not None and ( + BaseEvaluatorJustification.try_from(candidate.details) is None + or BaseEvaluatorJustification.try_from(existing.details) is not None + ): + continue + latest_by_dp_eval[dedup_key] = candidate + + results_by_evaluator: defaultdict[str, list[EvaluationResultDto]] = defaultdict( + list + ) + for (_dp_id, dedup_eval_name), dp_result in latest_by_dp_eval.items(): + results_by_evaluator[dedup_eval_name].append(dp_result) + + dataset_results: dict[str, EvaluationResultDto] = {} + for evaluator in evaluators: + # Aggregators currently only live on ExactMatch evaluator configs — the + # per-datapoint match outcome (with expected/actual labels in the + # justification) is exactly what the confusion matrix needs. Widen the + # isinstance tuple if a future evaluator type grows an ``aggregators`` + # field. + config = getattr(evaluator, "evaluator_config", None) + if not isinstance(config, ExactMatchEvaluatorConfig): + continue + if not config.aggregators or not config.classes: + continue + source_name = config.name + source_results = results_by_evaluator.get(source_name, []) + specs = unique_aggregator_specs(config.aggregators) + type_counts: dict[str, int] = defaultdict(int) + for spec in specs: + type_counts[spec.type] += 1 + for spec in specs: + dataset_evaluator = build_dataset_evaluator( + spec, source_name, config.classes + ) + key = dataset_result_key(source_name, spec, type_counts[spec.type] > 1) + result = dataset_evaluator.evaluate(source_results) + details: str | dict[str, Any] | None + if isinstance(result.details, BaseModel): + # Same camelCase wire shape the platform worker ships. + details = result.details.model_dump(by_alias=True, exclude_none=True) + else: + details = result.details + dataset_results[key] = EvaluationResultDto( + score=result.score, details=details + ) + return dataset_results + + class UiPathEvalRuntime: """Specialized runtime for evaluation runs, with access to the factory.""" @@ -399,6 +497,14 @@ async def execute(self) -> UiPathRuntimeResult: evaluators, ) + # Run dataset-level aggregators over the per-datapoint results. + results.dataset_evaluator_results = ( + compute_dataset_evaluator_results( + results.evaluation_set_results, + evaluators, + ) + ) + # Configure span with output and metadata await configure_eval_set_run_span( span=span, diff --git a/packages/uipath/tests/evaluators/test_dataset_classification_evaluators.py b/packages/uipath/tests/evaluators/test_dataset_classification_evaluators.py new file mode 100644 index 000000000..c4d427fe4 --- /dev/null +++ b/packages/uipath/tests/evaluators/test_dataset_classification_evaluators.py @@ -0,0 +1,648 @@ +"""Tests for dataset-level classification evaluators (Precision, Recall, FScore). + +Covers the math (2-class, 3-class, micro vs macro, F-beta), edge cases +(empty input, out-of-vocab labels, malformed details), factory dispatch, and +runtime-level routing where compute_dataset_evaluator_results walks +per-datapoint evaluator configs' embedded ``aggregators`` lists. +""" + +import uuid + +import pytest +from pydantic import BaseModel + +from uipath.eval.evaluators._aggregator_specs import ( + ConfusionMatrixAggregatorSpec, + FScoreAggregatorSpec, + PrecisionAggregatorSpec, + RecallAggregatorSpec, +) +from uipath.eval.evaluators.base_evaluator import BaseEvaluatorJustification +from uipath.eval.evaluators.classification_dataset_evaluators import ( + AveragedMetrics, + ClassificationDatasetEvaluator, + ClassificationDetails, + PerClassMetrics, +) +from uipath.eval.evaluators.dataset_evaluator_factory import build_dataset_evaluator +from uipath.eval.evaluators.exact_match_evaluator import ExactMatchEvaluator +from uipath.eval.models.models import ( + EvaluationResultDto, + NumericEvaluationResult, +) +from uipath.eval.runtime._types import ( + UiPathEvalRunResult, + UiPathEvalRunResultDto, +) +from uipath.eval.runtime.runtime import compute_dataset_evaluator_results + + +def _result(expected: str, actual: str) -> EvaluationResultDto: + """Build an EvaluationResultDto carrying an expected/actual justification.""" + justification = BaseEvaluatorJustification(expected=expected, actual=actual) + return EvaluationResultDto( + score=1.0 if expected.lower() == actual.lower() else 0.0, + details=justification.model_dump(), + ) + + +def _precision( + classes: list[str], averaging: str = "macro" +) -> ClassificationDatasetEvaluator: + spec = PrecisionAggregatorSpec(averaging=averaging) # type: ignore[arg-type] + return ClassificationDatasetEvaluator( + spec, source_evaluator="intent_match", classes=classes + ) + + +def _recall( + classes: list[str], averaging: str = "macro" +) -> ClassificationDatasetEvaluator: + spec = RecallAggregatorSpec(averaging=averaging) # type: ignore[arg-type] + return ClassificationDatasetEvaluator( + spec, source_evaluator="intent_match", classes=classes + ) + + +def _fscore( + classes: list[str], averaging: str = "macro", f_value: float = 1.0 +) -> ClassificationDatasetEvaluator: + spec = FScoreAggregatorSpec( + averaging=averaging, # type: ignore[arg-type] + f_value=f_value, + ) + return ClassificationDatasetEvaluator( + spec, source_evaluator="intent_match", classes=classes + ) + + +def _details(result: object) -> ClassificationDetails: + """Type-narrowing helper for asserting on details.""" + assert isinstance(result, NumericEvaluationResult) + assert isinstance(result.details, ClassificationDetails) + return result.details + + +# per_class / macro / micro are Optional on ClassificationDetails (the +# confusion_matrix variant omits them). Scalar-metric tests always populate +# them; these accessors assert-narrow to the non-Optional type so mypy is happy. +def _pc(d: ClassificationDetails) -> dict[str, PerClassMetrics]: + assert d.per_class is not None + return d.per_class + + +def _macro(d: ClassificationDetails) -> AveragedMetrics: + assert d.macro is not None + return d.macro + + +def _micro(d: ClassificationDetails) -> AveragedMetrics: + assert d.micro is not None + return d.micro + + +def _exact_match_evaluator( + name: str, + classes: list[str], + aggregators: list[BaseModel], +) -> ExactMatchEvaluator: + """Build a per-datapoint ExactMatch evaluator with attached aggregators. + + Aggregators + classes live on the evaluator config — every aggregator on + the same ExactMatch config shares the ``classes`` vocabulary declared here. + """ + return ExactMatchEvaluator.model_validate( + { + "id": str(uuid.uuid4()), + "evaluatorConfig": { + "name": name, + "classes": classes, + "aggregators": [spec.model_dump(by_alias=True) for spec in aggregators], + }, + } + ) + + +class TestPrecisionEvaluator: + def test_empty_input_returns_zeroed_result(self) -> None: + result = _precision(["cat", "dog"]).evaluate([]) + assert isinstance(result, NumericEvaluationResult) + assert result.score == 0.0 + d = _details(result) + assert d.n_total == 0 and d.n_scored == 0 + assert d.confusion_matrix == [[0, 0], [0, 0]] + assert _pc(d)["cat"].tp == 0 + assert _pc(d)["cat"].tn == 0 + + def test_confusion_matrix_is_predicted_by_expected(self) -> None: + # Pin the documented orientation: confusion_matrix[predicted][expected]. + # Differs from sklearn's [true][predicted] convention. + results = [ + _result("cat", "cat"), # expected=cat, predicted=cat -> [cat][cat] + _result("cat", "dog"), # expected=cat, predicted=dog -> [dog][cat] + _result("dog", "dog"), # expected=dog, predicted=dog -> [dog][dog] + _result("dog", "dog"), + ] + d = _details(_precision(["cat", "dog"]).evaluate(results)) + # classes -> index: cat=0, dog=1 + # [predicted=cat][expected=cat] = 1 + assert d.confusion_matrix[0][0] == 1 + # [predicted=dog][expected=cat] = 1 (the FP for dog / FN for cat) + assert d.confusion_matrix[1][0] == 1 + # [predicted=dog][expected=dog] = 2 + assert d.confusion_matrix[1][1] == 2 + # [predicted=cat][expected=dog] = 0 + assert d.confusion_matrix[0][1] == 0 + + def test_precision_two_class_macro(self) -> None: + results = [ + _result("yes", "yes"), + _result("yes", "yes"), + _result("yes", "no"), + _result("no", "yes"), + ] + result = _precision(["yes", "no"], averaging="macro").evaluate(results) + d = _details(result) + # precision_yes = 2 / (2 + 1) = 2/3 + # precision_no = 0 / (0 + 1) = 0 + # macro = (2/3 + 0) / 2 = 1/3 + assert _pc(d)["yes"].precision == pytest.approx(2 / 3) + assert _pc(d)["no"].precision == pytest.approx(0.0) + assert _macro(d).precision == pytest.approx((2 / 3 + 0.0) / 2) + assert result.score == pytest.approx(_macro(d).precision) + + def test_two_class_micro_equals_accuracy(self) -> None: + results = [ + _result("yes", "yes"), + _result("yes", "yes"), + _result("yes", "no"), + _result("no", "yes"), + ] + result = _precision(["yes", "no"], averaging="micro").evaluate(results) + d = _details(result) + assert _micro(d).precision == pytest.approx(0.5) + assert result.score == pytest.approx(0.5) + + def test_three_class_macro(self) -> None: + pairs = [ + ("cat", "cat"), + ("cat", "cat"), + ("cat", "dog"), + ("dog", "dog"), + ("dog", "dog"), + ("dog", "bird"), + ("bird", "bird"), + ("bird", "bird"), + ("bird", "cat"), + ] + result = _precision(["cat", "dog", "bird"], averaging="macro").evaluate( + [_result(e, a) for e, a in pairs] + ) + d = _details(result) + for label in ("cat", "dog", "bird"): + m = _pc(d)[label] + assert m.tp == 2 and m.fp == 1 and m.fn == 1 and m.tn == 5 + assert m.precision == pytest.approx(2 / 3) + assert _macro(d).precision == pytest.approx(2 / 3) + assert result.score == pytest.approx(2 / 3) + + +class TestRecallEvaluator: + def test_recall_two_class_macro(self) -> None: + results = [ + _result("yes", "yes"), + _result("yes", "yes"), + _result("yes", "no"), + _result("no", "yes"), + ] + result = _recall(["yes", "no"], averaging="macro").evaluate(results) + d = _details(result) + assert _pc(d)["yes"].recall == pytest.approx(2 / 3) + assert _pc(d)["no"].recall == pytest.approx(0.0) + assert result.score == pytest.approx(1 / 3) + + def test_recall_differs_from_precision(self) -> None: + results = [ + _result("yes", "yes"), + _result("yes", "yes"), + _result("no", "yes"), + _result("no", "yes"), + _result("no", "no"), + ] + p = _details(_precision(["yes", "no"], averaging="macro").evaluate(results)) + r = _details(_recall(["yes", "no"], averaging="macro").evaluate(results)) + assert _pc(p)["yes"].precision == pytest.approx(0.5) + assert _pc(p)["no"].precision == pytest.approx(1.0) + assert _pc(r)["yes"].recall == pytest.approx(1.0) + assert _pc(r)["no"].recall == pytest.approx(1 / 3) + + +class TestFScoreEvaluator: + def test_f1_equals_harmonic_mean_of_p_and_r(self) -> None: + results = [ + _result("yes", "yes"), + _result("yes", "yes"), + _result("yes", "no"), + _result("no", "yes"), + ] + f = _details( + _fscore(["yes", "no"], averaging="macro", f_value=1.0).evaluate(results) + ) + assert _pc(f)["yes"].f_score == pytest.approx(2 / 3) + assert _pc(f)["no"].f_score == pytest.approx(0.0) + assert _macro(f).f_score == pytest.approx((2 / 3 + 0.0) / 2) + + def test_f_beta_emphasizes_recall_when_beta_above_one(self) -> None: + results = [ + _result("yes", "yes"), + _result("yes", "yes"), + _result("no", "yes"), + _result("no", "yes"), + _result("no", "no"), + ] + f1 = _details( + _fscore(["yes", "no"], averaging="macro", f_value=1.0).evaluate(results) + ) + f2 = _details( + _fscore(["yes", "no"], averaging="macro", f_value=2.0).evaluate(results) + ) + assert _pc(f2)["yes"].f_score > _pc(f1)["yes"].f_score + + def test_three_class_micro_pools_across_classes(self) -> None: + pairs = [ + ("cat", "cat"), + ("cat", "cat"), + ("cat", "dog"), + ("dog", "dog"), + ("dog", "dog"), + ("dog", "bird"), + ("bird", "bird"), + ("bird", "bird"), + ("bird", "cat"), + ] + d = _details( + _fscore(["cat", "dog", "bird"], averaging="micro", f_value=1.0).evaluate( + [_result(e, a) for e, a in pairs] + ) + ) + assert _micro(d).f_score == pytest.approx(6 / 9) + + +class TestSkippingAndEdgeCases: + def test_out_of_vocab_prediction_counts_as_recall_miss(self) -> None: + results = [ + _result("cat", "cat"), + _result("cat", "platypus"), + ] + d = _details(_recall(["cat", "dog"]).evaluate(results)) + assert d.n_total == 2 and d.n_scored == 2 and d.n_skipped == 0 + assert _pc(d)["cat"].fn == 1 + assert _pc(d)["cat"].support == 2 + assert _pc(d)["cat"].recall == pytest.approx(0.5) + assert _pc(d)["cat"].precision == pytest.approx(1.0) + + def test_out_of_vocab_expected_label_is_skipped(self) -> None: + results = [ + _result("cat", "cat"), + _result("zebra", "dog"), + ] + d = _details(_precision(["cat", "dog"]).evaluate(results)) + assert d.n_total == 2 and d.n_scored == 1 and d.n_skipped == 1 + + def test_results_without_justification_are_skipped(self) -> None: + results = [ + _result("cat", "cat"), + EvaluationResultDto(score=1.0, details="just a string"), + EvaluationResultDto(score=0.0, details={"unrelated": "shape"}), + ] + d = _details(_precision(["cat", "dog"]).evaluate(results)) + assert d.n_total == 3 and d.n_scored == 1 and d.n_skipped == 2 + + def test_case_insensitive(self) -> None: + results = [_result("Cat", "CAT"), _result("DOG", "dog")] + d = _details(_precision(["cat", "dog"]).evaluate(results)) + assert _pc(d)["cat"].tp == 1 + assert _pc(d)["dog"].tp == 1 + + +class TestFactory: + """The factory builds from an AggregatorSpec instance + source name.""" + + def test_builds_precision_from_spec(self) -> None: + spec = PrecisionAggregatorSpec(averaging="macro") + evaluator = build_dataset_evaluator(spec, "intent_match", classes=["yes", "no"]) + assert isinstance(evaluator, ClassificationDatasetEvaluator) + assert evaluator.spec.type == "precision" + assert evaluator.source_evaluator == "intent_match" + + def test_builds_recall_from_spec(self) -> None: + spec = RecallAggregatorSpec(averaging="micro") + evaluator = build_dataset_evaluator(spec, "intent_match", classes=["yes", "no"]) + assert isinstance(evaluator, ClassificationDatasetEvaluator) + assert evaluator.spec.type == "recall" + + def test_builds_fscore_from_spec(self) -> None: + spec = FScoreAggregatorSpec(averaging="macro", f_value=2.0) + evaluator = build_dataset_evaluator(spec, "intent_match", classes=["yes", "no"]) + assert isinstance(evaluator, ClassificationDatasetEvaluator) + assert isinstance(evaluator.spec, FScoreAggregatorSpec) + assert evaluator.spec.f_value == 2.0 + + +class TestAggregatorSpecJsonRoundTrip: + """Pin the wire shape sent to the C# side.""" + + def test_precision_spec_wire_shape(self) -> None: + """Specs carry only metric-shape fields; ``classes`` lives on the + parent evaluator config. + """ + spec = PrecisionAggregatorSpec.model_validate( + { + "type": "precision", + "averaging": "macro", + } + ) + dumped = spec.model_dump(by_alias=True) + assert dumped == { + "type": "precision", + "averaging": "macro", + } + + def test_fscore_uses_camelcase_fvalue_on_wire(self) -> None: + spec = FScoreAggregatorSpec.model_validate( + { + "type": "fscore", + "averaging": "macro", + "fValue": 1.5, + } + ) + assert spec.f_value == 1.5 + dumped = spec.model_dump(by_alias=True) + assert dumped["fValue"] == 1.5 + assert "f_value" not in dumped + + def test_exact_match_evaluator_round_trips_aggregators(self) -> None: + """Per-datapoint evaluator config carries aggregators[]; survives dump+load.""" + ev = _exact_match_evaluator( + "intent_classifier", + classes=["book", "cancel", "reschedule"], + aggregators=[ + PrecisionAggregatorSpec(averaging="macro"), + FScoreAggregatorSpec(averaging="macro", f_value=1.0), + ], + ) + assert ev.evaluator_config.aggregators is not None + assert len(ev.evaluator_config.aggregators) == 2 + assert ev.evaluator_config.aggregators[0].type == "precision" + assert ev.evaluator_config.aggregators[1].type == "fscore" + + +class TestComputeDatasetEvaluatorResults: + """End-to-end: runtime walks evaluator configs' aggregators[].""" + + def test_walks_aggregators_on_classification_evaluator(self) -> None: + evaluator = _exact_match_evaluator( + "intent_match", + classes=["yes", "no"], + aggregators=[ + PrecisionAggregatorSpec(averaging="macro"), + RecallAggregatorSpec(averaging="macro"), + ], + ) + + eval_results = [ + UiPathEvalRunResult( + evaluation_name="dp1", + evaluation_run_results=[ + UiPathEvalRunResultDto( + evaluator_name="intent_match", + evaluator_id=str(uuid.uuid4()), + result=_result("yes", "yes"), + ), + UiPathEvalRunResultDto( + evaluator_name="some_other_evaluator", + evaluator_id=str(uuid.uuid4()), + result=EvaluationResultDto(score=0.5), + ), + ], + ), + UiPathEvalRunResult( + evaluation_name="dp2", + evaluation_run_results=[ + UiPathEvalRunResultDto( + evaluator_name="intent_match", + evaluator_id=str(uuid.uuid4()), + result=_result("yes", "no"), + ), + ], + ), + ] + + out = compute_dataset_evaluator_results(eval_results, [evaluator]) + # Two aggregators on intent_match → two keys, prefixed by source name. + assert set(out) == {"intent_match::precision", "intent_match::recall"} + precision_dto = out["intent_match::precision"] + assert isinstance(precision_dto, EvaluationResultDto) + assert isinstance(precision_dto.details, dict) + # The unrelated 0.5 score from some_other_evaluator must NOT be in the matrix. + assert precision_dto.details["nScored"] == 2 + + def test_evaluator_without_aggregators_is_skipped(self) -> None: + evaluator = _exact_match_evaluator( + "intent_match", classes=["yes", "no"], aggregators=[] + ) + eval_results = [ + UiPathEvalRunResult( + evaluation_name="dp1", + evaluation_run_results=[ + UiPathEvalRunResultDto( + evaluator_name="intent_match", + evaluator_id=str(uuid.uuid4()), + result=_result("yes", "yes"), + ), + ], + ), + ] + out = compute_dataset_evaluator_results(eval_results, [evaluator]) + assert out == {} + + def test_line_by_line_subresults_are_excluded(self) -> None: + evaluator = _exact_match_evaluator( + "intent_match", + classes=["yes", "no"], + aggregators=[ + PrecisionAggregatorSpec(averaging="macro"), + ], + ) + eval_results = [ + UiPathEvalRunResult( + evaluation_name="dp1", + evaluation_run_results=[ + UiPathEvalRunResultDto( + evaluator_name="intent_match", + evaluator_id=str(uuid.uuid4()), + result=_result("yes", "yes"), + is_line_result=True, + ), + UiPathEvalRunResultDto( + evaluator_name="intent_match", + evaluator_id=str(uuid.uuid4()), + result=_result("no", "no"), + ), + ], + ), + ] + out = compute_dataset_evaluator_results(eval_results, [evaluator]) + assert isinstance(out["intent_match::precision"].details, dict) + assert out["intent_match::precision"].details["nScored"] == 1 + + def test_source_with_no_results_produces_zeroed_report(self) -> None: + evaluator = _exact_match_evaluator( + "intent_match", + classes=["yes", "no"], + aggregators=[ + PrecisionAggregatorSpec(averaging="macro"), + ], + ) + eval_results = [ + UiPathEvalRunResult( + evaluation_name="dp1", + evaluation_run_results=[ + UiPathEvalRunResultDto( + evaluator_name="some_other_evaluator", + evaluator_id=str(uuid.uuid4()), + result=EvaluationResultDto(score=1.0), + ), + ], + ), + ] + out = compute_dataset_evaluator_results(eval_results, [evaluator]) + dto = out["intent_match::precision"] + assert dto.score == 0.0 + assert isinstance(dto.details, dict) + assert dto.details["nScored"] == 0 + + def test_duplicate_datapoint_results_are_deduped(self) -> None: + """A datapoint with two DTOs for one evaluator (e.g. a real result plus + a details-less zero from the partial-failure path, or a retry/resume + re-feed) must count once — no inflated nTotal/nSkipped, no double-count + in the matrix. The parseable DTO wins over the details-less one.""" + evaluator = _exact_match_evaluator( + "intent_match", + classes=["yes", "no"], + aggregators=[PrecisionAggregatorSpec(averaging="macro")], + ) + eval_results = [ + UiPathEvalRunResult( + evaluation_name="dp1", + evaluation_run_results=[ + UiPathEvalRunResultDto( + evaluator_name="intent_match", + evaluator_id=str(uuid.uuid4()), + result=_result("yes", "yes"), + ), + # Duplicate: details-less zero (partial-failure path shape). + UiPathEvalRunResultDto( + evaluator_name="intent_match", + evaluator_id=str(uuid.uuid4()), + result=EvaluationResultDto(score=0.0), + ), + ], + ), + ] + out = compute_dataset_evaluator_results(eval_results, [evaluator]) + details = out["intent_match::precision"].details + assert isinstance(details, dict) + # One datapoint in, one counted — not two, and the parseable one scored. + assert details["nTotal"] == 1 + assert details["nScored"] == 1 + assert details["nSkipped"] == 0 + + def test_duplicate_aggregator_type_disambiguates_by_averaging(self) -> None: + """Two aggregators of the same type get distinct keys (no overwrite).""" + evaluator = _exact_match_evaluator( + "intent_match", + classes=["yes", "no"], + aggregators=[ + PrecisionAggregatorSpec(averaging="macro"), + PrecisionAggregatorSpec(averaging="micro"), + ], + ) + eval_results = [ + UiPathEvalRunResult( + evaluation_name="dp1", + evaluation_run_results=[ + UiPathEvalRunResultDto( + evaluator_name="intent_match", + evaluator_id=str(uuid.uuid4()), + result=_result("yes", "yes"), + ), + ], + ), + ] + out = compute_dataset_evaluator_results(eval_results, [evaluator]) + # Same type appears twice → averaging suffix disambiguates so neither + # is silently overwritten. + assert set(out) == { + "intent_match::precision.macro", + "intent_match::precision.micro", + } + + def test_exact_duplicate_specs_are_deduped(self) -> None: + """Identical specs collapse to one result; duplicate confusion_matrix + (no averaging field) must not crash key disambiguation.""" + evaluator = _exact_match_evaluator( + "intent_match", + classes=["yes", "no"], + aggregators=[ + PrecisionAggregatorSpec(averaging="macro"), + PrecisionAggregatorSpec(averaging="macro"), + ConfusionMatrixAggregatorSpec(), + ConfusionMatrixAggregatorSpec(), + ], + ) + eval_results = [ + UiPathEvalRunResult( + evaluation_name="dp1", + evaluation_run_results=[ + UiPathEvalRunResultDto( + evaluator_name="intent_match", + evaluator_id=str(uuid.uuid4()), + result=_result("yes", "yes"), + ), + ], + ), + ] + out = compute_dataset_evaluator_results(eval_results, [evaluator]) + assert set(out) == { + "intent_match::precision", + "intent_match::confusion_matrix", + } + + def test_details_are_dumped_to_camelcase_wire_shape(self) -> None: + """The local path ships the same JSON shape as the platform worker: + camelCase keys, absent (not null) optional fields.""" + evaluator = _exact_match_evaluator( + "intent_match", + classes=["yes", "no"], + aggregators=[ConfusionMatrixAggregatorSpec()], + ) + eval_results = [ + UiPathEvalRunResult( + evaluation_name="dp1", + evaluation_run_results=[ + UiPathEvalRunResultDto( + evaluator_name="intent_match", + evaluator_id=str(uuid.uuid4()), + result=_result("yes", "yes"), + ), + ], + ), + ] + out = compute_dataset_evaluator_results(eval_results, [evaluator]) + details = out["intent_match::confusion_matrix"].details + assert isinstance(details, dict) + assert details["confusionMatrix"] == [[1, 0], [0, 0]] + assert details["nScored"] == 1 + # confusion_matrix variant: scalar fields are absent, not null. + assert "perClass" not in details and "macro" not in details diff --git a/packages/uipath/tests/evaluators/test_exact_match_aggregators.py b/packages/uipath/tests/evaluators/test_exact_match_aggregators.py new file mode 100644 index 000000000..49ede250e --- /dev/null +++ b/packages/uipath/tests/evaluators/test_exact_match_aggregators.py @@ -0,0 +1,195 @@ +"""ExactMatch aggregator config surface. + +The platform's dataset-evaluator pass (Agents repo) reads ``classes`` and +``aggregators`` off the ExactMatch evaluator config. These tests pin the SDK +side of that wire contract: the discriminated spec union, the config fields, +and the config validator. The aggregation math lives in +``classification_dataset_evaluators.py`` (tested separately) and is consumed +by both `uipath eval` and the platform's python-dataset-eval-worker. +""" + +import uuid +from typing import Any + +import pytest +from pydantic import TypeAdapter, ValidationError + +from uipath.eval.evaluators._aggregator_specs import ( + AggregatorSpec, + ConfusionMatrixAggregatorSpec, + FScoreAggregatorSpec, + PrecisionAggregatorSpec, + RecallAggregatorSpec, +) +from uipath.eval.evaluators.exact_match_evaluator import ExactMatchEvaluator + + +def _evaluator(config: dict[str, Any]) -> ExactMatchEvaluator: + return ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + + +class TestAggregatorSpecUnion: + """Wire shape sent to the platform — {type, averaging, [fValue]}.""" + + def test_discriminates_on_type(self) -> None: + adapter: TypeAdapter[AggregatorSpec] = TypeAdapter(AggregatorSpec) + assert isinstance( + adapter.validate_python({"type": "precision", "averaging": "macro"}), + PrecisionAggregatorSpec, + ) + assert isinstance( + adapter.validate_python({"type": "recall", "averaging": "micro"}), + RecallAggregatorSpec, + ) + fscore = adapter.validate_python( + {"type": "fscore", "averaging": "macro", "fValue": 2.0} + ) + assert isinstance(fscore, FScoreAggregatorSpec) + assert fscore.f_value == 2.0 + assert isinstance( + adapter.validate_python({"type": "confusion_matrix"}), + ConfusionMatrixAggregatorSpec, + ) + + def test_specs_do_not_carry_classes(self) -> None: + # Classes live once on the evaluator config, shared by all aggregators. + dumped = PrecisionAggregatorSpec(averaging="macro").model_dump(by_alias=True) + assert dumped == {"type": "precision", "averaging": "macro"} + + def test_fscore_uses_camelcase_fvalue_on_wire(self) -> None: + dumped = FScoreAggregatorSpec(averaging="macro", f_value=1.5).model_dump( + by_alias=True + ) + assert dumped["fValue"] == 1.5 + assert "f_value" not in dumped + + def test_fscore_fvalue_is_bounded(self) -> None: + # A huge beta overflows beta² to inf → NaN score → unrepresentable JSON. + adapter: TypeAdapter[AggregatorSpec] = TypeAdapter(AggregatorSpec) + for bad in (0, -1, 1e200, float("inf"), float("nan")): + with pytest.raises(ValidationError): + adapter.validate_python( + {"type": "fscore", "averaging": "macro", "fValue": bad} + ) + + +class TestExactMatchAggregatorConfig: + def test_accepts_aggregators_with_classes(self) -> None: + ev = _evaluator( + { + "name": "IntentClassifier", + "classes": ["book", "cancel", "reschedule"], + "aggregators": [ + {"type": "precision", "averaging": "macro"}, + {"type": "recall", "averaging": "macro"}, + {"type": "fscore", "averaging": "macro", "fValue": 1.0}, + ], + } + ) + config = ev.evaluator_config + assert config.classes == ["book", "cancel", "reschedule"] + assert config.aggregators is not None + assert [s.type for s in config.aggregators] == ["precision", "recall", "fscore"] + + def test_rejects_aggregators_without_classes(self) -> None: + # The SDK wraps pydantic's ValidationError in UiPathEvaluationError at + # evaluator construction; match the message rather than the type. + with pytest.raises(Exception, match="classes"): + _evaluator( + { + "name": "IntentClassifier", + "aggregators": [{"type": "precision", "averaging": "macro"}], + } + ) + + def test_rejects_case_duplicate_classes(self) -> None: + # Labels match case-insensitively, so "Yes"/"yes" would collapse onto + # one matrix index and silently skew every metric. + with pytest.raises(Exception, match="unique"): + _evaluator( + { + "name": "IntentClassifier", + "classes": ["Yes", "yes"], + "aggregators": [{"type": "precision", "averaging": "macro"}], + } + ) + + def test_rejects_blank_class_labels(self) -> None: + with pytest.raises(Exception, match="non-blank"): + _evaluator( + { + "name": "IntentClassifier", + "classes": ["yes", " "], + "aggregators": [{"type": "precision", "averaging": "macro"}], + } + ) + + def test_rejects_aggregators_with_case_sensitive(self) -> None: + # Per-datapoint scoring would be case-sensitive while the matrix + # buckets case-insensitively — a 0.0-scored datapoint could land on + # the true-positive diagonal. + with pytest.raises(Exception, match="case_sensitive"): + _evaluator( + { + "name": "IntentClassifier", + "classes": ["yes", "no"], + "caseSensitive": True, + "aggregators": [{"type": "precision", "averaging": "macro"}], + } + ) + + def test_rejects_aggregators_with_negated(self) -> None: + with pytest.raises(Exception, match="negated"): + _evaluator( + { + "name": "IntentClassifier", + "classes": ["yes", "no"], + "negated": True, + "aggregators": [{"type": "precision", "averaging": "macro"}], + } + ) + + def test_rejects_padded_class_labels(self) -> None: + # Padded labels pass a blank check but never match at lookup time — + # every datapoint would silently land in nSkipped. + with pytest.raises(Exception, match="whitespace"): + _evaluator( + { + "name": "IntentClassifier", + "classes": ["yes", "no "], + "aggregators": [{"type": "precision", "averaging": "macro"}], + } + ) + + def test_rejects_aggregators_with_line_by_line(self) -> None: + # Per-line results carry no expected/actual labels — every datapoint + # would land in n_skipped and all metrics would silently read 0. + with pytest.raises(Exception, match="line_by_line"): + _evaluator( + { + "name": "IntentClassifier", + "classes": ["yes", "no"], + "lineByLineEvaluator": True, + "aggregators": [{"type": "precision", "averaging": "macro"}], + } + ) + + def test_plain_config_needs_neither_field(self) -> None: + ev = _evaluator({"name": "PlainMatch"}) + assert ev.evaluator_config.classes is None + assert ev.evaluator_config.aggregators is None + + def test_round_trips_through_dump_and_load(self) -> None: + config = { + "name": "IntentClassifier", + "classes": ["yes", "no"], + "aggregators": [{"type": "fscore", "averaging": "micro", "fValue": 2.0}], + } + ev = _evaluator(config) + dumped = ev.evaluator_config.model_dump(by_alias=True, exclude_none=True) + assert dumped["classes"] == ["yes", "no"] + assert dumped["aggregators"] == [ + {"type": "fscore", "averaging": "micro", "fValue": 2.0} + ] From 59365ec45e528489bc9d0d7ef83de60ae1b57dcb Mon Sep 17 00:00:00 2001 From: Ionut Mihalache <67947900+ionut-mihalache-uipath@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:08:15 +0300 Subject: [PATCH 178/191] feat(agent): add http-request internal tool type (#1830) Co-authored-by: Claude Fable 5 --- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/agent/models/agent.py | 10 +++++++ .../uipath/tests/agent/models/test_agent.py | 28 +++++++++++++++++++ packages/uipath/uv.lock | 2 +- 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index b35e51aee..9c5cf81ca 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.13.15" +version = "2.13.16" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 033bb04ed..86a0de739 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -127,6 +127,7 @@ class AgentInternalToolType(str, CaseInsensitiveEnum): ANALYZE_FILES = "analyze-attachments" DEEP_RAG = "deep-rag" BATCH_TRANSFORM = "batch-transform" + HTTP_REQUEST = "http-request" class AgentEscalationRecipientType(str, CaseInsensitiveEnum): @@ -1046,11 +1047,20 @@ class AgentInternalBatchTransformToolProperties(BaseResourceProperties): settings: AgentInternalBatchTransformSettings = Field(..., alias="settings") +class AgentInternalHttpRequestToolProperties(BaseResourceProperties): + """Agent internal http request tool properties model.""" + + tool_type: Literal[AgentInternalToolType.HTTP_REQUEST] = Field( + alias="toolType", default=AgentInternalToolType.HTTP_REQUEST, frozen=True + ) + + AgentInternalToolProperties = Annotated[ Union[ AgentInternalAnalyzeFilesToolProperties, AgentInternalDeepRagToolProperties, AgentInternalBatchTransformToolProperties, + AgentInternalHttpRequestToolProperties, ], Field(discriminator="tool_type"), _case_insensitive_enum_validator("tool_type", AgentInternalToolType, "toolType"), diff --git a/packages/uipath/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py index 5d62149a9..4333dec38 100644 --- a/packages/uipath/tests/agent/models/test_agent.py +++ b/packages/uipath/tests/agent/models/test_agent.py @@ -27,6 +27,7 @@ AgentIntegrationToolResourceConfig, AgentInternalBatchTransformToolProperties, AgentInternalDeepRagToolProperties, + AgentInternalHttpRequestToolProperties, AgentInternalToolResourceConfig, AgentInternalToolType, AgentIxpExtractionResourceConfig, @@ -3157,6 +3158,24 @@ def test_case_insensitive_enums(self): "description": "Test batch transform tool", "isEnabled": True, }, + { + "$resourceType": "Tool", + "type": "Internal", + "inputSchema": { + "type": "object", + "properties": {}, + }, + "outputSchema": {"type": "object", "properties": {}}, + "arguments": {}, + "settings": {"timeout": 0, "maxAttempts": 0, "retryDelay": 0}, + "properties": { + "toolType": "Http-Request", + }, + "argumentProperties": {}, + "name": "HTTP Request Tool", + "description": "Test http request tool", + "isEnabled": True, + }, ], "guardrails": [ { @@ -3279,6 +3298,15 @@ def test_case_insensitive_enums(self): == BatchTransformWebSearchGrounding.ENABLED ) + http_request_tool = config.resources[5] + assert isinstance(http_request_tool, AgentInternalToolResourceConfig) + assert isinstance( + http_request_tool.properties, AgentInternalHttpRequestToolProperties + ) + assert ( + http_request_tool.properties.tool_type == AgentInternalToolType.HTTP_REQUEST + ) + assert config.guardrails is not None custom_guardrail = config.guardrails[0] assert isinstance(custom_guardrail, AgentCustomGuardrail) diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 796ffb972..7af888cf9 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2598,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.15" +version = "2.13.16" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 49b881d87de311088d0e459598f52bec902c052b Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Tue, 28 Jul 2026 17:20:17 +0300 Subject: [PATCH 179/191] fix(eval): serialize non-json-native tool args when simulating (#1832) --- packages/uipath/pyproject.toml | 2 +- .../src/uipath/eval/mocks/_llm_mocker.py | 3 +- .../eval/mocks/_simulate_component_service.py | 4 +- .../mocks/test_mocker_arg_serialization.py | 251 ++++++++++++++++++ packages/uipath/uv.lock | 2 +- 5 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 packages/uipath/tests/cli/eval/mocks/test_mocker_arg_serialization.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 9c5cf81ca..d2c1791f0 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.13.16" +version = "2.13.17" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py b/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py index a9ab7005e..5c9a0cf38 100644 --- a/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py +++ b/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py @@ -7,6 +7,7 @@ from opentelemetry.sdk.trace import ReadableSpan from pydantic import BaseModel, TypeAdapter +from uipath.core.serialization import serialize_defaults from uipath.core.tracing import traced from uipath.platform import UiPath from uipath.platform.chat import UiPathLlmChatService @@ -171,7 +172,7 @@ async def response( "testRunProctorInstructions": self.context.strategy.prompt, } prompt_generation_args = { - k: json.dumps(pydantic_to_dict_safe(v)) + k: json.dumps(pydantic_to_dict_safe(v), default=serialize_defaults) for k, v in prompt_input.items() } model_parameters = self.context.strategy.model diff --git a/packages/uipath/src/uipath/eval/mocks/_simulate_component_service.py b/packages/uipath/src/uipath/eval/mocks/_simulate_component_service.py index 29f8d3a20..50f83cad1 100644 --- a/packages/uipath/src/uipath/eval/mocks/_simulate_component_service.py +++ b/packages/uipath/src/uipath/eval/mocks/_simulate_component_service.py @@ -1,8 +1,10 @@ """Service for calling the simulate-component API.""" +import json from typing import Any from uipath._utils import Endpoint +from uipath.core.serialization import serialize_json from uipath.platform.common import BaseService from uipath.platform.constants import ( HEADER_INTERNAL_ACCOUNT_ID, @@ -25,7 +27,7 @@ async def simulate(self, payload: dict[str, Any]) -> dict[str, Any]: url=Endpoint( "/agentsruntime_/api/execution/simulations/simulate-component" ), - json=payload, + json=json.loads(serialize_json(payload)), headers=headers, ) return response.json() diff --git a/packages/uipath/tests/cli/eval/mocks/test_mocker_arg_serialization.py b/packages/uipath/tests/cli/eval/mocks/test_mocker_arg_serialization.py new file mode 100644 index 000000000..a8752c276 --- /dev/null +++ b/packages/uipath/tests/cli/eval/mocks/test_mocker_arg_serialization.py @@ -0,0 +1,251 @@ +"""Tool arguments must survive simulation regardless of their Python type. + +A simulated tool call is serialized twice: once by ``LLMMocker`` to build the +simulation prompt, and once by ``SimulateComponentService`` to POST the payload. +Tool ``args_schema`` models may type a field as any Python type (``uuid.UUID``, +``datetime``, ``Enum``, ...), and LangChain hands the tool the *validated* +value, so both paths receive objects the stdlib JSON encoder rejects. +""" + +import json +import uuid +from datetime import datetime, timezone +from enum import Enum +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from _pytest.monkeypatch import MonkeyPatch +from pytest_httpx import HTTPXMock + +from uipath.eval.mocks._mock_runtime import ( + clear_execution_context, + set_execution_context, +) +from uipath.eval.mocks._simulate_component_service import ( + _create_simulate_component_service, +) +from uipath.eval.mocks._types import ( + ComponentSimulationConfig, + LLMMockingStrategy, + MockingContext, + SimulationStrategy, + ToolSimulation, +) +from uipath.eval.mocks.mockable import mockable + +_mock_span_collector = MagicMock() + +_ATTACHMENT_ID = uuid.UUID("9b702dc7-4988-4fc0-ba81-08deeaade3da") + + +class _Flavor(str, Enum): + PDF = "pdf" + + +class _Rank(Enum): + """A plain Enum: not a str subclass, so json.dumps cannot encode it.""" + + HIGH = 1 + + +def _llm_context(tool_name: str) -> MockingContext: + return MockingContext( + strategy=LLMMockingStrategy( + prompt="simulate it", + tools_to_simulate=[ToolSimulation(name=tool_name)], + ), + name="test-run", + inputs={}, + ) + + +class TestLLMMockerArgSerialization: + """LLMMocker builds its prompt with json.dumps over the raw invocation.""" + + def teardown_method(self): + clear_execution_context() + + @pytest.mark.asyncio + async def test_uuid_argument_does_not_break_prompt_generation(self): + captured: dict[str, Any] = {} + + async def _fake_generate(llm, messages, **kwargs): + captured["prompt"] = messages[0]["content"] + return {"ok": True} + + @mockable() + async def extraction_tool(**kwargs: Any) -> dict[str, Any]: + raise NotImplementedError("must be simulated, never executed") + + set_execution_context( + _llm_context("extraction_tool"), _mock_span_collector, "exec-uuid" + ) + with ( + patch("uipath.eval.mocks._llm_mocker.UiPath", MagicMock()), + patch("uipath.eval.mocks._llm_mocker.UiPathLlmChatService", MagicMock()), + patch( + "uipath.eval.mocks._llm_mocker.generate_structured_output", + _fake_generate, + ), + ): + result = await extraction_tool( + id=_ATTACHMENT_ID, + full_name="PO_234.pdf", + mime_type="application/pdf", + ) + + assert result == {"ok": True} + # The UUID must reach the prompt in its string form. + assert str(_ATTACHMENT_ID) in captured["prompt"] + + @pytest.mark.asyncio + async def test_other_non_json_native_arguments_are_serialized(self): + captured: dict[str, Any] = {} + + async def _fake_generate(llm, messages, **kwargs): + captured["prompt"] = messages[0]["content"] + return {"ok": True} + + @mockable() + async def typed_tool(**kwargs: Any) -> dict[str, Any]: + raise NotImplementedError() + + set_execution_context( + _llm_context("typed_tool"), _mock_span_collector, "exec-typed" + ) + with ( + patch("uipath.eval.mocks._llm_mocker.UiPath", MagicMock()), + patch("uipath.eval.mocks._llm_mocker.UiPathLlmChatService", MagicMock()), + patch( + "uipath.eval.mocks._llm_mocker.generate_structured_output", + _fake_generate, + ), + ): + await typed_tool( + when=datetime(2026, 7, 27, 12, 39, 59, tzinfo=timezone.utc), + flavor=_Flavor.PDF, + rank=_Rank.HIGH, + tags={"alpha", "beta"}, + ) + + prompt = captured["prompt"] + assert "2026-07-27T12:39:59" in prompt + assert "pdf" in prompt + # A plain (non-str) Enum must be reduced to its value. + assert '"rank": 1' in prompt + # Sets become lists; assert on members so the check stays order-independent. + assert '"alpha"' in prompt + assert '"beta"' in prompt + + +class TestSimulateComponentPayloadSerialization: + """The simulate-component payload is encoded by httpx with a bare json.dumps.""" + + def teardown_method(self): + clear_execution_context() + + @pytest.mark.asyncio + @pytest.mark.httpx_mock(assert_all_responses_were_requested=False) + async def test_uuid_in_payload_is_sent_as_string( + self, httpx_mock: HTTPXMock, monkeypatch: MonkeyPatch + ): + monkeypatch.setenv("UIPATH_URL", "https://example.com/myorg/mytenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "token") + httpx_mock.add_response( + url="https://example.com/myorg/mytenant/agentsruntime_/api/execution/simulations/simulate-component", + method="POST", + json={"status": 1, "simulatedOutput": "result"}, + ) + + service = _create_simulate_component_service() + result = await service.simulate( + { + "componentId": "extraction_tool", + "input": { + "args": [], + "kwargs": { + "id": _ATTACHMENT_ID, + "when": datetime(2026, 7, 27, 12, 39, 59, tzinfo=timezone.utc), + "rank": _Rank.HIGH, + "tags": {"alpha", "beta"}, + }, + }, + } + ) + + assert result == {"status": 1, "simulatedOutput": "result"} + sent_kwargs = json.loads(httpx_mock.get_requests()[-1].read())["input"][ + "kwargs" + ] + assert sent_kwargs["id"] == str(_ATTACHMENT_ID) + assert sent_kwargs["when"].startswith("2026-07-27T12:39:59") + assert sent_kwargs["rank"] == 1 + assert sorted(sent_kwargs["tags"]) == ["alpha", "beta"] + + @pytest.mark.asyncio + async def test_mocker_end_to_end_with_uuid_argument(self): + captured: list[dict[str, Any]] = [] + + async def _capture(payload, **kwargs): + captured.append(payload) + return {"status": 1, "simulatedOutput": "ok"} + + svc_mock = MagicMock() + svc_mock.simulate = _capture + + @mockable() + async def extraction_tool(**kwargs: Any) -> str: + raise NotImplementedError() + + context = MockingContext( + strategy=None, + name="test-run", + inputs={}, + workload_id="wl-1", + components=[ + ComponentSimulationConfig( + component_id="extraction_tool", + simulation_strategy=SimulationStrategy.LLM, + ) + ], + ) + set_execution_context(context, _mock_span_collector, "exec-e2e") + with patch( + "uipath.eval.mocks._simulate_component_mocker._create_simulate_component_service", + return_value=svc_mock, + ): + result = await extraction_tool(id=_ATTACHMENT_ID) + + assert result == "ok" + assert captured[0]["input"]["kwargs"]["id"] == _ATTACHMENT_ID + + +class TestSimulateComponentServiceUnchangedBehaviour: + """Normalization must not alter payloads that already serialized cleanly.""" + + @pytest.mark.asyncio + @pytest.mark.httpx_mock(assert_all_responses_were_requested=False) + async def test_json_native_payload_is_unchanged( + self, httpx_mock: HTTPXMock, monkeypatch: MonkeyPatch + ): + monkeypatch.setenv("UIPATH_URL", "https://example.com/myorg/mytenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "token") + httpx_mock.add_response( + url="https://example.com/myorg/mytenant/agentsruntime_/api/execution/simulations/simulate-component", + method="POST", + json={"status": 1, "simulatedOutput": "result"}, + ) + + payload: dict[str, Any] = { + "componentId": "my_tool", + "componentType": "tool", + "input": {"args": [1, "two", True, None], "kwargs": {"nested": {"a": 1.5}}}, + "behaviors": None, + "simulationStrategy": 0, + } + service = _create_simulate_component_service() + await service.simulate(payload) + + sent = json.loads(httpx_mock.get_requests()[-1].read()) + assert sent == payload diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 7af888cf9..a29691618 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2598,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.16" +version = "2.13.17" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 6de5bed7fcd7e7f2d4948c8da475f90e07728399 Mon Sep 17 00:00:00 2001 From: Eduard Dumitru Date: Wed, 29 Jul 2026 12:01:10 +0200 Subject: [PATCH 180/191] feat(cli): serve the pre-warmed runtime over uipath-ipc alongside HTTP [ROBO-5779] (#1809) --- packages/uipath/pyproject.toml | 7 +- .../uipath/src/uipath/_cli/_server_core.py | 108 ++++++++ packages/uipath/src/uipath/_cli/cli_server.py | 256 +++++++++--------- .../uipath/src/uipath/_cli/cli_server_ipc.py | 103 +++++++ packages/uipath/tests/cli/test_server.py | 39 ++- packages/uipath/tests/cli/test_server_ipc.py | 226 ++++++++++++++++ .../uipath/tests/cli/test_server_job_core.py | 94 +++++++ .../uipath/tests/cli/test_server_transport.py | 216 +++++++++++++++ packages/uipath/uv.lock | 21 +- sonar-project.properties | 12 + 10 files changed, 939 insertions(+), 143 deletions(-) create mode 100644 packages/uipath/src/uipath/_cli/_server_core.py create mode 100644 packages/uipath/src/uipath/_cli/cli_server_ipc.py create mode 100644 packages/uipath/tests/cli/test_server_ipc.py create mode 100644 packages/uipath/tests/cli/test_server_job_core.py create mode 100644 packages/uipath/tests/cli/test_server_transport.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index d2c1791f0..6ba25b021 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.13.17" +version = "2.13.18" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" @@ -46,6 +46,9 @@ Documentation = "https://uipath.github.io/uipath-python/" [project.scripts] uipath = "uipath._cli:cli" +[project.optional-dependencies] +ipc = ["uipath-ipc>=2.5.1,<2.6.0"] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -78,6 +81,7 @@ dev = [ "types-toml>=0.10.8", "types-PyYAML>=6.0", "pytest-timeout>=2.4.0", + "uipath-ipc>=2.5.1,<2.6.0", ] [tool.hatch.build.targets.wheel] @@ -171,6 +175,7 @@ exclude-newer = "2 days" uipath-core = false uipath-runtime = false uipath-platform = false +uipath-ipc = false [tool.uv.sources] uipath-core = { path = "../uipath-core", editable = true } diff --git a/packages/uipath/src/uipath/_cli/_server_core.py b/packages/uipath/src/uipath/_cli/_server_core.py new file mode 100644 index 000000000..c426126ff --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_server_core.py @@ -0,0 +1,108 @@ +"""Transport-agnostic job core shared by the HTTP and uipath-ipc channels.""" + +import asyncio +import os +import shlex +from typing import Any + +from .cli_debug import debug +from .cli_eval import eval +from .cli_run import run + +COMMANDS = { + "run": run, + "debug": debug, + "eval": eval, +} + + +class _ServerState: + """Mutable server state, initialized lazily at server startup.""" + + def __init__(self) -> None: + self.lock: asyncio.Lock | None = None + self.baseline_env: dict[str, str] | None = None + + def init(self) -> None: + """Must be called inside a running event loop at server startup.""" + if self.lock is not None: + return + self.lock = asyncio.Lock() + self.baseline_env = os.environ.copy() + + +_state = _ServerState() + + +def parse_args(args: str | list[str] | None) -> list[str]: + """Parse args into a list of strings.""" + if args is None: + return [] + if isinstance(args, list): + return args + if isinstance(args, str): + return shlex.split(args) + return [] + + +async def _run_command_isolated( + cmd: Any, + args: list[str], + env_vars: dict[str, str], + working_dir: str | None, +) -> dict[str, Any]: + """Run one command with per-job env/cwd isolation (the shared job core).""" + if _state.lock is None or _state.baseline_env is None: + raise RuntimeError("Server state not initialized") + + async with _state.lock: + original_cwd = os.getcwd() + try: + # Start from server baseline + request env vars only, so nothing from + # a previous job leaks through. + os.environ.clear() + os.environ.update(_state.baseline_env) + if isinstance(env_vars, dict): + os.environ.update(env_vars) + + if working_dir and isinstance(working_dir, str): + try: + os.chdir(working_dir) + except (FileNotFoundError, NotADirectoryError, PermissionError) as e: + # Request-shaped error: the caller gave a bad working dir. + # HTTP surfaces this as 400; IPC just returns ExitCode/Error. + return { + "ExitCode": 1, + "Error": f"Cannot change to working directory: {e}", + "Result": None, + "Unexpected": False, + "ClientError": True, + } + + result_value = await asyncio.to_thread( + cmd.main, args, standalone_mode=False + ) + return { + "ExitCode": 0, + "Error": None, + "Result": result_value, + "Unexpected": False, + } + except SystemExit as e: + exit_code = e.code if isinstance(e.code, int) else 1 + return { + "ExitCode": exit_code, + "Error": None if exit_code == 0 else f"Exit code: {exit_code}", + "Result": None, + "Unexpected": False, + } + except Exception as e: # report any job failure as a result, not a fault + return {"ExitCode": 1, "Error": str(e), "Result": None, "Unexpected": True} + finally: + # Restore to server baseline. + try: + os.chdir(original_cwd) + except OSError: + pass + os.environ.clear() + os.environ.update(_state.baseline_env) diff --git a/packages/uipath/src/uipath/_cli/cli_server.py b/packages/uipath/src/uipath/_cli/cli_server.py index 06450c401..6c020dca7 100644 --- a/packages/uipath/src/uipath/_cli/cli_server.py +++ b/packages/uipath/src/uipath/_cli/cli_server.py @@ -2,7 +2,6 @@ import importlib import json import os -import shlex import sys import tempfile import time @@ -13,44 +12,39 @@ import click from aiohttp import ClientSession, UnixConnector, web +from ._server_core import ( + COMMANDS, + _run_command_isolated, + _state, + parse_args, +) from ._telemetry import track_command from ._utils._console import ConsoleLogger -from .cli_debug import debug -from .cli_eval import eval -from .cli_run import run +from .cli_server_ipc import ( + IPythonRuntimeServer, + PythonRunRequest, + PythonRunResult, + PythonRuntimeService, + start_ipc_server, +) + +__all__ = [ + "server", + "IPythonRuntimeServer", + "PythonRunRequest", + "PythonRunResult", + "PythonRuntimeService", + "start_ipc_server", +] console = ConsoleLogger() +IS_WINDOWS = sys.platform == "win32" + SOCKET_ENV_VAR = "UIPATH_SERVER_SOCKET" DEFAULT_SOCKET_PATH = "/tmp/uipath-server.sock" DEFAULT_PORT = 8765 -IS_WINDOWS = sys.platform == "win32" - -COMMANDS = { - "run": run, - "debug": debug, - "eval": eval, -} - - -class _ServerState: - """Mutable server state, initialized lazily at server startup.""" - - def __init__(self) -> None: - self.lock: asyncio.Lock | None = None - self.baseline_env: dict[str, str] | None = None - - def init(self) -> None: - """Must be called inside a running event loop at server startup.""" - if self.lock is not None: - return - self.lock = asyncio.Lock() - self.baseline_env = os.environ.copy() - - -_state = _ServerState() - DEFAULT_PRELOAD_MODULES = [ # Network/async - slowest to load @@ -97,7 +91,7 @@ def preload_modules() -> None: def generate_socket_path() -> str: - """Generate a unique socket path for the server to listen on.""" + """Generate a unique socket path for the HTTP server to listen on.""" return os.path.join(tempfile.gettempdir(), f"uipath-server-{os.getpid()}.sock") @@ -109,15 +103,9 @@ def get_field(message: dict[str, Any], *keys: str) -> Any: return None -def parse_args(args: str | list[str] | None) -> list[str]: - """Parse args into a list of strings.""" - if args is None: - return [] - if isinstance(args, list): - return args - if isinstance(args, str): - return shlex.split(args) - return [] +# --------------------------------------------------------------------------- # +# HTTP transport (default) — aiohttp over a Unix socket / TCP, with ready-ACK # +# --------------------------------------------------------------------------- # async def send_ack(ack_socket_path: str, server_socket_path: str) -> None: @@ -150,7 +138,7 @@ async def handle_health(request: web.Request) -> web.Response: async def handle_start(request: web.Request) -> web.Response: - """Handle POST /jobs/{job_key}/start endpoint.""" + """Handle POST /jobs/{job_key}/start — runs a job via the shared core.""" job_key = request.match_info.get("job_key") if not job_key: return web.json_response( @@ -173,13 +161,19 @@ async def handle_start(request: web.Request) -> web.Response: status=400, ) - args_raw = get_field(message, "args", "Args") - args = parse_args(args_raw) - - env_vars = get_field(message, "environmentVariables", "EnvironmentVariables") or {} + args = parse_args(get_field(message, "args", "Args")) + env_vars = get_field(message, "environmentVariables", "EnvironmentVariables") working_dir = get_field(message, "workingDirectory", "WorkingDirectory") - console.info(f"Starting job {job_key}: {command_name} {args}") + if env_vars is not None and not isinstance(env_vars, dict): + return web.json_response( + { + "success": False, + "error": "Invalid field: 'environmentVariables' must be a dict", + }, + status=400, + ) + env_vars = env_vars or {} cmd = COMMANDS.get(command_name) if cmd is None: @@ -188,78 +182,28 @@ async def handle_start(request: web.Request) -> web.Response: status=400, ) - console.info(f"Original cwd: {os.getcwd()}") - console.info(f"Requested working_dir: {working_dir}") + console.info(f"Starting job {job_key}: {command_name} {args}") - if _state.lock is None or _state.baseline_env is None: - raise RuntimeError("Server state not initialized") + result = await _run_command_isolated(cmd, args, env_vars, working_dir) - # Validate environmentVariables type early - if env_vars and not isinstance(env_vars, dict): + if result["Unexpected"]: return web.json_response( - { - "success": False, - "error": "Invalid field: 'environmentVariables' must be a dict", - }, + {"success": False, "job_key": job_key, "error": result["Error"]}, + status=500, + ) + if result.get("ClientError"): + # Request-shaped failure (e.g. bad working directory) — 4xx, not 200. + return web.json_response( + {"success": False, "job_key": job_key, "error": result["Error"]}, status=400, ) - - # Serialize command execution to prevent concurrent os.environ mutation - async with _state.lock: - original_cwd = os.getcwd() - - try: - # Start from server baseline + request env vars only. - # This ensures no env vars from previous requests leak through. - os.environ.clear() - os.environ.update(_state.baseline_env) - if isinstance(env_vars, dict): - os.environ.update(env_vars) - - if working_dir and isinstance(working_dir, str): - try: - os.chdir(working_dir) - except (FileNotFoundError, NotADirectoryError, PermissionError) as e: - return web.json_response( - { - "success": False, - "job_key": job_key, - "error": f"Cannot change to working directory: {e}", - }, - status=400, - ) - - result = await asyncio.to_thread(cmd.main, args, standalone_mode=False) - - return web.json_response( - { - "success": True, - "job_key": job_key, - "result": result, - } - ) - except SystemExit as e: - exit_code = e.code if isinstance(e.code, int) else 1 - return web.json_response( - { - "success": exit_code == 0, - "job_key": job_key, - "error": None if exit_code == 0 else f"Exit code: {exit_code}", - } - ) - except Exception as e: - return web.json_response( - {"success": False, "job_key": job_key, "error": str(e)}, - status=500, - ) - finally: - # Restore to server baseline - try: - os.chdir(original_cwd) - except OSError: - pass - os.environ.clear() - os.environ.update(_state.baseline_env) + if result["ExitCode"] == 0: + return web.json_response( + {"success": True, "job_key": job_key, "result": result["Result"]} + ) + return web.json_response( + {"success": False, "job_key": job_key, "error": result["Error"]} + ) ALLOWED_HOSTS = {"127.0.0.1", "localhost", "[::1]"} @@ -350,58 +294,106 @@ async def start_tcp_server(host: str, port: int) -> None: await runner.cleanup() +# The uipath-ipc transport (contract, DTOs, service, ``start_ipc_server``) lives +# in ``cli_server_ipc`` and is served alongside HTTP when ``--ipc-pipe`` is given. +# Older servers served HTTP only; the .NET Handler copes. + + +# --------------------------------------------------------------------------- # +# CLI # +# --------------------------------------------------------------------------- # + + @click.command() @click.option( "--client-socket", type=str, default=None, - help=f"Unix socket path to send ready ack to (default: ${SOCKET_ENV_VAR} or {DEFAULT_SOCKET_PATH})", + help=f"Unix socket to send the ready ACK to (default: ${SOCKET_ENV_VAR} " + f"or {DEFAULT_SOCKET_PATH}).", ) @click.option( "--server-socket", type=str, default=None, - help="Unix socket path the server listens on (default: auto-generated in tmp dir)", + help="Unix socket the HTTP server listens on (default: auto-generated in tmp).", +) +@click.option( + "--ipc-pipe", + type=str, + default=None, + help="Named pipe for the uipath-ipc channel. IPC is served only when this is " + "given; omit it for HTTP-only.", ) @click.option( "--port", type=int, default=None, - help=f"TCP port, used on Windows or when --tcp flag is set (default: {DEFAULT_PORT})", + help=f"TCP port, used on Windows or with --tcp (default: {DEFAULT_PORT}).", ) @click.option( "--tcp", is_flag=True, - help="Force TCP mode even on Unix systems", + help="Force TCP mode even on Unix systems.", ) @track_command("server") def server( client_socket: str | None, server_socket: str | None, + ipc_pipe: str | None, port: int | None, tcp: bool, ) -> None: - """Start an HTTP server that forwards commands to run/debug/eval. + """Serve run/debug/eval over HTTP, plus uipath-ipc when --ipc-pipe is given.""" + preload_modules() + _run_server(client_socket, server_socket, ipc_pipe, port, tcp) - Creates its own socket to listen on and sends an ack to --client-socket with: - {"status": "ready", "socket": "/path/to/server.sock"} - Endpoint: POST /jobs/{job_key}/start - Body: {"command": "run", "args": "agent.json '{}'", "environmentVariables": {}, "workingDirectory": "/path"} +async def _serve( + ack_socket_path: str, + server_socket: str | None, + ipc_pipe: str | None, + port: int, + use_tcp: bool, +) -> None: + """Run the HTTP channel, plus the uipath-ipc channel when a pipe name is given.""" + _state.init() - Endpoint: GET /health - """ - use_tcp = IS_WINDOWS or tcp + tasks: list[Any] = [] + if use_tcp: + tasks.append(start_tcp_server("127.0.0.1", port)) + else: + tasks.append(start_unix_server(ack_socket_path, server_socket)) - preload_modules() + # IPC is opt-in and independent of the HTTP socket: it is served only when an + # explicit pipe name is given, which both sides agree on out of band (the .NET + # peer connects to the same name it passed — no derivation from the HTTP socket). + if ipc_pipe: + tasks.append(start_ipc_server(ipc_pipe)) + + await asyncio.gather(*tasks) + +def _run_server( + client_socket: str | None, + server_socket: str | None, + ipc_pipe: str | None, + port: int | None, + tcp: bool, +) -> None: + """Drive ``_serve`` on the right event loop for the platform.""" + use_tcp = IS_WINDOWS or tcp + ack_socket_path = ( + client_socket or os.environ.get(SOCKET_ENV_VAR) or DEFAULT_SOCKET_PATH + ) + coro = _serve( + ack_socket_path, server_socket, ipc_pipe, port or DEFAULT_PORT, use_tcp + ) try: - if use_tcp: - asyncio.run(start_tcp_server("127.0.0.1", port or DEFAULT_PORT)) + if sys.platform == "win32": + with asyncio.Runner(loop_factory=asyncio.ProactorEventLoop) as runner: + runner.run(coro) else: - ack_socket_path = ( - client_socket or os.environ.get(SOCKET_ENV_VAR) or DEFAULT_SOCKET_PATH - ) - asyncio.run(start_unix_server(ack_socket_path, server_socket)) + asyncio.run(coro) except KeyboardInterrupt: console.info("Shutting down") diff --git a/packages/uipath/src/uipath/_cli/cli_server_ipc.py b/packages/uipath/src/uipath/_cli/cli_server_ipc.py new file mode 100644 index 000000000..24ebd100c --- /dev/null +++ b/packages/uipath/src/uipath/_cli/cli_server_ipc.py @@ -0,0 +1,103 @@ +"""uipath-ipc runtime transport — the IPC contract, DTOs, and service. + +The PascalCase method and field names are dictated by the .NET/CoreIpc peer +(the serializer maps them verbatim), so Sonar's S100/S116 naming rules are +suppressed for this file only (see ``sonar-project.properties``). + +``uipath-ipc`` is an optional dependency (the ``ipc`` extra): it is imported +lazily inside ``start_ipc_server`` so this module — and HTTP-only serving — +works without it. The DTOs and the contract below are pure stdlib and never +reference it. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field + +from ._server_core import COMMANDS, _run_command_isolated, _state, parse_args +from ._utils._console import ConsoleLogger + +console = ConsoleLogger() + + +@dataclass +class PythonRunRequest: + """Mirrors the .NET PythonRunRequest DTO. PascalCase fields match the wire keys.""" + + JobKey: str = "" + Command: str = "" + # The .NET peer sends a single string; HTTP callers and tests may pass a + # pre-split list. parse_args accepts both. + Args: str | list[str] | None = None + WorkingDirectory: str | None = None + EnvironmentVariables: dict[str, str] = field(default_factory=dict) + + +@dataclass +class PythonRunResult: + """Mirrors the .NET PythonRunResult DTO.""" + + ExitCode: int = 0 + Error: str | None = None + + +class IPythonRuntimeServer(ABC): + """Contract the .NET job executor calls over uipath-ipc.""" + + @abstractmethod + async def StartJob(self, request: PythonRunRequest) -> PythonRunResult: + """Run a job → PythonRunResult(ExitCode, Error).""" + + @abstractmethod + async def StopJob(self, job_key: str) -> bool: + """Cancel a running job by key (bool return avoids fire-and-forget).""" + + +class PythonRuntimeService(IPythonRuntimeServer): + """``IPythonRuntimeServer`` implementation backed by run/debug/eval.""" + + async def StartJob(self, request: PythonRunRequest) -> PythonRunResult: + command_name = request.Command + if not isinstance(command_name, str) or not command_name: + return PythonRunResult( + ExitCode=1, Error="Missing or invalid field: 'Command'" + ) + + cmd = COMMANDS.get(command_name) + if cmd is None: + return PythonRunResult(ExitCode=1, Error=f"Unknown command: {command_name}") + + args = parse_args(request.Args) + + console.info(f"Starting job {request.JobKey}: {command_name} {args}") + + result = await _run_command_isolated( + cmd, args, request.EnvironmentVariables, request.WorkingDirectory + ) + # IPC contract (PythonRunResult) carries only ExitCode + Error. + return PythonRunResult(ExitCode=result["ExitCode"], Error=result["Error"]) + + async def StopJob(self, job_key: str) -> bool: + console.info(f"StopJob requested for {job_key} (no-op)") + return True + + +async def start_ipc_server(pipe_name: str) -> None: + """Serve the Python runtime over a uipath-ipc named pipe until it is closed.""" + try: + from uipath_ipc import IpcServer, NamedPipeServerTransport + except ImportError as e: + raise RuntimeError( + "The uipath-ipc channel was requested (--ipc-pipe) but the 'uipath-ipc' " + "package is not installed in this environment. Install it (pip install " + "'uipath[ipc]') or omit --ipc-pipe to serve HTTP only." + ) from e + + _state.init() + server = IpcServer( + transport=NamedPipeServerTransport(pipe_name), + services={IPythonRuntimeServer: PythonRuntimeService()}, + request_timeout=None, # jobs are long-running; no server-side timeout + ) + console.success(f"IPC server listening on pipe '{pipe_name}'") + async with server: + await server.serve_forever() diff --git a/packages/uipath/tests/cli/test_server.py b/packages/uipath/tests/cli/test_server.py index c4d73f361..70d29cb38 100644 --- a/packages/uipath/tests/cli/test_server.py +++ b/packages/uipath/tests/cli/test_server.py @@ -147,6 +147,27 @@ def test_start_job_unknown_command(self, server): assert response["success"] is False assert "Unknown command" in response["error"] + def test_bad_working_directory_returns_400(self, server): + """A request-shaped error (bad working dir) is a 4xx, not a 200.""" + port = server + + async def send(): + async with aiohttp.ClientSession() as session: + async with session.post( + f"http://127.0.0.1:{port}/jobs/job-bad-cwd/start", + json={ + "command": "run", + "args": [], + "workingDirectory": "/no/such/dir/xyz-does-not-exist", + }, + ) as response: + return response.status, await response.json() + + status, body = asyncio.run(send()) + assert status == 400 + assert body["success"] is False + assert "working directory" in body["error"] + def test_start_job_missing_command(self, server): """Test starting a job without command field.""" port = server @@ -337,21 +358,21 @@ def server_with_spy(self, server_port, env_snapshots): """Start server with a spy command that captures os.environ.""" import click - from uipath._cli import cli_server + from uipath._cli import _server_core @click.command() def spy_cmd(): env_snapshots.append(dict(os.environ)) - original_commands = cli_server.COMMANDS.copy() - cli_server.COMMANDS["spy"] = spy_cmd + original_commands = _server_core.COMMANDS.copy() + _server_core.COMMANDS["spy"] = spy_cmd start_cli_server_thread(server_port) yield server_port - cli_server.COMMANDS.clear() - cli_server.COMMANDS.update(original_commands) + _server_core.COMMANDS.clear() + _server_core.COMMANDS.update(original_commands) def test_env_vars_do_not_leak_between_requests( self, server_with_spy, env_snapshots @@ -396,7 +417,7 @@ def test_env_vars_do_not_leak_between_requests( def test_server_baseline_env_preserved(self, server_with_spy, env_snapshots): """Server baseline env vars (like PATH) should be available during command execution.""" - from uipath._cli import cli_server + from uipath._cli import _server_core port = server_with_spy @@ -414,7 +435,7 @@ def test_server_baseline_env_preserved(self, server_with_spy, env_snapshots): env_run = env_snapshots[0] # Baseline is captured at server start, not import time - baseline = cli_server._state.baseline_env + baseline = _server_core._state.baseline_env assert baseline is not None # Baseline env vars should be present @@ -425,7 +446,7 @@ def test_server_baseline_env_preserved(self, server_with_spy, env_snapshots): def test_env_restored_after_request(self, server_with_spy): """os.environ should be restored to baseline after each request.""" - from uipath._cli import cli_server + from uipath._cli import _server_core port = server_with_spy @@ -439,7 +460,7 @@ def test_env_restored_after_request(self, server_with_spy): ) ) - baseline = cli_server._state.baseline_env + baseline = _server_core._state.baseline_env assert baseline is not None # After the request, os.environ should match baseline diff --git a/packages/uipath/tests/cli/test_server_ipc.py b/packages/uipath/tests/cli/test_server_ipc.py new file mode 100644 index 000000000..efcd76a19 --- /dev/null +++ b/packages/uipath/tests/cli/test_server_ipc.py @@ -0,0 +1,226 @@ +"""Tests for the uipath-ipc runtime server channel. + +The server hosts ``IPythonRuntimeServer`` (StartJob / StopJob) on a named pipe +alongside the HTTP channel when ``--ipc-pipe`` names one (see +``test_server_transport.py`` for the channel composition). Mirrors +``test_server.py`` (the HTTP path) but drives the pipe with a Python +``uipath-ipc`` client. + +Requires ``uipath-ipc`` to be installed. ``StartJob`` success runs the real +runtime (like ``test_server.test_start_job_success``); the rest exercise the IPC +wiring and env isolation without it. +""" + +import asyncio +import json +import os +import sys +import threading +import time +from typing import Any, Awaitable, Callable + +import click +import pytest +from uipath_ipc import IpcClient, NamedPipeClientTransport + +from uipath._cli import _server_core +from uipath._cli.cli_server import ( + IPythonRuntimeServer, + start_ipc_server, +) + +_pipe_counter = 0 + + +def _unique_pipe() -> str: + global _pipe_counter + _pipe_counter += 1 + return f"uipath-ipc-test-{os.getpid()}-{_pipe_counter}" + + +def _serve_in_background(pipe_name: str) -> None: + """Run the IPC server on its own event loop in a daemon thread. + + ``asyncio.new_event_loop()`` yields the per-OS default loop — Proactor on + Windows (required for named pipes), Selector on Linux (CoreFxPipe UDS). + """ + + def run_server() -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(start_ipc_server(pipe_name)) + except asyncio.CancelledError: + pass + finally: + loop.close() + + thread = threading.Thread(target=run_server, daemon=True) + thread.start() + _wait_until_ready(pipe_name) + + +async def _with_proxy(pipe_name: str, fn: Callable[[Any], Awaitable[Any]]) -> Any: + """Connect a uipath-ipc client to the pipe, run ``fn(proxy)``, then close.""" + client = IpcClient(transport=NamedPipeClientTransport(pipe_name)) + try: + # get_proxy is by-contract; the abstract interface is exactly what it wants. + proxy = client.get_proxy(IPythonRuntimeServer) # type: ignore[type-abstract] + return await fn(proxy) + finally: + await client.aclose() + + +def _wait_until_ready(pipe_name: str, timeout: float = 10.0) -> None: + """Poll the pipe until the IPC server answers, instead of a fixed sleep. + + A fixed ``sleep`` races the server's startup under load; this connects a real + client and calls the no-op ``StopJob`` until it succeeds (or times out). + """ + deadline = time.monotonic() + timeout + last_err: Exception | None = None + while time.monotonic() < deadline: + try: + asyncio.run(_with_proxy(pipe_name, lambda p: p.StopJob("readiness-probe"))) + return + except Exception as e: # server not accepting connections yet + last_err = e + time.sleep(0.05) + raise TimeoutError( + f"IPC server on pipe {pipe_name!r} not ready within {timeout}s: {last_err}" + ) + + +def create_uipath_json( + script_path: str, entrypoint_name: str = "main" +) -> dict[str, Any]: + return {"functions": {entrypoint_name: f"{script_path}:main"}} + + +SIMPLE_SCRIPT = """ +from dataclasses import dataclass + +@dataclass +class Input: + message: str + repeat: int = 1 + +def main(input: Input) -> str: + return (input.message + " ") * input.repeat +""" + + +def test_start_ipc_server_fails_fast_without_uipath_ipc(monkeypatch): + """--ipc-pipe with uipath-ipc absent must fail loudly, not silently no-op.""" + monkeypatch.setitem(sys.modules, "uipath_ipc", None) + coro = start_ipc_server(_unique_pipe()) + with pytest.raises(RuntimeError, match="uipath-ipc"): + asyncio.run(coro) + + +class TestIpcServer: + @pytest.fixture + def pipe(self): + pipe_name = _unique_pipe() + _serve_in_background(pipe_name) + # Daemon thread; the server blocks in serve_forever and is torn down when + # the process exits (mirrors test_server.py's background HTTP server). + yield pipe_name + + def test_start_job_success(self, pipe, temp_dir): + """A real 'run' job executes and writes output.json (needs the runtime).""" + script_file = "entrypoint.py" + with open(os.path.join(temp_dir, script_file), "w") as f: + f.write(SIMPLE_SCRIPT) + with open(os.path.join(temp_dir, "uipath.json"), "w") as f: + json.dump(create_uipath_json(script_file), f) + + input_file = os.path.join(temp_dir, "input.json") + with open(input_file, "w") as f: + json.dump({"message": "Hello", "repeat": 3}, f) + output_file = os.path.join(temp_dir, "output.json") + + request = { + "JobKey": "job-123", + "Command": "run", + "Args": ["main", "--input-file", input_file, "--output-file", output_file], + "WorkingDirectory": temp_dir, + "EnvironmentVariables": {}, + } + result = asyncio.run(_with_proxy(pipe, lambda p: p.StartJob(request))) + + assert result.ExitCode == 0 + assert result.Error is None + assert os.path.exists(output_file) + with open(output_file, "r") as f: + assert "Hello" in f.read() + + def test_start_job_unknown_command(self, pipe): + request = {"JobKey": "job-1", "Command": "does_not_exist"} + result = asyncio.run(_with_proxy(pipe, lambda p: p.StartJob(request))) + assert result.ExitCode != 0 + assert "Unknown command" in (result.Error or "") + + def test_start_job_missing_command(self, pipe): + """Absent/empty Command is rejected before the job core is touched.""" + result = asyncio.run( + _with_proxy(pipe, lambda p: p.StartJob({"JobKey": "job-1"})) + ) + assert result.ExitCode != 0 + assert "Command" in (result.Error or "") + + def test_stop_job_returns_true(self, pipe): + """StopJob is a no-op stub today, but must ack (bool) so the call is awaitable.""" + result = asyncio.run(_with_proxy(pipe, lambda p: p.StopJob("job-1"))) + assert result is True + + +class TestIpcServerEnvIsolation: + """Env vars must not leak between sequential jobs (as on the HTTP path).""" + + @pytest.fixture + def pipe_with_spy(self): + env_snapshots: list[dict[str, str]] = [] + + @click.command() + def spy_cmd() -> None: + env_snapshots.append(dict(os.environ)) + + original = _server_core.COMMANDS.copy() + _server_core.COMMANDS["spy"] = spy_cmd + + pipe_name = _unique_pipe() + _serve_in_background(pipe_name) + try: + yield pipe_name, env_snapshots + finally: + _server_core.COMMANDS.clear() + _server_core.COMMANDS.update(original) + + def test_env_vars_do_not_leak_between_jobs(self, pipe_with_spy): + pipe_name, env_snapshots = pipe_with_spy + + async def run_two(proxy: Any) -> None: + await proxy.StartJob( + { + "JobKey": "job-1", + "Command": "spy", + "EnvironmentVariables": {"TEST_VAR_A": "a"}, + } + ) + await proxy.StartJob( + { + "JobKey": "job-2", + "Command": "spy", + "EnvironmentVariables": {"TEST_VAR_B": "b"}, + } + ) + + asyncio.run(_with_proxy(pipe_name, run_two)) + + assert len(env_snapshots) == 2 + run1, run2 = env_snapshots + assert run1["TEST_VAR_A"] == "a" + assert "TEST_VAR_B" not in run1 + assert run2["TEST_VAR_B"] == "b" + assert "TEST_VAR_A" not in run2 diff --git a/packages/uipath/tests/cli/test_server_job_core.py b/packages/uipath/tests/cli/test_server_job_core.py new file mode 100644 index 000000000..1722d0c5b --- /dev/null +++ b/packages/uipath/tests/cli/test_server_job_core.py @@ -0,0 +1,94 @@ +"""Unit tests for ``_run_command_isolated`` — the shared job core behind both the +HTTP and uipath-ipc channels. These drive its error branches directly (bad +working dir, SystemExit, unexpected exception, uninitialized state) without +standing up a transport, so they run fast on every OS/Python. +""" + +import asyncio +import os +from typing import Any +from unittest.mock import Mock + +import pytest + +from uipath._cli import _server_core + + +@pytest.fixture +def restore_state(): + """Save/restore the module-level _ServerState singleton around a test.""" + saved_lock = _server_core._state.lock + saved_env = _server_core._state.baseline_env + try: + yield _server_core._state + finally: + _server_core._state.lock = saved_lock + _server_core._state.baseline_env = saved_env + + +def _init(state: Any) -> None: + state.lock = asyncio.Lock() + state.baseline_env = dict(os.environ) + + +async def test_requires_initialized_state(restore_state: Any) -> None: + restore_state.lock = None + restore_state.baseline_env = None + cmd = Mock() + with pytest.raises(RuntimeError, match="not initialized"): + await _server_core._run_command_isolated(cmd, [], {}, None) + + +async def test_rejects_bad_working_dir(restore_state: Any, tmp_path: Any) -> None: + _init(restore_state) + missing = str(tmp_path / "does-not-exist") + result = await _server_core._run_command_isolated(Mock(), [], {}, missing) + assert result["ExitCode"] == 1 + assert "working directory" in result["Error"] + assert result["Unexpected"] is False + assert result["ClientError"] is True # HTTP maps this to 400 + + +async def test_maps_system_exit_code(restore_state: Any) -> None: + _init(restore_state) + cmd = Mock() + cmd.main.side_effect = SystemExit(2) + result = await _server_core._run_command_isolated(cmd, [], {}, None) + assert result["ExitCode"] == 2 + assert result["Unexpected"] is False + + +async def test_reports_unexpected_exception(restore_state: Any) -> None: + _init(restore_state) + cmd = Mock() + cmd.main.side_effect = ValueError("boom") + result = await _server_core._run_command_isolated(cmd, [], {}, None) + assert result["ExitCode"] == 1 + assert result["Unexpected"] is True + assert "boom" in result["Error"] + + +# parse_args accepts what every caller sends: the .NET peer sends a single +# string (shlex-split), HTTP dicts / tests may send a pre-split list, or None. + + +def test_parse_args_splits_a_string() -> None: + # The real .NET-shaped Args: a single string, shlex-split into argv. + assert _server_core.parse_args("run --input-file in.json --flag") == [ + "run", + "--input-file", + "in.json", + "--flag", + ] + + +def test_parse_args_passes_a_list_through() -> None: + assert _server_core.parse_args(["run", "--input-file", "in.json"]) == [ + "run", + "--input-file", + "in.json", + ] + + +def test_parse_args_none_is_empty() -> None: + assert _server_core.parse_args(None) == [] diff --git a/packages/uipath/tests/cli/test_server_transport.py b/packages/uipath/tests/cli/test_server_transport.py new file mode 100644 index 000000000..6d6e2e489 --- /dev/null +++ b/packages/uipath/tests/cli/test_server_transport.py @@ -0,0 +1,216 @@ +"""`uipath server` serves BOTH transports concurrently — never either/or. + +The HTTP channel (aiohttp over a Unix socket, or TCP on Windows / ``--tcp``) is +ALWAYS started. The uipath-ipc named-pipe channel is opt-in and independent of the +HTTP socket: it is started alongside HTTP only when ``--ipc-pipe`` names a pipe, +served verbatim on that name (both sides agree on it out of band). The HTTP channel +is never torn down. + +These tests stub the three channel runners (so ``_serve``'s ``asyncio.gather`` +returns at once instead of serving forever) and assert which channels ``_serve`` +composes, how ``_run_server`` resolves its arguments, and that the CLI wires them +through. +""" + +import asyncio +from typing import Any + +from click.testing import CliRunner + +import uipath._cli._telemetry as _telemetry +from uipath._cli import _server_core, cli_server + + +def _stub_channels(monkeypatch) -> dict[str, Any]: + """Stub the three channel runners + state init; record what ``_serve`` starts. + + Each runner is replaced with an async recorder that returns immediately, so + ``_serve``'s ``asyncio.gather`` completes instead of blocking in serve-forever. + ``_state.init`` is stubbed too, so no event-loop-bound lock leaks between the + per-test loops ``asyncio.run`` creates. + """ + calls: dict[str, Any] = {} + + async def _rec_unix(ack_socket_path, server_socket_path=None): + calls["unix"] = (ack_socket_path, server_socket_path) + + async def _rec_tcp(host, port): + calls["tcp"] = (host, port) + + async def _rec_ipc(pipe_name): + calls["ipc"] = pipe_name + + monkeypatch.setattr(_server_core._state, "init", lambda: None) + monkeypatch.setattr(cli_server, "start_unix_server", _rec_unix) + monkeypatch.setattr(cli_server, "start_tcp_server", _rec_tcp) + monkeypatch.setattr(cli_server, "start_ipc_server", _rec_ipc) + return calls + + +# --------------------------------------------------------------------------- # +# _serve: channel composition # +# --------------------------------------------------------------------------- # + + +def test_serve_runs_http_and_ipc_together(monkeypatch): + calls = _stub_channels(monkeypatch) + asyncio.run( + cli_server._serve("/tmp/ack.sock", "/tmp/run-1.sock", "agent.pipe", 8765, False) + ) + assert calls["unix"] == ("/tmp/ack.sock", "/tmp/run-1.sock") + assert calls["ipc"] == "agent.pipe" # served on the explicit --ipc-pipe name + assert "tcp" not in calls + + +def test_serve_uses_the_explicit_ipc_pipe_name(monkeypatch): + """The IPC pipe name is taken verbatim from ``--ipc-pipe``, not derived from the + HTTP socket path.""" + calls = _stub_channels(monkeypatch) + asyncio.run( + cli_server._serve( + "/tmp/ack.sock", + "/var/tmp/uipath-server-42.sock", + "my-agent-pipe", + 8765, + False, + ) + ) + assert calls["ipc"] == "my-agent-pipe" # verbatim, independent of the HTTP socket + + +def test_serve_ipc_is_independent_of_the_http_socket(monkeypatch): + """``--ipc-pipe`` drives IPC even when HTTP auto-generates its socket + (``server_socket`` is ``None``).""" + calls = _stub_channels(monkeypatch) + asyncio.run(cli_server._serve("/tmp/ack.sock", None, "agent.pipe", 8765, False)) + assert calls["unix"] == ("/tmp/ack.sock", None) # HTTP auto-socket + assert calls["ipc"] == "agent.pipe" + + +def test_serve_rides_ipc_alongside_tcp(monkeypatch): + calls = _stub_channels(monkeypatch) + asyncio.run( + cli_server._serve("/tmp/ack.sock", "/tmp/run-1.sock", "agent.pipe", 9000, True) + ) + assert calls["tcp"] == ("127.0.0.1", 9000) + assert "unix" not in calls + assert calls["ipc"] == "agent.pipe" # IPC rides next to TCP too, not only UDS + + +def test_serve_skips_ipc_without_ipc_pipe(monkeypatch): + """No ``--ipc-pipe`` ⇒ HTTP only, regardless of the HTTP socket.""" + calls = _stub_channels(monkeypatch) + asyncio.run( + cli_server._serve("/tmp/ack.sock", "/tmp/run-1.sock", None, 8765, False) + ) + assert calls["unix"] == ("/tmp/ack.sock", "/tmp/run-1.sock") + assert "ipc" not in calls + + +# --------------------------------------------------------------------------- # +# _run_server: argument resolution + per-OS loop # +# --------------------------------------------------------------------------- # + + +def _capture_serve(monkeypatch) -> dict[str, Any]: + """Replace ``_serve`` with an async recorder so ``_run_server`` runs to + completion on its real per-OS loop (Proactor on Windows, ``asyncio.run`` on + Linux) without actually serving anything.""" + seen: dict[str, Any] = {} + + async def _rec_serve(ack_socket_path, server_socket, ipc_pipe, port, use_tcp): + seen.update( + ack=ack_socket_path, + server_socket=server_socket, + ipc_pipe=ipc_pipe, + port=port, + use_tcp=use_tcp, + ) + + monkeypatch.setattr(cli_server, "_serve", _rec_serve) + return seen + + +def test_run_server_defaults_ack_from_env(monkeypatch): + seen = _capture_serve(monkeypatch) + monkeypatch.setenv(cli_server.SOCKET_ENV_VAR, "/tmp/from-env.sock") + cli_server._run_server(None, "/tmp/s.sock", "agent.pipe", None, False) + assert seen["ack"] == "/tmp/from-env.sock" + assert seen["server_socket"] == "/tmp/s.sock" + assert seen["ipc_pipe"] == "agent.pipe" + assert seen["port"] == cli_server.DEFAULT_PORT + assert seen["use_tcp"] is cli_server.IS_WINDOWS # UDS on Linux, TCP on Windows + + +def test_run_server_prefers_explicit_client_socket(monkeypatch): + seen = _capture_serve(monkeypatch) + monkeypatch.setenv(cli_server.SOCKET_ENV_VAR, "/tmp/from-env.sock") + cli_server._run_server("/tmp/explicit.sock", "/tmp/s.sock", None, 1234, False) + assert seen["ack"] == "/tmp/explicit.sock" # explicit arg beats the env var + assert seen["port"] == 1234 + + +def test_run_server_falls_back_to_default_ack(monkeypatch): + seen = _capture_serve(monkeypatch) + monkeypatch.delenv(cli_server.SOCKET_ENV_VAR, raising=False) + cli_server._run_server(None, "/tmp/s.sock", None, None, False) + assert seen["ack"] == cli_server.DEFAULT_SOCKET_PATH + + +def test_run_server_tcp_flag_forces_tcp(monkeypatch): + seen = _capture_serve(monkeypatch) + cli_server._run_server("/tmp/a.sock", "/tmp/s.sock", None, None, True) + assert seen["use_tcp"] is True + + +# --------------------------------------------------------------------------- # +# CLI wiring # +# --------------------------------------------------------------------------- # + + +def _stub_cli(monkeypatch) -> dict[str, Any]: + """Disable telemetry + preload and record the args the CLI hands _run_server.""" + seen: dict[str, Any] = {} + monkeypatch.setattr(_telemetry, "is_telemetry_enabled", lambda: False) + monkeypatch.setattr(cli_server, "preload_modules", lambda: None) + monkeypatch.setattr( + cli_server, + "_run_server", + lambda client_socket, server_socket, ipc_pipe, port, tcp: seen.update( + client_socket=client_socket, + server_socket=server_socket, + ipc_pipe=ipc_pipe, + port=port, + tcp=tcp, + ), + ) + return seen + + +def test_cli_passes_socket_args_through(monkeypatch): + seen = _stub_cli(monkeypatch) + result = CliRunner().invoke( + cli_server.server, + [ + "--client-socket", + "/tmp/ack.sock", + "--server-socket", + "/tmp/run.sock", + "--ipc-pipe", + "agent.pipe", + ], + ) + assert result.exit_code == 0, result.output + assert seen["client_socket"] == "/tmp/ack.sock" + assert seen["server_socket"] == "/tmp/run.sock" + assert seen["ipc_pipe"] == "agent.pipe" + assert seen["tcp"] is False + + +def test_cli_ipc_pipe_is_optional(monkeypatch): + """No ``--ipc-pipe`` ⇒ HTTP only; the server still starts (IPC simply skipped).""" + seen = _stub_cli(monkeypatch) + result = CliRunner().invoke(cli_server.server, []) + assert result.exit_code == 0, result.output + assert seen["server_socket"] is None + assert seen["ipc_pipe"] is None diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index a29691618..ab4fcb07b 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -7,6 +7,7 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P2D" [options.exclude-newer-package] +uipath-ipc = false uipath-runtime = false uipath-platform = false uipath-core = false @@ -2598,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.17" +version = "2.13.18" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2623,6 +2624,11 @@ dependencies = [ { name = "uipath-runtime" }, ] +[package.optional-dependencies] +ipc = [ + { name = "uipath-ipc" }, +] + [package.dev-dependencies] dev = [ { name = "bandit" }, @@ -2650,6 +2656,7 @@ dev = [ { name = "tomli-w" }, { name = "types-pyyaml" }, { name = "types-toml" }, + { name = "uipath-ipc" }, { name = "virtualenv" }, ] @@ -2673,9 +2680,11 @@ requires-dist = [ { name = "tenacity", specifier = ">=9.0.0" }, { name = "truststore", specifier = ">=0.10.1" }, { name = "uipath-core", editable = "../uipath-core" }, + { name = "uipath-ipc", marker = "extra == 'ipc'", specifier = ">=2.5.1,<2.6.0" }, { name = "uipath-platform", editable = "../uipath-platform" }, { name = "uipath-runtime", specifier = ">=0.12.2,<0.13.0" }, ] +provides-extras = ["ipc"] [package.metadata.requires-dev] dev = [ @@ -2704,6 +2713,7 @@ dev = [ { name = "tomli-w", specifier = ">=1.2.0" }, { name = "types-pyyaml", specifier = ">=6.0" }, { name = "types-toml", specifier = ">=0.10.8" }, + { name = "uipath-ipc", specifier = ">=2.5.1,<2.6.0" }, { name = "virtualenv", specifier = ">=20.36.1" }, ] @@ -2739,6 +2749,15 @@ dev = [ { name = "rust-just", specifier = ">=1.39.0" }, ] +[[package]] +name = "uipath-ipc" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/6b/53d9725d6abd1dab447300a7f999332f530678e3fabd38fc31171a5a9a6f/uipath_ipc-2.5.2.tar.gz", hash = "sha256:d69c3d7c1ad1a25ef7f9f1d78c505f5d2ed7daa7227bb202404fa3d47e0ba12e", size = 86360, upload-time = "2026-07-24T09:44:35.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/eb/6def505a0d351119da27b342c11eb0767a31a7f100022d35e349c5194eb3/uipath_ipc-2.5.2-py3-none-any.whl", hash = "sha256:617f25f35377d87956165a875b0966a3cd69ae6724fcd533c93a7a6a9460fc4f", size = 50345, upload-time = "2026-07-24T09:44:33.7Z" }, +] + [[package]] name = "uipath-platform" version = "0.2.13" diff --git a/sonar-project.properties b/sonar-project.properties index 3a4a84529..5c593c8ba 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -12,4 +12,16 @@ sonar.exclusions=**/samples/**,**/testcases/**,**/template/**,**/_resources/** sonar.cpd.exclusions=**/__init__.py +# The uipath-ipc runtime contract (methods + DTO fields) is PascalCase because +# the wire keys and method names are dictated by the .NET/CoreIpc peer — the +# serializer maps them verbatim (no alias mechanism), so the names cannot be +# Python-idiomatic without breaking interop. It is isolated in cli_server_ipc.py +# so this suppression of method-naming (S100) and field-naming (S116) touches +# that file only. +sonar.issue.ignore.multicriteria=ipc1,ipc2 +sonar.issue.ignore.multicriteria.ipc1.ruleKey=python:S100 +sonar.issue.ignore.multicriteria.ipc1.resourceKey=**/cli_server_ipc.py +sonar.issue.ignore.multicriteria.ipc2.ruleKey=python:S116 +sonar.issue.ignore.multicriteria.ipc2.resourceKey=**/cli_server_ipc.py + sonar.sourceEncoding=UTF-8 From dddfe7eac6fa8b6391b9123d06ee818d58a37ade Mon Sep 17 00:00:00 2001 From: Andrei Petraru Date: Thu, 30 Jul 2026 10:19:35 +0300 Subject: [PATCH 181/191] feat: add ByoValidator for Bring Your Own Guardrail (BYOG) [AL-512] (#1833) Co-authored-by: Claude Fable 5 --- packages/uipath-platform/pyproject.toml | 2 +- .../uipath/platform/guardrails/__init__.py | 4 + .../guardrails/decorators/__init__.py | 2 + .../decorators/validators/__init__.py | 2 + .../guardrails/decorators/validators/byo.py | 104 ++++++++++++++++++ .../services/test_guardrails_decorators.py | 89 +++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 8 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/byo.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index bbf16c8ca..d973884de 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.13" +version = "0.2.14" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py b/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py index ed6c8d4dd..0f6a16209 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py @@ -17,6 +17,7 @@ from .decorators import ( BlockAction, BuiltInGuardrailValidator, + ByoValidator, CustomGuardrailValidator, CustomValidator, GuardrailAction, @@ -43,6 +44,7 @@ register_guardrail_adapter, ) from .guardrails import ( + BYO_VALIDATOR_TYPE, BuiltInValidatorGuardrail, EnumListParameterValue, GuardrailType, @@ -53,6 +55,7 @@ # Service "GuardrailsService", # Guardrail models + "BYO_VALIDATOR_TYPE", "BuiltInValidatorGuardrail", "GuardrailType", "GuardrailValidationResultType", @@ -67,6 +70,7 @@ "guardrail", "GuardrailValidatorBase", "BuiltInGuardrailValidator", + "ByoValidator", "CustomGuardrailValidator", "HarmfulContentValidator", "IntellectualPropertyValidator", diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py index 841da6660..3db6a9c4f 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py @@ -19,6 +19,7 @@ from ._registry import GuardrailTargetAdapter, register_guardrail_adapter from .validators import ( BuiltInGuardrailValidator, + ByoValidator, CustomGuardrailValidator, CustomValidator, GuardrailValidatorBase, @@ -37,6 +38,7 @@ # Validators "GuardrailValidatorBase", "BuiltInGuardrailValidator", + "ByoValidator", "CustomGuardrailValidator", "HarmfulContentValidator", "IntellectualPropertyValidator", diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py index 14cea0d4b..b2ab5e6e2 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py @@ -5,6 +5,7 @@ CustomGuardrailValidator, GuardrailValidatorBase, ) +from .byo import ByoValidator from .custom import CustomValidator, RuleFunction from .harmful_content import HarmfulContentValidator from .intellectual_property import IntellectualPropertyValidator @@ -16,6 +17,7 @@ __all__ = [ "GuardrailValidatorBase", "BuiltInGuardrailValidator", + "ByoValidator", "CustomGuardrailValidator", "HarmfulContentValidator", "IntellectualPropertyValidator", diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/byo.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/byo.py new file mode 100644 index 000000000..b89543cde --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/byo.py @@ -0,0 +1,104 @@ +"""Bring Your Own Guardrail (BYOG) validator.""" + +from typing import Sequence +from uuid import uuid4 + +from uipath.platform.guardrails.guardrails import ( + BYO_VALIDATOR_TYPE, + BuiltInValidatorGuardrail, + ValidatorParameter, +) + +from ._base import BuiltInGuardrailValidator + + +class ByoValidator(BuiltInGuardrailValidator): + """Validate data through a Bring Your Own Guardrail (BYOG) configuration. + + BYOG lets an organization plug its own safety validator (e.g. a customer + Azure Content Safety subscription, a vendor connector, or a custom + Integration Service connector) into UiPath guardrails. An admin first + creates the configuration under ``Admin -> AI Trust Layer -> Guardrails + Configurations``; this validator references it by its validator name and + (recommended) Integration Service connection id. + + Supported at all stages — BYO validator capabilities are connector-defined + and cannot be known statically, so no stage restriction is applied here. + Configuring a scope or stage the connector does not support surfaces as a + ``PROVIDER_ERROR`` at evaluation time. + + Example:: + + from uipath.platform.guardrails.decorators import ( + BlockAction, + ByoValidator, + guardrail, + ) + + byog_harmful_content = ByoValidator( + "byog-harmful-content", + connection_id="24887687-6ed1-4fe2-9b87-087ffb232682", + ) + + @guardrail(validator=byog_harmful_content, action=BlockAction()) + def summarize(text: str) -> str: + ... + + Args: + validator_name: The BYOG configuration's validator name + (``byoValidatorName``), as shown in Admin -> AI Trust Layer -> + Guardrails Configurations. + connection_id: Optional Integration Service connection id backing the + BYOG configuration. Strongly recommended: validator names are only + unique per connection, so omitting it lets the server pick the + first configuration matching the name. + parameters: Optional list of validator parameters. BYO parameter + schemas are connector-defined, so values are passed through as-is. + + Raises: + ValueError: If *validator_name* is empty or whitespace. + """ + + def __init__( + self, + validator_name: str, + *, + connection_id: str | None = None, + parameters: Sequence[ValidatorParameter] | None = None, + ) -> None: + """Initialize ByoValidator with a BYOG configuration reference.""" + if not validator_name or not validator_name.strip(): + raise ValueError("validator_name must be a non-empty string") + self.validator_name = validator_name + self.connection_id = connection_id + self.parameters = list(parameters or []) + + def get_built_in_guardrail( + self, + name: str, + description: str | None, + enabled_for_evals: bool, + ) -> BuiltInValidatorGuardrail: + """Build a BYOG :class:`BuiltInValidatorGuardrail`. + + Args: + name: Name for the guardrail. + description: Optional description. + enabled_for_evals: Whether active in evaluation scenarios. + + Returns: + Configured :class:`BuiltInValidatorGuardrail` referencing the BYOG + configuration via ``byoValidatorName``/``byoConnectionId``. + """ + return BuiltInValidatorGuardrail( + id=str(uuid4()), + name=name, + description=description + or f"Bring Your Own Guardrail validation '{self.validator_name}'", + enabled_for_evals=enabled_for_evals, + guardrail_type="builtInValidator", + validator_type=BYO_VALIDATOR_TYPE, + validator_parameters=self.parameters, + byo_validator_name=self.validator_name, + byo_connection_id=self.connection_id, + ) diff --git a/packages/uipath-platform/tests/services/test_guardrails_decorators.py b/packages/uipath-platform/tests/services/test_guardrails_decorators.py index 29674ca09..3d75ba2c3 100644 --- a/packages/uipath-platform/tests/services/test_guardrails_decorators.py +++ b/packages/uipath-platform/tests/services/test_guardrails_decorators.py @@ -21,6 +21,7 @@ from uipath.platform.guardrails.decorators import ( BlockAction, + ByoValidator, CustomValidator, GuardrailAction, GuardrailBlockException, @@ -1236,3 +1237,91 @@ def joke(topic: str) -> str: with pytest.raises(GuardrailBlockException): joke("cats") mock_uipath.guardrails.evaluate_guardrail.assert_called_once() + + +# --------------------------------------------------------------------------- +# ByoValidator — Bring Your Own Guardrail configuration reference +# --------------------------------------------------------------------------- + + +class TestByoValidator: + def test_empty_validator_name_raises(self): + with pytest.raises(ValueError, match="validator_name"): + ByoValidator("") + + def test_whitespace_validator_name_raises(self): + with pytest.raises(ValueError, match="validator_name"): + ByoValidator(" ") + + def test_builds_byo_guardrail_with_name_and_connection(self): + v = ByoValidator( + "byog-harmful-content", + connection_id="24887687-6ed1-4fe2-9b87-087ffb232682", + ) + g = v.get_built_in_guardrail("G", None, True) + assert g.validator_type == "byo" + assert g.byo_validator_name == "byog-harmful-content" + assert g.byo_connection_id == "24887687-6ed1-4fe2-9b87-087ffb232682" + + def test_connection_id_defaults_to_none(self): + v = ByoValidator("byog-harmful-content") + g = v.get_built_in_guardrail("G", None, True) + assert g.byo_connection_id is None + + def test_aliases_serialize_for_the_wire(self): + v = ByoValidator("byog-pii", connection_id="conn-1") + g = v.get_built_in_guardrail("G", None, True) + dumped = g.model_dump(by_alias=True) + assert dumped["validatorType"] == "byo" + assert dumped["byoValidatorName"] == "byog-pii" + assert dumped["byoConnectionId"] == "conn-1" + assert dumped["$guardrailType"] == "builtInValidator" + + def test_parameters_pass_through(self): + from uipath.platform.guardrails.guardrails import NumberParameterValue + + param = NumberParameterValue(parameter_type="number", id="threshold", value=0.7) + v = ByoValidator("byog-custom", parameters=[param]) + g = v.get_built_in_guardrail("G", None, True) + assert g.validator_parameters == [param] + + def test_parameters_default_empty(self): + v = ByoValidator("byog-custom") + g = v.get_built_in_guardrail("G", None, True) + assert g.validator_parameters == [] + + def test_default_description_includes_validator_name(self): + v = ByoValidator("byog-harmful-content") + g = v.get_built_in_guardrail("G", None, True) + assert g.description is not None + assert "byog-harmful-content" in g.description + + def test_no_stage_restriction(self): + v = ByoValidator("byog-harmful-content") + # BYO capabilities are connector-defined — all stages allowed + v.validate_stage(GuardrailExecutionStage.PRE) + v.validate_stage(GuardrailExecutionStage.POST) + + def test_selector_is_none(self): + v = ByoValidator("byog-harmful-content") + g = v.get_built_in_guardrail("G", None, True) + assert g.selector is None + + def test_run_forwards_byo_guardrail_to_service(self): + v = ByoValidator("byog-harmful-content", connection_id="conn-1") + mock_uipath = MagicMock() + mock_uipath.guardrails.evaluate_guardrail.return_value = ( + GuardrailValidationResult( + result=GuardrailValidationResultType.PASSED, reason="" + ) + ) + with patch("uipath.platform.UiPath", return_value=mock_uipath): + result = v.run( + "G", None, True, "some input", GuardrailExecutionStage.PRE, None, None + ) + assert result.result == GuardrailValidationResultType.PASSED + data, g = mock_uipath.guardrails.evaluate_guardrail.call_args[0] + assert data == "some input" + assert g.validator_type == "byo" + assert g.byo_validator_name == "byog-harmful-content" + assert g.byo_connection_id == "conn-1" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 295f77671..80451fb6f 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.13" +version = "0.2.14" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index ab4fcb07b..9c6d369c2 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2760,7 +2760,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.13" +version = "0.2.14" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From 110e21e02ba4af0047242d2303d94cf29b68aa52 Mon Sep 17 00:00:00 2001 From: Chibi Vikramathithan Date: Thu, 30 Jul 2026 08:50:31 -0700 Subject: [PATCH 182/191] fix(platform): drop temperature for models that reject it (#1834) Co-authored-by: Claude Opus 5 (1M context) --- .../platform/chat/_llm_gateway_service.py | 21 +- .../platform/chat/_model_capabilities.py | 98 +++++++ .../platform/common/_endpoints_manager.py | 10 + .../services/test_llm_temperature_skip.py | 245 ++++++++++++++++++ packages/uipath/pyproject.toml | 4 +- .../tests/cli/eval/mocks/test_input_mocker.py | 7 + packages/uipath/uv.lock | 2 +- 7 files changed, 380 insertions(+), 7 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/chat/_model_capabilities.py create mode 100644 packages/uipath-platform/tests/services/test_llm_temperature_skip.py diff --git a/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py b/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py index 00e373d60..65ec6223a 100644 --- a/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py +++ b/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py @@ -29,6 +29,7 @@ from ..common._endpoints_manager import EndpointManager from ..common._execution_context import UiPathExecutionContext from ..common._models import Endpoint +from ._model_capabilities import should_skip_temperature from .llm_gateway import ( ChatCompletion, SpecificToolChoice, @@ -175,6 +176,7 @@ def __init__( action_id: Optional[str] = None, ) -> None: super().__init__(config=config, execution_context=execution_context) + self._agenthub_config = agenthub_config self._llm_headers = _build_llm_headers( requesting_product, requesting_feature, agenthub_config, action_id ) @@ -329,13 +331,18 @@ class Country(BaseModel): is_reasoning_model = model.lower().startswith(("o1", "o3", "o4")) + # Reasoning models (o1, o3, o4) don't support temperature; newer models + # reject it too, which only discovery knows about. + skip_temperature = is_reasoning_model or await should_skip_temperature( + self, model, self._agenthub_config + ) + request_body: dict[str, Any] = { "messages": messages, "max_tokens": max_tokens, } - # Reasoning models (o1, o3, o4) don't support temperature - if not is_reasoning_model: + if not skip_temperature: request_body["temperature"] = temperature # Handle response_format - convert BaseModel to schema if needed @@ -392,6 +399,7 @@ def __init__( action_id: Optional[str] = None, ) -> None: super().__init__(config=config, execution_context=execution_context) + self._agenthub_config = agenthub_config self._llm_headers = _build_llm_headers( requesting_product, requesting_feature, agenthub_config, action_id ) @@ -558,13 +566,18 @@ class Country(BaseModel): is_claude_model = "claude" in model_lower is_reasoning_model = model_lower.startswith(("o1", "o3", "o4")) + # Reasoning models (o1, o3, o4) don't support temperature; newer models + # reject it too, which only discovery knows about. + skip_temperature = is_reasoning_model or await should_skip_temperature( + self, model, self._agenthub_config + ) + request_body: dict[str, Any] = { "messages": converted_messages, "max_tokens": max_tokens, } - # Reasoning models (o1, o3, o4) don't support temperature and sampling parameters - if not is_reasoning_model: + if not skip_temperature: request_body["temperature"] = temperature # Only add OpenAI-specific parameters for non-Claude and non-reasoning models diff --git a/packages/uipath-platform/src/uipath/platform/chat/_model_capabilities.py b/packages/uipath-platform/src/uipath/platform/chat/_model_capabilities.py new file mode 100644 index 000000000..4a435d117 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/chat/_model_capabilities.py @@ -0,0 +1,98 @@ +"""Per-model parameter support, resolved from the LLM Gateway discovery API. + +Newer models reject parameters that older ones accept: passing `temperature` to +a model that dropped it fails the whole request with HTTP 400. The gateway +publishes these constraints per model under `modelDetails`, so callers can strip +the parameter instead of guessing from the model name. +""" + +import logging +from typing import Any + +from ..common._base_service import BaseService +from ..common._endpoints_manager import EndpointManager +from ..common._models import Endpoint +from ..constants import HEADER_AGENTHUB_CONFIG + +logger = logging.getLogger(__name__) + +CacheKey = tuple[str, str | None] + +# One fetch per (base_url, agenthub_config); an empty mapping caches "couldn't read +# discovery". Deliberately unlocked: a racing duplicate GET is cheaper than an +# asyncio.Lock pinned to whichever event loop touched it first. +_model_details: dict[CacheKey, dict[str, dict[str, Any]]] = {} + + +async def should_skip_temperature( + service: BaseService, model: str, agenthub_config: str | None +) -> bool: + """Whether `model` rejects the `temperature` parameter. + + Args: + service: Service used to call discovery; supplies base URL and auth. + model: Model name as sent to the gateway. + agenthub_config: AgentHub config scoping which models are visible. + + Returns: + True only when discovery explicitly reports the model skips temperature. + Unknown models, unreachable discovery, and any unexpected failure return + False, leaving the caller's request untouched. + """ + try: + details = await _details_for(service, model, agenthub_config) + except Exception as e: + logger.warning( + "Could not resolve parameter support for model %s (%s); " + "sending model parameters unchanged", + model, + e, + ) + return False + + return bool(details.get("shouldSkipTemperature", False)) + + +async def _details_for( + service: BaseService, model: str, agenthub_config: str | None +) -> dict[str, Any]: + key = (service._config.base_url, agenthub_config) + + if key not in _model_details: + _model_details[key] = await _fetch_model_details(service, agenthub_config) + + return _model_details[key].get(model, {}) + + +async def _fetch_model_details( + service: BaseService, agenthub_config: str | None +) -> dict[str, dict[str, Any]]: + headers = {HEADER_AGENTHUB_CONFIG: agenthub_config} if agenthub_config else {} + endpoint = Endpoint("/" + EndpointManager.get_discovery_endpoint()) + + try: + response = await service.request_async("GET", endpoint, headers=headers) + models = response.json() + except Exception as e: + # Falling back to the caller's parameters is the pre-existing behaviour; + # failing the LLM call because discovery is down would be worse. + logger.warning( + "LLM Gateway discovery unavailable (%s); sending model parameters unchanged", + e, + ) + return {} + + if not isinstance(models, list): + logger.warning("Unexpected LLM Gateway discovery payload; expected a list") + return {} + + return { + model["modelName"]: model.get("modelDetails") or {} + for model in models + if isinstance(model, dict) and model.get("modelName") + } + + +def _reset_cache() -> None: + """Clear the discovery cache. For tests.""" + _model_details.clear() diff --git a/packages/uipath-platform/src/uipath/platform/common/_endpoints_manager.py b/packages/uipath-platform/src/uipath/platform/common/_endpoints_manager.py index 044ab1393..8e3b436ac 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_endpoints_manager.py +++ b/packages/uipath-platform/src/uipath/platform/common/_endpoints_manager.py @@ -21,6 +21,7 @@ class UiPathEndpoints(Enum): "agenthub_/llm/raw/vendor/{vendor}/model/{model}/completions" ) AH_CAPABILITIES_ENDPOINT = "agenthub_/llm/api/capabilities" + AH_DISCOVERY_ENDPOINT = "agenthub_/llm/api/discovery" OR_NORMALIZED_COMPLETION_ENDPOINT = "orchestrator_/llm/api/chat/completions" OR_PASSTHROUGH_COMPLETION_ENDPOINT = "orchestrator_/llm/openai/deployments/{model}/chat/completions?api-version={api_version}" @@ -29,6 +30,7 @@ class UiPathEndpoints(Enum): "orchestrator_/llm/raw/vendor/{vendor}/model/{model}/completions" ) OR_CAPABILITIES_ENDPOINT = "orchestrator_/llm/api/capabilities" + OR_DISCOVERY_ENDPOINT = "orchestrator_/llm/api/discovery" class EndpointManager: @@ -185,6 +187,14 @@ def get_normalized_endpoint(cls) -> str: UiPathEndpoints.OR_NORMALIZED_COMPLETION_ENDPOINT, ) + @classmethod + def get_discovery_endpoint(cls) -> str: + """Get the model discovery endpoint.""" + return cls._select_endpoint( + UiPathEndpoints.AH_DISCOVERY_ENDPOINT, + UiPathEndpoints.OR_DISCOVERY_ENDPOINT, + ) + @classmethod def get_embeddings_endpoint(cls) -> str: """Get the embeddings endpoint.""" diff --git a/packages/uipath-platform/tests/services/test_llm_temperature_skip.py b/packages/uipath-platform/tests/services/test_llm_temperature_skip.py new file mode 100644 index 000000000..fd3aef196 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_llm_temperature_skip.py @@ -0,0 +1,245 @@ +"""Temperature is dropped for models that reject it, per LLM Gateway discovery. + +Regression coverage for Sonnet 5 evals: the judge sent `temperature` on every +normalized chat completion, and the gateway answered 400 "`temperature` is +deprecated for this model." +""" + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.chat import UiPathLlmChatService, UiPathOpenAIService +from uipath.platform.chat._model_capabilities import _reset_cache + +SKIP_TEMPERATURE_MODEL = "anthropic.claude-sonnet-5" + +SKIP_TEMPERATURE_DISCOVERY = [ + { + "modelName": SKIP_TEMPERATURE_MODEL, + "modelDetails": {"shouldSkipTemperature": True}, + } +] + + +def _discovery_response(models: list[dict[str, Any]]) -> MagicMock: + response = MagicMock() + response.status_code = 200 + response.json.return_value = models + return response + + +def _completion_response(model: str) -> MagicMock: + response = MagicMock() + response.status_code = 200 + response.json.return_value = { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1234567890, + "model": model, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hello"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + } + return response + + +def _responder(discovery: list[dict[str, Any]] | Exception, model: str = "test-model"): + """Answer by endpoint, so assertions don't depend on call ordering.""" + + async def respond(_method, endpoint, **_kwargs): + if "discovery" in str(endpoint): + if isinstance(discovery, Exception): + raise discovery + return _discovery_response(discovery) + return _completion_response(model) + + return respond + + +def _is_discovery(call) -> bool: + return "discovery" in str(call.args[1]) + + +def _discovery_calls(mock_request: MagicMock) -> list[Any]: + return [c for c in mock_request.call_args_list if _is_discovery(c)] + + +def _completion_body(mock_request: MagicMock) -> dict[str, Any]: + completions = [c for c in mock_request.call_args_list if not _is_discovery(c)] + assert completions, "no chat completion request was sent" + return completions[-1][1]["json"] + + +class TestSkipTemperatureFromDiscovery: + @pytest.fixture(autouse=True) + def clear_discovery_cache(self): + # The cache is process-wide; keep it from leaking in either direction. + _reset_cache() + yield + _reset_cache() + + @pytest.fixture + def config(self): + return UiPathApiConfig(base_url="https://example.com", secret="test_secret") + + @pytest.fixture + def execution_context(self): + return UiPathExecutionContext() + + @pytest.fixture + def llm_service(self, config, execution_context): + return UiPathLlmChatService(config=config, execution_context=execution_context) + + @patch( + "uipath.platform.chat._llm_gateway_service.UiPathLlmChatService.request_async" + ) + @pytest.mark.asyncio + async def test_omits_temperature_when_discovery_sets_flag( + self, mock_request, llm_service + ): + mock_request.side_effect = _responder( + SKIP_TEMPERATURE_DISCOVERY, SKIP_TEMPERATURE_MODEL + ) + + await llm_service.chat_completions( + messages=[{"role": "user", "content": "Hello"}], + model=SKIP_TEMPERATURE_MODEL, + temperature=0, + ) + + assert "temperature" not in _completion_body(mock_request) + + @patch( + "uipath.platform.chat._llm_gateway_service.UiPathLlmChatService.request_async" + ) + @pytest.mark.asyncio + async def test_keeps_temperature_when_flag_absent(self, mock_request, llm_service): + mock_request.side_effect = _responder( + [ + { + "modelName": "gpt-4o-2024-11-20", + "modelDetails": {"maxOutputTokens": 4096}, + } + ], + "gpt-4o-2024-11-20", + ) + + await llm_service.chat_completions( + messages=[{"role": "user", "content": "Hello"}], + model="gpt-4o-2024-11-20", + temperature=0.3, + ) + + assert _completion_body(mock_request)["temperature"] == 0.3 + + @patch( + "uipath.platform.chat._llm_gateway_service.UiPathLlmChatService.request_async" + ) + @pytest.mark.asyncio + async def test_keeps_temperature_for_model_missing_from_discovery( + self, mock_request, llm_service + ): + mock_request.side_effect = _responder( + SKIP_TEMPERATURE_DISCOVERY, "some-other-model" + ) + + await llm_service.chat_completions( + messages=[{"role": "user", "content": "Hello"}], + model="some-other-model", + temperature=0, + ) + + assert _completion_body(mock_request)["temperature"] == 0 + + @patch( + "uipath.platform.chat._llm_gateway_service.UiPathLlmChatService.request_async" + ) + @pytest.mark.asyncio + async def test_keeps_temperature_when_discovery_unavailable( + self, mock_request, llm_service + ): + """Discovery being down must not fail or silently alter the LLM call.""" + mock_request.side_effect = _responder( + RuntimeError("discovery unreachable"), SKIP_TEMPERATURE_MODEL + ) + + await llm_service.chat_completions( + messages=[{"role": "user", "content": "Hello"}], + model=SKIP_TEMPERATURE_MODEL, + temperature=0, + ) + + assert _completion_body(mock_request)["temperature"] == 0 + + @patch( + "uipath.platform.chat._llm_gateway_service.UiPathLlmChatService.request_async" + ) + @pytest.mark.asyncio + async def test_discovery_fetched_once_for_repeated_calls( + self, mock_request, llm_service + ): + mock_request.side_effect = _responder( + SKIP_TEMPERATURE_DISCOVERY, SKIP_TEMPERATURE_MODEL + ) + + for _ in range(2): + await llm_service.chat_completions( + messages=[{"role": "user", "content": "Hello"}], + model=SKIP_TEMPERATURE_MODEL, + temperature=0, + ) + + assert len(_discovery_calls(mock_request)) == 1 + assert "temperature" not in _completion_body(mock_request) + + @patch( + "uipath.platform.chat._llm_gateway_service.UiPathLlmChatService.request_async" + ) + @pytest.mark.asyncio + async def test_discovery_scoped_by_agenthub_config( + self, mock_request, config, execution_context + ): + service = UiPathLlmChatService( + config=config, + execution_context=execution_context, + agenthub_config="agentsevals", + ) + mock_request.side_effect = _responder([], SKIP_TEMPERATURE_MODEL) + + await service.chat_completions( + messages=[{"role": "user", "content": "Hello"}], + model=SKIP_TEMPERATURE_MODEL, + ) + + discovery_headers = _discovery_calls(mock_request)[0][1]["headers"] + assert discovery_headers["x-uipath-agenthub-config"] == "agentsevals" + + @patch( + "uipath.platform.chat._llm_gateway_service.UiPathOpenAIService.request_async" + ) + @pytest.mark.asyncio + async def test_openai_compatible_path_omits_temperature( + self, mock_request, config, execution_context + ): + service = UiPathOpenAIService( + config=config, execution_context=execution_context + ) + mock_request.side_effect = _responder( + SKIP_TEMPERATURE_DISCOVERY, SKIP_TEMPERATURE_MODEL + ) + + await service.chat_completions( + messages=[{"role": "user", "content": "Hello"}], + model=SKIP_TEMPERATURE_MODEL, + temperature=0, + ) + + assert "temperature" not in _completion_body(mock_request) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 6ba25b021..bb91f7c6f 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.13.18" +version = "2.13.19" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.30, <0.6.0", "uipath-runtime>=0.12.2, <0.13.0", - "uipath-platform>=0.2.4, <0.3.0", + "uipath-platform>=0.2.14, <0.3.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/tests/cli/eval/mocks/test_input_mocker.py b/packages/uipath/tests/cli/eval/mocks/test_input_mocker.py index a8a8a64ec..7abdf78b1 100644 --- a/packages/uipath/tests/cli/eval/mocks/test_input_mocker.py +++ b/packages/uipath/tests/cli/eval/mocks/test_input_mocker.py @@ -68,6 +68,13 @@ async def test_generate_llm_input_with_model_settings( json={}, ) + # Chat completions consults discovery to learn which parameters the model accepts. + httpx_mock.add_response( + url="https://example.com/llm/api/discovery", + status_code=200, + json=[{"modelName": "gpt-4o-mini-2024-07-18", "modelDetails": {}}], + ) + httpx_mock.add_response( url="https://example.com/llm/api/chat/completions" "?api-version=2024-08-01-preview", diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 9c6d369c2..fbd18e154 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.18" +version = "2.13.19" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From f694ebda69e0947e7a6a193f9537613b31446b1f Mon Sep 17 00:00:00 2001 From: Radu Mihai Gheorghe Date: Fri, 31 Jul 2026 14:09:53 +0300 Subject: [PATCH 183/191] Resolve A2A and MCP bindings by name (#1828) --- packages/uipath-platform/pyproject.toml | 2 +- .../platform/agenthub/_remote_a2a_service.py | 32 ++- .../platform/common/_resource_identifier.py | 12 ++ .../platform/orchestrator/_mcp_service.py | 38 ++-- .../tests/services/test_mcp_service.py | 131 +++++++++++- .../tests/services/test_remote_a2a_service.py | 188 +++++++++++++++++- packages/uipath-platform/uv.lock | 4 +- packages/uipath/pyproject.toml | 2 +- .../uipath/samples/list-mcp-agent/main.py | 4 +- packages/uipath/tests/sdk/test_bindings.py | 52 +++++ packages/uipath/uv.lock | 6 +- 11 files changed, 435 insertions(+), 36 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/common/_resource_identifier.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index d973884de..733852d36 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.14" +version = "0.2.15" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/agenthub/_remote_a2a_service.py b/packages/uipath-platform/src/uipath/platform/agenthub/_remote_a2a_service.py index c8993e3ee..037f4ff7d 100644 --- a/packages/uipath-platform/src/uipath/platform/agenthub/_remote_a2a_service.py +++ b/packages/uipath-platform/src/uipath/platform/agenthub/_remote_a2a_service.py @@ -7,6 +7,7 @@ import warnings from typing import Any, List +from urllib.parse import quote from ..common._base_service import BaseService from ..common._bindings import resource_override @@ -14,6 +15,7 @@ from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import FolderContext, header_folder from ..common._models import Endpoint, RequestSpec +from ..common._resource_identifier import resolve_retrieve_identifier from ..orchestrator import FolderService from .remote_a2a import RemoteA2aAgent @@ -150,20 +152,23 @@ async def main(): data = response.json() return [RemoteA2aAgent.model_validate(agent) for agent in data.get("value", [])] + @resource_override(resource_type="remoteA2aAgent", resource_identifier="name") @resource_override(resource_type="remoteA2aAgent", resource_identifier="slug") def retrieve( self, - slug: str, + slug: str | None = None, *, + name: str | None = None, folder_path: str | None = None, ) -> RemoteA2aAgent: - """Retrieve a specific Remote A2A agent by slug. + """Retrieve a Remote A2A agent by its display name or legacy slug. .. warning:: This method is experimental and subject to change. Args: - slug: The unique slug identifier for the agent. + slug: The legacy slug identifier of the agent. + name: The display name of the agent. folder_path: The folder path where the agent is located. Returns: @@ -183,7 +188,8 @@ def retrieve( "remote_a2a.retrieve is experimental and subject to change.", stacklevel=2, ) - spec = self._retrieve_spec(slug=slug, folder_path=folder_path) + identifier = resolve_retrieve_identifier(name=name, slug=slug) + spec = self._retrieve_spec(name=identifier, folder_path=folder_path) response = self.request( spec.method, url=spec.endpoint, @@ -192,20 +198,23 @@ def retrieve( ) return RemoteA2aAgent.model_validate(response.json()) + @resource_override(resource_type="remoteA2aAgent", resource_identifier="name") @resource_override(resource_type="remoteA2aAgent", resource_identifier="slug") async def retrieve_async( self, - slug: str, + slug: str | None = None, *, + name: str | None = None, folder_path: str | None = None, ) -> RemoteA2aAgent: - """Asynchronously retrieve a specific Remote A2A agent by slug. + """Asynchronously retrieve a Remote A2A agent by display name or legacy slug. .. warning:: This method is experimental and subject to change. Args: - slug: The unique slug identifier for the agent. + slug: The legacy slug identifier of the agent. + name: The display name of the agent. folder_path: The folder path where the agent is located. Returns: @@ -229,7 +238,8 @@ async def main(): "remote_a2a.retrieve_async is experimental and subject to change.", stacklevel=2, ) - spec = self._retrieve_spec(slug=slug, folder_path=folder_path) + identifier = resolve_retrieve_identifier(name=name, slug=slug) + spec = self._retrieve_spec(name=identifier, folder_path=folder_path) response = await self.request_async( spec.method, url=spec.endpoint, @@ -279,14 +289,16 @@ def _list_spec( def _retrieve_spec( self, - slug: str, + name: str, *, folder_path: str | None, ) -> RequestSpec: folder_key = self._resolve_folder_key(folder_path) return RequestSpec( method="GET", - endpoint=Endpoint(f"/agenthub_/api/remote-a2a-agents/{slug}"), + endpoint=Endpoint( + f"/agenthub_/api/remote-a2a-agents/{quote(name, safe='')}" + ), headers={ **header_folder(folder_key, None), }, diff --git a/packages/uipath-platform/src/uipath/platform/common/_resource_identifier.py b/packages/uipath-platform/src/uipath/platform/common/_resource_identifier.py new file mode 100644 index 000000000..3f90ae774 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/_resource_identifier.py @@ -0,0 +1,12 @@ +"""Shared retrieve-identifier resolution for name/slug resources (MCP servers and Remote A2A agents).""" + + +def resolve_retrieve_identifier(name: str | None, slug: str | None) -> str: + """Resolve a retrieve identifier, preferring the display name over the legacy slug.""" + if name is not None and slug is not None: + raise ValueError("Specify either 'name' or 'slug', not both.") + if name is not None: + return name + if slug is not None: + return slug + raise TypeError("Either 'name' or 'slug' must be provided.") diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_mcp_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_mcp_service.py index 195eb8240..e2a9f13c3 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_mcp_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_mcp_service.py @@ -1,4 +1,5 @@ from typing import List +from urllib.parse import quote from uipath.core.tracing import traced @@ -8,6 +9,7 @@ from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import FolderContext, header_folder from ..common._models import Endpoint, RequestSpec +from ..common._resource_identifier import resolve_retrieve_identifier from ._folder_service import FolderService from .mcp import McpServer @@ -66,7 +68,7 @@ def list( return [McpServer.model_validate(server) for server in response.json()] - @traced(name="mcp_list", run_type="uipath") + @traced(name="mcp_list_async", run_type="uipath") async def list_async( self, *, @@ -109,18 +111,21 @@ async def main(): return [McpServer.model_validate(server) for server in response.json()] + @resource_override(resource_type="mcpServer", resource_identifier="name") @resource_override(resource_type="mcpServer", resource_identifier="slug") @traced(name="mcp_retrieve", run_type="uipath") def retrieve( self, - slug: str, + slug: str | None = None, *, + name: str | None = None, folder_path: str | None = None, ) -> McpServer: - """Retrieve a specific MCP server by its slug. + """Retrieve a specific MCP server by its display name or legacy slug. Args: - slug (str): The unique slug identifier for the server. + slug (Optional[str]): The legacy slug identifier of the server. + name (Optional[str]): The display name of the server. folder_path (Optional[str]): The path of the folder where the server is located. Returns: @@ -132,12 +137,13 @@ def retrieve( client = UiPath() - server = client.mcp.retrieve(slug="my-server-slug", folder_path="MyFolder") + server = client.mcp.retrieve(name="My Server", folder_path="MyFolder") print(f"Server: {server.name}, URL: {server.mcp_url}") ``` """ + identifier = resolve_retrieve_identifier(name=name, slug=slug) spec = self._retrieve_spec( - slug=slug, + name=identifier, folder_path=folder_path, ) @@ -150,18 +156,21 @@ def retrieve( return McpServer.model_validate(response.json()) + @resource_override(resource_type="mcpServer", resource_identifier="name") @resource_override(resource_type="mcpServer", resource_identifier="slug") - @traced(name="mcp_retrieve", run_type="uipath") + @traced(name="mcp_retrieve_async", run_type="uipath") async def retrieve_async( self, - slug: str, + slug: str | None = None, *, + name: str | None = None, folder_path: str | None = None, ) -> McpServer: - """Asynchronously retrieve a specific MCP server by its slug. + """Asynchronously retrieve an MCP server by its display name or legacy slug. Args: - slug (str): The unique slug identifier for the server. + slug (Optional[str]): The legacy slug identifier of the server. + name (Optional[str]): The display name of the server. folder_path (Optional[str]): The path of the folder where the server is located. Returns: @@ -176,14 +185,15 @@ async def retrieve_async( sdk = UiPath() async def main(): - server = await sdk.mcp.retrieve_async(slug="my-server-slug", folder_path="MyFolder") + server = await sdk.mcp.retrieve_async(name="My Server", folder_path="MyFolder") print(f"Server: {server.name}, URL: {server.mcp_url}") asyncio.run(main()) ``` """ + identifier = resolve_retrieve_identifier(name=name, slug=slug) spec = self._retrieve_spec( - slug=slug, + name=identifier, folder_path=folder_path, ) @@ -223,14 +233,14 @@ def _list_spec( def _retrieve_spec( self, - slug: str, + name: str, *, folder_path: str | None, ) -> RequestSpec: folder_key = self._resolve_folder_key(folder_path) return RequestSpec( method="GET", - endpoint=Endpoint(f"/agenthub_/api/servers/{slug}"), + endpoint=Endpoint(f"/agenthub_/api/servers/{quote(name, safe='')}"), headers={ **header_folder(folder_key, None), }, diff --git a/packages/uipath-platform/tests/services/test_mcp_service.py b/packages/uipath-platform/tests/services/test_mcp_service.py index d83699f0c..002eb981f 100644 --- a/packages/uipath-platform/tests/services/test_mcp_service.py +++ b/packages/uipath-platform/tests/services/test_mcp_service.py @@ -1,9 +1,13 @@ -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest from pytest_httpx import HTTPXMock from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.common._bindings import ( + GenericResourceOverwrite, + _resource_overwrites, +) from uipath.platform.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT from uipath.platform.orchestrator import McpService from uipath.platform.orchestrator._folder_service import FolderService @@ -363,6 +367,125 @@ async def test_retrieve_server_async( == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.McpService.retrieve_async/{version}" ) + def test_retrieve_server_by_name(self, service: McpService) -> None: + response = Mock() + response.json.return_value = { + "name": "Friendly MCP/Europe", + "slug": "friendly-mcp-europe", + } + + with patch.object(service, "request", return_value=response) as request: + server = service.retrieve(name="Friendly MCP/Europe") + + assert server.name == "Friendly MCP/Europe" + assert "api/servers/Friendly%20MCP%2FEurope" in str( + request.call_args.kwargs["url"] + ) + + def test_retrieve_rejects_name_and_slug(self, service: McpService) -> None: + with pytest.raises( + ValueError, match="Specify either 'name' or 'slug', not both" + ): + service.retrieve("friendly-mcp", name="Friendly MCP") + + def test_retrieve_requires_name_or_slug(self, service: McpService) -> None: + with pytest.raises( + TypeError, match="Either 'name' or 'slug' must be provided" + ): + service.retrieve() + + def test_retrieve_applies_display_name_binding( + self, service: McpService + ) -> None: + response = Mock() + response.json.return_value = { + "name": "Replacement MCP", + "slug": "replacement-mcp", + } + overwrite = GenericResourceOverwrite( + resource_type="mcpServer", + name="Replacement MCP", + folder_path="Replacement Folder", + ) + token = _resource_overwrites.set({"mcpServer.Original MCP": overwrite}) + + try: + with ( + patch.object(service, "request", return_value=response) as request, + patch.object( + service._folders_service, + "retrieve_folder_key", + return_value="replacement-folder-key", + ), + ): + service.retrieve(name="Original MCP") + finally: + _resource_overwrites.reset(token) + + assert "api/servers/Replacement%20MCP" in str( + request.call_args.kwargs["url"] + ) + assert ( + request.call_args.kwargs["headers"][HEADER_FOLDER_KEY] + == "replacement-folder-key" + ) + + def test_retrieve_applies_legacy_slug_binding( + self, service: McpService + ) -> None: + response = Mock() + response.json.return_value = { + "name": "Replacement MCP", + "slug": "replacement-mcp", + } + overwrite = GenericResourceOverwrite( + resource_type="mcpServer", + name="Replacement MCP", + folder_path="Replacement Folder", + ) + token = _resource_overwrites.set({"mcpServer.original-mcp": overwrite}) + + try: + with ( + patch.object(service, "request", return_value=response) as request, + patch.object( + service._folders_service, + "retrieve_folder_key", + return_value="replacement-folder-key", + ), + ): + service.retrieve(slug="original-mcp") + finally: + _resource_overwrites.reset(token) + + assert "api/servers/Replacement%20MCP" in str( + request.call_args.kwargs["url"] + ) + assert ( + request.call_args.kwargs["headers"][HEADER_FOLDER_KEY] + == "replacement-folder-key" + ) + + @pytest.mark.anyio + async def test_retrieve_server_by_name_async(self, service: McpService) -> None: + response = Mock() + response.json.return_value = { + "name": "Friendly MCP/Europe", + "slug": "friendly-mcp-europe", + } + + with patch.object( + service, + "request_async", + new=AsyncMock(return_value=response), + ) as request: + server = await service.retrieve_async(name="Friendly MCP/Europe") + + assert server.name == "Friendly MCP/Europe" + assert "api/servers/Friendly%20MCP%2FEurope" in str( + request.call_args.kwargs["url"] + ) + class TestRequestKwargs: """Test that all methods pass the correct kwargs to request/request_async.""" @@ -569,3 +692,9 @@ def test_validate_swagger_server(self) -> None: ) assert server.type == 7 assert server.slug == "contoso-directory" + + +def test_mcp_retrieve_spec_encodes_display_name(service: McpService) -> None: + spec = service._retrieve_spec(name="Friendly MCP/Europe", folder_path=None) + + assert "api/servers/Friendly%20MCP%2FEurope" in str(spec.endpoint) diff --git a/packages/uipath-platform/tests/services/test_remote_a2a_service.py b/packages/uipath-platform/tests/services/test_remote_a2a_service.py index d5d611958..d6c56d6a2 100644 --- a/packages/uipath-platform/tests/services/test_remote_a2a_service.py +++ b/packages/uipath-platform/tests/services/test_remote_a2a_service.py @@ -1,7 +1,13 @@ +from unittest.mock import AsyncMock, Mock, patch + import pytest from uipath.platform import UiPathApiConfig, UiPathExecutionContext from uipath.platform.agenthub._remote_a2a_service import RemoteA2aService +from uipath.platform.common._bindings import ( + GenericResourceOverwrite, + _resource_overwrites, +) from uipath.platform.constants import HEADER_FOLDER_KEY from uipath.platform.orchestrator._folder_service import FolderService @@ -36,7 +42,7 @@ def test_falls_back_to_folder_context_when_folder_path_missing( self, service: RemoteA2aService ) -> None: """No folder_path (e.g. local debug) must not raise; it falls back to context.""" - spec = service._retrieve_spec(slug="weather", folder_path=None) + spec = service._retrieve_spec(name="weather", folder_path=None) assert "remote-a2a-agents/weather" in str(spec.endpoint) assert spec.headers[HEADER_FOLDER_KEY] == "context-folder-key" @@ -50,6 +56,184 @@ def test_resolves_explicit_folder_path( lambda folder_path: "resolved-folder-key", ) - spec = service._retrieve_spec(slug="weather", folder_path="MyFolder") + spec = service._retrieve_spec(name="weather", folder_path="MyFolder") assert spec.headers[HEADER_FOLDER_KEY] == "resolved-folder-key" + + def test_encodes_display_name_in_lookup_path( + self, service: RemoteA2aService + ) -> None: + spec = service._retrieve_spec(name="Friendly Agent/Europe", folder_path=None) + + assert "remote-a2a-agents/Friendly%20Agent%2FEurope" in str(spec.endpoint) + + +class TestRetrieveByName: + def test_retrieves_by_display_name(self, service: RemoteA2aService) -> None: + response = Mock() + response.json.return_value = { + "name": "Friendly Agent/Europe", + "slug": "friendly-agent-europe", + } + + with patch.object(service, "request", return_value=response) as request: + agent = service.retrieve(name="Friendly Agent/Europe") + + assert agent.name == "Friendly Agent/Europe" + assert "remote-a2a-agents/Friendly%20Agent%2FEurope" in str( + request.call_args.kwargs["url"] + ) + + def test_applies_display_name_binding(self, service: RemoteA2aService) -> None: + response = Mock() + response.json.return_value = { + "name": "Replacement Agent", + "slug": "replacement-agent", + } + overwrite = GenericResourceOverwrite( + resource_type="remoteA2aAgent", + name="Replacement Agent", + folder_path="Replacement Folder", + ) + token = _resource_overwrites.set({"remoteA2aAgent.Original Agent": overwrite}) + + try: + with ( + patch.object(service, "request", return_value=response) as request, + patch.object( + service._folders_service, + "retrieve_folder_key", + return_value="replacement-folder-key", + ), + ): + service.retrieve(name="Original Agent") + finally: + _resource_overwrites.reset(token) + + assert "remote-a2a-agents/Replacement%20Agent" in str( + request.call_args.kwargs["url"] + ) + assert ( + request.call_args.kwargs["headers"][HEADER_FOLDER_KEY] + == "replacement-folder-key" + ) + + @pytest.mark.anyio + async def test_retrieves_by_display_name_async( + self, service: RemoteA2aService + ) -> None: + response = Mock() + response.json.return_value = { + "name": "Friendly Agent/Europe", + "slug": "friendly-agent-europe", + } + + with patch.object( + service, + "request_async", + new=AsyncMock(return_value=response), + ) as request: + agent = await service.retrieve_async(name="Friendly Agent/Europe") + + assert agent.name == "Friendly Agent/Europe" + assert "remote-a2a-agents/Friendly%20Agent%2FEurope" in str( + request.call_args.kwargs["url"] + ) + + +class TestRetrieveCompatibility: + def test_retrieves_by_positional_slug(self, service: RemoteA2aService) -> None: + response = Mock() + response.json.return_value = { + "name": "Friendly Agent", + "slug": "friendly-agent", + } + + with patch.object(service, "request", return_value=response) as request: + agent = service.retrieve("friendly-agent") + + assert agent.slug == "friendly-agent" + assert "remote-a2a-agents/friendly-agent" in str( + request.call_args.kwargs["url"] + ) + + def test_retrieves_by_slug_keyword(self, service: RemoteA2aService) -> None: + response = Mock() + response.json.return_value = { + "name": "Friendly Agent", + "slug": "friendly-agent", + } + + with patch.object(service, "request", return_value=response) as request: + agent = service.retrieve(slug="friendly-agent") + + assert agent.slug == "friendly-agent" + assert "remote-a2a-agents/friendly-agent" in str( + request.call_args.kwargs["url"] + ) + + def test_applies_legacy_slug_binding(self, service: RemoteA2aService) -> None: + response = Mock() + response.json.return_value = { + "name": "Replacement Agent", + "slug": "replacement-agent", + } + overwrite = GenericResourceOverwrite( + resource_type="remoteA2aAgent", + name="Replacement Agent", + folder_path="Replacement Folder", + ) + token = _resource_overwrites.set({"remoteA2aAgent.original-agent": overwrite}) + + try: + with ( + patch.object(service, "request", return_value=response) as request, + patch.object( + service._folders_service, + "retrieve_folder_key", + return_value="replacement-folder-key", + ), + ): + service.retrieve("original-agent") + finally: + _resource_overwrites.reset(token) + + assert "remote-a2a-agents/Replacement%20Agent" in str( + request.call_args.kwargs["url"] + ) + assert ( + request.call_args.kwargs["headers"][HEADER_FOLDER_KEY] + == "replacement-folder-key" + ) + + @pytest.mark.anyio + async def test_retrieves_by_positional_slug_async( + self, service: RemoteA2aService + ) -> None: + response = Mock() + response.json.return_value = { + "name": "Friendly Agent", + "slug": "friendly-agent", + } + + with patch.object( + service, + "request_async", + new=AsyncMock(return_value=response), + ) as request: + agent = await service.retrieve_async("friendly-agent") + + assert agent.slug == "friendly-agent" + assert "remote-a2a-agents/friendly-agent" in str( + request.call_args.kwargs["url"] + ) + + def test_rejects_name_and_slug(self, service: RemoteA2aService) -> None: + with pytest.raises( + ValueError, match="Specify either 'name' or 'slug', not both" + ): + service.retrieve("friendly-agent", name="Friendly Agent") + + def test_requires_name_or_slug(self, service: RemoteA2aService) -> None: + with pytest.raises(TypeError, match="Either 'name' or 'slug' must be provided"): + service.retrieve() diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 80451fb6f..4aa68776a 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-07-29T07:23:36.9681123Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.14" +version = "0.2.15" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index bb91f7c6f..e700e252a 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.13.19" +version = "2.13.20" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/samples/list-mcp-agent/main.py b/packages/uipath/samples/list-mcp-agent/main.py index 2d71c7697..d1c78b989 100644 --- a/packages/uipath/samples/list-mcp-agent/main.py +++ b/packages/uipath/samples/list-mcp-agent/main.py @@ -24,9 +24,9 @@ def list_mcp_servers() -> list[McpServer]: return uipath.mcp.list(folder_path="Shared") -def retrieve_mcp_server(slug: str) -> McpServer: +def retrieve_mcp_server(name: str) -> McpServer: uipath = UiPath() - return uipath.mcp.retrieve(slug, folder_path="Shared") + return uipath.mcp.retrieve(name, folder_path="Shared") async def connect_and_list_tools(server: McpServer) -> list[str]: diff --git a/packages/uipath/tests/sdk/test_bindings.py b/packages/uipath/tests/sdk/test_bindings.py index 1d51d7413..c581cb2ac 100644 --- a/packages/uipath/tests/sdk/test_bindings.py +++ b/packages/uipath/tests/sdk/test_bindings.py @@ -131,6 +131,58 @@ def dummy_func(name, folder_path): _resource_overwrites.reset(token) +class TestStackedResourceOverrideDecorators: + @pytest.mark.anyio + @pytest.mark.parametrize( + ("arguments", "expected"), + [ + ( + {"name": "original-resource"}, + (None, "replacement-resource", "replacement-folder"), + ), + ( + {"slug": "original-resource"}, + ("replacement-resource", None, "replacement-folder"), + ), + ], + ids=["outer-name-decorator", "inner-slug-decorator"], + ) + async def test_decorators_apply_override_for_active_identifier( + self, + arguments, + expected, + ): + """The outer name decorator must not prevent the inner slug override.""" + overwrite = GenericResourceOverwrite( + resource_type="mcpServer", + name="replacement-resource", + folder_path="replacement-folder", + ) + + @resource_override(resource_type="mcpServer", resource_identifier="name") + @resource_override(resource_type="mcpServer", resource_identifier="slug") + def retrieve(slug=None, *, name=None, folder_path=None): + return slug, name, folder_path + + @resource_override( + resource_type="mcpServer", + resource_identifier="name", + ) + @resource_override( + resource_type="mcpServer", + resource_identifier="slug", + ) + async def retrieve_async(slug=None, *, name=None, folder_path=None): + return slug, name, folder_path + + token = _resource_overwrites.set({"mcpServer.original-resource": overwrite}) + try: + assert retrieve(**arguments) == expected + assert await retrieve_async(**arguments) == expected + finally: + _resource_overwrites.reset(token) + + class TestResourceOverwritesContext: """Test that ResourceOverwritesContext works correctly with infer_bindings.""" diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index fbd18e154..55a1152c2 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-07-29T07:23:41.8582141Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.19" +version = "2.13.20" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2760,7 +2760,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.14" +version = "0.2.15" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From 06f5403cca4cb330d5b6e5e5f36e7931d27692c5 Mon Sep 17 00:00:00 2001 From: Eduard Dumitru Date: Fri, 31 Jul 2026 17:10:16 +0200 Subject: [PATCH 184/191] feat(cli): carry ResumeVersion and ForceStop on the IPC job DTOs [ROBO-5779] (#1838) --- packages/uipath/pyproject.toml | 2 +- packages/uipath/src/uipath/_cli/cli_server.py | 10 +- .../uipath/src/uipath/_cli/cli_server_ipc.py | 63 ++++---- packages/uipath/tests/cli/test_server_ipc.py | 139 ++++++++++++++++-- packages/uipath/uv.lock | 2 +- 5 files changed, 164 insertions(+), 52 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index e700e252a..b59c5e95b 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.13.20" +version = "2.13.21" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/cli_server.py b/packages/uipath/src/uipath/_cli/cli_server.py index 6c020dca7..d822bdd8c 100644 --- a/packages/uipath/src/uipath/_cli/cli_server.py +++ b/packages/uipath/src/uipath/_cli/cli_server.py @@ -22,18 +22,20 @@ from ._utils._console import ConsoleLogger from .cli_server_ipc import ( IPythonRuntimeServer, - PythonRunRequest, - PythonRunResult, PythonRuntimeService, + RunJobRequest, + RunJobResult, + StopJobRequest, start_ipc_server, ) __all__ = [ "server", "IPythonRuntimeServer", - "PythonRunRequest", - "PythonRunResult", "PythonRuntimeService", + "RunJobRequest", + "RunJobResult", + "StopJobRequest", "start_ipc_server", ] diff --git a/packages/uipath/src/uipath/_cli/cli_server_ipc.py b/packages/uipath/src/uipath/_cli/cli_server_ipc.py index 24ebd100c..11c461ae6 100644 --- a/packages/uipath/src/uipath/_cli/cli_server_ipc.py +++ b/packages/uipath/src/uipath/_cli/cli_server_ipc.py @@ -1,15 +1,3 @@ -"""uipath-ipc runtime transport — the IPC contract, DTOs, and service. - -The PascalCase method and field names are dictated by the .NET/CoreIpc peer -(the serializer maps them verbatim), so Sonar's S100/S116 naming rules are -suppressed for this file only (see ``sonar-project.properties``). - -``uipath-ipc`` is an optional dependency (the ``ipc`` extra): it is imported -lazily inside ``start_ipc_server`` so this module — and HTTP-only serving — -works without it. The DTOs and the contract below are pure stdlib and never -reference it. -""" - from abc import ABC, abstractmethod from dataclasses import dataclass, field @@ -19,13 +7,18 @@ console = ConsoleLogger() +def _run_id(job_key: str, resume_version: int | None) -> str: + return job_key if resume_version is None else f"{job_key}-{resume_version}" + + @dataclass -class PythonRunRequest: - """Mirrors the .NET PythonRunRequest DTO. PascalCase fields match the wire keys.""" +class RunJobRequest: + """PascalCase fields match the wire keys.""" JobKey: str = "" + ResumeVersion: int | None = None Command: str = "" - # The .NET peer sends a single string; HTTP callers and tests may pass a + # The peer sends a single string; HTTP callers and tests may pass a # pre-split list. parse_args accepts both. Args: str | list[str] | None = None WorkingDirectory: str | None = None @@ -33,51 +26,59 @@ class PythonRunRequest: @dataclass -class PythonRunResult: - """Mirrors the .NET PythonRunResult DTO.""" +class StopJobRequest: + JobKey: str = "" + ResumeVersion: int | None = None + ForceStop: bool = False + +@dataclass +class RunJobResult: ExitCode: int = 0 Error: str | None = None class IPythonRuntimeServer(ABC): - """Contract the .NET job executor calls over uipath-ipc.""" + """Contract the job executor calls over uipath-ipc.""" @abstractmethod - async def StartJob(self, request: PythonRunRequest) -> PythonRunResult: - """Run a job → PythonRunResult(ExitCode, Error).""" + async def RunJob(self, request: RunJobRequest) -> RunJobResult: + """Run a job → RunJobResult(ExitCode, Error).""" @abstractmethod - async def StopJob(self, job_key: str) -> bool: + async def StopJob(self, request: StopJobRequest) -> bool: """Cancel a running job by key (bool return avoids fire-and-forget).""" class PythonRuntimeService(IPythonRuntimeServer): """``IPythonRuntimeServer`` implementation backed by run/debug/eval.""" - async def StartJob(self, request: PythonRunRequest) -> PythonRunResult: + async def RunJob(self, request: RunJobRequest) -> RunJobResult: command_name = request.Command if not isinstance(command_name, str) or not command_name: - return PythonRunResult( - ExitCode=1, Error="Missing or invalid field: 'Command'" - ) + return RunJobResult(ExitCode=1, Error="Missing or invalid field: 'Command'") cmd = COMMANDS.get(command_name) if cmd is None: - return PythonRunResult(ExitCode=1, Error=f"Unknown command: {command_name}") + return RunJobResult(ExitCode=1, Error=f"Unknown command: {command_name}") args = parse_args(request.Args) - console.info(f"Starting job {request.JobKey}: {command_name} {args}") + console.info( + f"Running job {_run_id(request.JobKey, request.ResumeVersion)}: {command_name} {args}" + ) result = await _run_command_isolated( cmd, args, request.EnvironmentVariables, request.WorkingDirectory ) - # IPC contract (PythonRunResult) carries only ExitCode + Error. - return PythonRunResult(ExitCode=result["ExitCode"], Error=result["Error"]) + # IPC contract (RunJobResult) carries only ExitCode + Error. + return RunJobResult(ExitCode=result["ExitCode"], Error=result["Error"]) - async def StopJob(self, job_key: str) -> bool: - console.info(f"StopJob requested for {job_key} (no-op)") + async def StopJob(self, request: StopJobRequest) -> bool: + console.info( + f"StopJob requested for {_run_id(request.JobKey, request.ResumeVersion)} " + f"(force={request.ForceStop}) (no-op)" + ) return True diff --git a/packages/uipath/tests/cli/test_server_ipc.py b/packages/uipath/tests/cli/test_server_ipc.py index efcd76a19..43fe313c9 100644 --- a/packages/uipath/tests/cli/test_server_ipc.py +++ b/packages/uipath/tests/cli/test_server_ipc.py @@ -1,12 +1,12 @@ """Tests for the uipath-ipc runtime server channel. -The server hosts ``IPythonRuntimeServer`` (StartJob / StopJob) on a named pipe +The server hosts ``IPythonRuntimeServer`` (RunJob / StopJob) on a named pipe alongside the HTTP channel when ``--ipc-pipe`` names one (see ``test_server_transport.py`` for the channel composition). Mirrors ``test_server.py`` (the HTTP path) but drives the pipe with a Python ``uipath-ipc`` client. -Requires ``uipath-ipc`` to be installed. ``StartJob`` success runs the real +Requires ``uipath-ipc`` to be installed. ``RunJob`` success runs the real runtime (like ``test_server.test_start_job_success``); the rest exercise the IPC wiring and env isolation without it. """ @@ -21,11 +21,17 @@ import click import pytest -from uipath_ipc import IpcClient, NamedPipeClientTransport +from uipath_ipc import ( + IpcClient, + IpcServer, + NamedPipeClientTransport, + NamedPipeServerTransport, +) from uipath._cli import _server_core from uipath._cli.cli_server import ( IPythonRuntimeServer, + RunJobResult, start_ipc_server, ) @@ -81,7 +87,14 @@ def _wait_until_ready(pipe_name: str, timeout: float = 10.0) -> None: last_err: Exception | None = None while time.monotonic() < deadline: try: - asyncio.run(_with_proxy(pipe_name, lambda p: p.StopJob("readiness-probe"))) + asyncio.run( + _with_proxy( + pipe_name, + lambda p: p.StopJob( + {"JobKey": "00000000-0000-0000-0000-000000000000"} + ), + ) + ) return except Exception as e: # server not accepting connections yet last_err = e @@ -127,7 +140,7 @@ def pipe(self): # the process exits (mirrors test_server.py's background HTTP server). yield pipe_name - def test_start_job_success(self, pipe, temp_dir): + def test_run_job_success(self, pipe, temp_dir): """A real 'run' job executes and writes output.json (needs the runtime).""" script_file = "entrypoint.py" with open(os.path.join(temp_dir, script_file), "w") as f: @@ -147,7 +160,7 @@ def test_start_job_success(self, pipe, temp_dir): "WorkingDirectory": temp_dir, "EnvironmentVariables": {}, } - result = asyncio.run(_with_proxy(pipe, lambda p: p.StartJob(request))) + result = asyncio.run(_with_proxy(pipe, lambda p: p.RunJob(request))) assert result.ExitCode == 0 assert result.Error is None @@ -155,23 +168,45 @@ def test_start_job_success(self, pipe, temp_dir): with open(output_file, "r") as f: assert "Hello" in f.read() - def test_start_job_unknown_command(self, pipe): + def test_run_job_unknown_command(self, pipe): request = {"JobKey": "job-1", "Command": "does_not_exist"} - result = asyncio.run(_with_proxy(pipe, lambda p: p.StartJob(request))) + result = asyncio.run(_with_proxy(pipe, lambda p: p.RunJob(request))) assert result.ExitCode != 0 assert "Unknown command" in (result.Error or "") - def test_start_job_missing_command(self, pipe): + def test_run_job_missing_command(self, pipe): """Absent/empty Command is rejected before the job core is touched.""" - result = asyncio.run( - _with_proxy(pipe, lambda p: p.StartJob({"JobKey": "job-1"})) - ) + result = asyncio.run(_with_proxy(pipe, lambda p: p.RunJob({"JobKey": "job-1"}))) assert result.ExitCode != 0 assert "Command" in (result.Error or "") + def test_run_job_accepts_resume_version(self, pipe): + request = { + "JobKey": "3f2504e0-4f89-11d3-9a0c-0305e82c3301", + "ResumeVersion": 4, + "Command": "does_not_exist", + } + result = asyncio.run(_with_proxy(pipe, lambda p: p.RunJob(request))) + + assert "Unknown command" in (result.Error or "") + + def test_stop_job_accepts_resume_version_and_force_stop(self, pipe): + request = { + "JobKey": "3f2504e0-4f89-11d3-9a0c-0305e82c3301", + "ResumeVersion": 2, + "ForceStop": True, + } + result = asyncio.run(_with_proxy(pipe, lambda p: p.StopJob(request))) + + assert result is True + def test_stop_job_returns_true(self, pipe): """StopJob is a no-op stub today, but must ack (bool) so the call is awaitable.""" - result = asyncio.run(_with_proxy(pipe, lambda p: p.StopJob("job-1"))) + result = asyncio.run( + _with_proxy( + pipe, lambda p: p.StopJob({"JobKey": "job-1", "ForceStop": True}) + ) + ) assert result is True @@ -201,14 +236,14 @@ def test_env_vars_do_not_leak_between_jobs(self, pipe_with_spy): pipe_name, env_snapshots = pipe_with_spy async def run_two(proxy: Any) -> None: - await proxy.StartJob( + await proxy.RunJob( { "JobKey": "job-1", "Command": "spy", "EnvironmentVariables": {"TEST_VAR_A": "a"}, } ) - await proxy.StartJob( + await proxy.RunJob( { "JobKey": "job-2", "Command": "spy", @@ -224,3 +259,77 @@ async def run_two(proxy: Any) -> None: assert "TEST_VAR_B" not in run1 assert run2["TEST_VAR_B"] == "b" assert "TEST_VAR_A" not in run2 + + +class TestIpcContractFieldTransit: + @staticmethod + def _serve_spy(pipe_name: str, service: IPythonRuntimeServer) -> None: + def run_server() -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + async def main() -> None: + server = IpcServer( + transport=NamedPipeServerTransport(pipe_name), + services={IPythonRuntimeServer: service}, + request_timeout=None, + ) + async with server: + await server.serve_forever() + + try: + loop.run_until_complete(main()) + except asyncio.CancelledError: + pass + finally: + loop.close() + + threading.Thread(target=run_server, daemon=True).start() + _wait_until_ready(pipe_name) + + def test_all_wire_fields_arrive_intact(self): + received: list[Any] = [] + + class SpyService(IPythonRuntimeServer): + async def RunJob(self, request: Any) -> RunJobResult: + received.append(request) + return RunJobResult(ExitCode=0) + + async def StopJob(self, request: Any) -> bool: + received.append(request) + return True + + pipe = _unique_pipe() + self._serve_spy(pipe, SpyService()) + received.clear() # drop the readiness probe's StopJob + + job_key = "3f2504e0-4f89-11d3-9a0c-0305e82c3301" + + async def drive(proxy: Any) -> None: + await proxy.RunJob( + { + "JobKey": job_key, + "ResumeVersion": 5, + "Command": "run", + "Args": "main --input-file in.json", + "WorkingDirectory": "/tmp/wd", + "EnvironmentVariables": {"A": "1"}, + } + ) + await proxy.StopJob( + {"JobKey": job_key, "ResumeVersion": 5, "ForceStop": True} + ) + + asyncio.run(_with_proxy(pipe, drive)) + + run_request, stop_request = received + assert run_request.JobKey == job_key + assert run_request.ResumeVersion == 5 + assert run_request.Command == "run" + assert run_request.Args == "main --input-file in.json" + assert run_request.WorkingDirectory == "/tmp/wd" + assert run_request.EnvironmentVariables == {"A": "1"} + + assert stop_request.JobKey == job_key + assert stop_request.ResumeVersion == 5 + assert stop_request.ForceStop is True diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 55a1152c2..0356dc28d 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.20" +version = "2.13.21" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From d55c0d730940491bbc4d6d78dcdfc7bd23217bb9 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Fri, 31 Jul 2026 11:23:49 -0700 Subject: [PATCH 185/191] test(voice): pin output envelope passthrough in session end detail (#1794) Co-authored-by: Claude Fable 5 --- .../tests/cli/chat/test_voice_bridge.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/uipath/tests/cli/chat/test_voice_bridge.py b/packages/uipath/tests/cli/chat/test_voice_bridge.py index 663175199..c4e704922 100644 --- a/packages/uipath/tests/cli/chat/test_voice_bridge.py +++ b/packages/uipath/tests/cli/chat/test_voice_bridge.py @@ -72,6 +72,27 @@ async def test_session_ended_preserves_payload_opaquely(self) -> None: assert session.end_detail["callContext"]["id"] == "CA123" assert session.end_detail["endedBy"] == "agent" + async def test_session_ended_preserves_output_envelope(self) -> None: + """The voice outputs envelope must reach the job runtime untouched.""" + session = _make_session() + envelope = { + "fields": {"caller_name": "Ada", "callback_requested": None}, + "status": "extracted", + "extracted": True, + } + payload = { + "callEnded": True, + "endedBy": "agent", + "reason": "agent_completed", + "endToolCalled": True, + "output": envelope, + } + + await session._handle_session_ended(payload) + + assert session.end_detail["output"] == envelope + assert session.end_detail["endToolCalled"] is True + async def test_session_ended_non_dict_payload_is_empty_detail(self) -> None: session = _make_session() await session._handle_session_ended("not-a-dict") From 21e21a9f5c77799a399ebace716287cab049e84f Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Mon, 3 Aug 2026 14:31:24 +0300 Subject: [PATCH 186/191] fix: categorize missing HITL index as deployment (#1839) --- packages/uipath-platform/pyproject.toml | 2 +- .../platform/resume_triggers/_protocol.py | 92 +++++++++++-------- .../tests/services/test_hitl.py | 69 ++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 5 files changed, 127 insertions(+), 40 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 733852d36..f74c2bb87 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.15" +version = "0.2.16" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py b/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py index 784e9f2ae..97dd5731a 100644 --- a/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py +++ b/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py @@ -60,6 +60,7 @@ from uipath.platform.errors import ( BatchTransformFailedException, BatchTransformNotCompleteException, + ContextGroundingIndexNotFoundError, OperationNotCompleteException, ) from uipath.platform.orchestrator.job import JobState @@ -544,6 +545,8 @@ async def create_trigger(self, suspend_value: Any) -> UiPathResumeTrigger: f"Unexpected model received" f"{type(suspend_value)} is not a valid Human-In-The-Loop model", ) + except UiPathFaultedTriggerError: + raise except Exception as e: raise UiPathFaultedTriggerError( ErrorCategory.SYSTEM, @@ -697,28 +700,35 @@ async def _handle_deep_rag_job_trigger( resume_trigger.item_key = value.deep_rag.id elif isinstance(value, CreateDeepRag): uipath = UiPath() - if value.is_ephemeral_index: - deep_rag = ( - await uipath.context_grounding.start_deep_rag_ephemeral_async( + try: + if value.is_ephemeral_index: + deep_rag = ( + await uipath.context_grounding.start_deep_rag_ephemeral_async( + name=value.name, + index_id=value.index_id, + prompt=value.prompt, + glob_pattern=value.glob_pattern, + citation_mode=value.citation_mode, + ) + ) + else: + deep_rag = await uipath.context_grounding.start_deep_rag_async( name=value.name, + index_name=value.index_name, index_id=value.index_id, prompt=value.prompt, glob_pattern=value.glob_pattern, citation_mode=value.citation_mode, + folder_path=value.index_folder_path, + folder_key=value.index_folder_key, ) - ) - - else: - deep_rag = await uipath.context_grounding.start_deep_rag_async( - name=value.name, - index_name=value.index_name, - index_id=value.index_id, - prompt=value.prompt, - glob_pattern=value.glob_pattern, - citation_mode=value.citation_mode, - folder_path=value.index_folder_path, - folder_key=value.index_folder_key, - ) + except ContextGroundingIndexNotFoundError as e: + raise UiPathFaultedTriggerError( + ErrorCategory.DEPLOYMENT, + "Context grounding index not found. Check that the index is " + "deployed and available in the configured folder.", + str(e), + ) from e if not deep_rag: raise Exception("Failed to start deep rag") @@ -778,27 +788,35 @@ async def _handle_batch_rag_job_trigger( resume_trigger.item_key = value.batch_transform.id elif isinstance(value, CreateBatchTransform): uipath = UiPath() - if value.is_ephemeral_index: - batch_transform = await uipath.context_grounding.start_batch_transform_ephemeral_async( - name=value.name, - index_id=value.index_id, - prompt=value.prompt, - output_columns=value.output_columns, - storage_bucket_folder_path_prefix=value.storage_bucket_folder_path_prefix, - enable_web_search_grounding=value.enable_web_search_grounding, - ) - else: - batch_transform = await uipath.context_grounding.start_batch_transform_async( - name=value.name, - index_name=value.index_name, - index_id=value.index_id, - prompt=value.prompt, - output_columns=value.output_columns, - storage_bucket_folder_path_prefix=value.storage_bucket_folder_path_prefix, - enable_web_search_grounding=value.enable_web_search_grounding, - folder_path=value.index_folder_path, - folder_key=value.index_folder_key, - ) + try: + if value.is_ephemeral_index: + batch_transform = await uipath.context_grounding.start_batch_transform_ephemeral_async( + name=value.name, + index_id=value.index_id, + prompt=value.prompt, + output_columns=value.output_columns, + storage_bucket_folder_path_prefix=value.storage_bucket_folder_path_prefix, + enable_web_search_grounding=value.enable_web_search_grounding, + ) + else: + batch_transform = await uipath.context_grounding.start_batch_transform_async( + name=value.name, + index_name=value.index_name, + index_id=value.index_id, + prompt=value.prompt, + output_columns=value.output_columns, + storage_bucket_folder_path_prefix=value.storage_bucket_folder_path_prefix, + enable_web_search_grounding=value.enable_web_search_grounding, + folder_path=value.index_folder_path, + folder_key=value.index_folder_key, + ) + except ContextGroundingIndexNotFoundError as e: + raise UiPathFaultedTriggerError( + ErrorCategory.DEPLOYMENT, + "Context grounding index not found. Check that the index is " + "deployed and available in the configured folder.", + str(e), + ) from e if not batch_transform: raise Exception("Failed to start batch transform") diff --git a/packages/uipath-platform/tests/services/test_hitl.py b/packages/uipath-platform/tests/services/test_hitl.py index 134d75fc0..f0c463251 100644 --- a/packages/uipath-platform/tests/services/test_hitl.py +++ b/packages/uipath-platform/tests/services/test_hitl.py @@ -67,6 +67,7 @@ StartExtractionValidationResponse, ValidateExtractionAction, ) +from uipath.platform.errors import ContextGroundingIndexNotFoundError from uipath.platform.orchestrator import Job, JobErrorInfo from uipath.platform.orchestrator.job import JobState from uipath.platform.resume_triggers import ( @@ -1705,6 +1706,38 @@ async def test_create_resume_trigger_create_deep_rag( folder_key=create_deep_rag.index_folder_key, ) + @pytest.mark.anyio + async def test_missing_deep_rag_index_is_deployment_error( + self, + setup_test_env: None, + ) -> None: + create_deep_rag = CreateDeepRag( + name="test-deep-rag", + index_name="Files", + prompt="test prompt", + glob_pattern="**/*.pdf", + citation_mode=CitationMode.INLINE, + index_folder_path="/test/path", + ) + missing_index = ContextGroundingIndexNotFoundError("Files") + mock_start_deep_rag = AsyncMock(side_effect=missing_index) + + with patch( + "uipath.platform.context_grounding._context_grounding_service.ContextGroundingService.start_deep_rag_async", + new=mock_start_deep_rag, + ): + with pytest.raises(UiPathFaultedTriggerError) as exc_info: + await UiPathResumeTriggerCreator().create_trigger(create_deep_rag) + + error = exc_info.value + assert error.category == ErrorCategory.DEPLOYMENT + assert error.message == ( + "Context grounding index not found. Check that the index is deployed and " + "available in the configured folder." + ) + assert error.detail == "ContextGroundingIndex 'Files' not found" + assert error.__cause__ is missing_index + @pytest.mark.anyio async def test_create_resume_trigger_wait_deep_rag( self, @@ -1832,6 +1865,42 @@ async def test_create_resume_trigger_create_batch_transform( folder_key=create_batch_transform.index_folder_key, ) + @pytest.mark.anyio + async def test_missing_batch_transform_index_is_deployment_error( + self, + setup_test_env: None, + ) -> None: + create_batch_transform = CreateBatchTransform( + name="test-batch-transform", + index_name="Files", + prompt="test prompt", + output_columns=[ + BatchTransformOutputColumn(name="column1", description="desc1") + ], + destination_path="/output/path.xlsx", + index_folder_path="/test/path", + ) + missing_index = ContextGroundingIndexNotFoundError("Files") + mock_start_batch_transform = AsyncMock(side_effect=missing_index) + + with patch( + "uipath.platform.context_grounding._context_grounding_service.ContextGroundingService.start_batch_transform_async", + new=mock_start_batch_transform, + ): + with pytest.raises(UiPathFaultedTriggerError) as exc_info: + await UiPathResumeTriggerCreator().create_trigger( + create_batch_transform + ) + + error = exc_info.value + assert error.category == ErrorCategory.DEPLOYMENT + assert error.message == ( + "Context grounding index not found. Check that the index is deployed and " + "available in the configured folder." + ) + assert error.detail == "ContextGroundingIndex 'Files' not found" + assert error.__cause__ is missing_index + @pytest.mark.anyio async def test_create_resume_trigger_wait_batch_transform( self, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 4aa68776a..f5331a448 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.15" +version = "0.2.16" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 0356dc28d..208b7064f 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2760,7 +2760,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.15" +version = "0.2.16" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From c58d485246b9b28c6356df35396643f4faccbd08 Mon Sep 17 00:00:00 2001 From: Andrei Petraru Date: Mon, 3 Aug 2026 22:00:37 +0300 Subject: [PATCH 187/191] feat(guardrails): resolve BYOG by validator name only [AL-510] (#1840) Co-authored-by: Claude Opus 5 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../guardrails/_guardrails_service.py | 2 - .../guardrails/decorators/validators/byo.py | 24 +++----- .../uipath/platform/guardrails/guardrails.py | 1 - .../services/test_guardrails_decorators.py | 26 +++------ .../tests/services/test_guardrails_service.py | 57 +++++++++++-------- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 8 files changed, 52 insertions(+), 64 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index f74c2bb87..eab210d34 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.16" +version = "0.2.17" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py index fbeb1f027..b73d810e7 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py @@ -127,8 +127,6 @@ def evaluate_guardrail( "BYO (Bring Your Own) guardrails require byo_validator_name." ) payload["byoValidatorName"] = guardrail.byo_validator_name - if guardrail.byo_connection_id: - payload["byoConnectionId"] = guardrail.byo_connection_id spec = RequestSpec( method="POST", endpoint=Endpoint("/agentsruntime_/api/execution/guardrails/validate"), diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/byo.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/byo.py index b89543cde..425a84654 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/byo.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/byo.py @@ -19,13 +19,13 @@ class ByoValidator(BuiltInGuardrailValidator): Azure Content Safety subscription, a vendor connector, or a custom Integration Service connector) into UiPath guardrails. An admin first creates the configuration under ``Admin -> AI Trust Layer -> Guardrails - Configurations``; this validator references it by its validator name and - (recommended) Integration Service connection id. + Configurations``; this validator references it purely by its validator + name, which is unique per tenant. The Integration Service connection to + use is resolved server-side from the configuration, so an admin rebind is + always honored. Supported at all stages — BYO validator capabilities are connector-defined and cannot be known statically, so no stage restriction is applied here. - Configuring a scope or stage the connector does not support surfaces as a - ``PROVIDER_ERROR`` at evaluation time. Example:: @@ -35,10 +35,7 @@ class ByoValidator(BuiltInGuardrailValidator): guardrail, ) - byog_harmful_content = ByoValidator( - "byog-harmful-content", - connection_id="24887687-6ed1-4fe2-9b87-087ffb232682", - ) + byog_harmful_content = ByoValidator("my-harmful-content-guardrail") @guardrail(validator=byog_harmful_content, action=BlockAction()) def summarize(text: str) -> str: @@ -47,11 +44,7 @@ def summarize(text: str) -> str: Args: validator_name: The BYOG configuration's validator name (``byoValidatorName``), as shown in Admin -> AI Trust Layer -> - Guardrails Configurations. - connection_id: Optional Integration Service connection id backing the - BYOG configuration. Strongly recommended: validator names are only - unique per connection, so omitting it lets the server pick the - first configuration matching the name. + Guardrails Configurations. Unique per tenant. parameters: Optional list of validator parameters. BYO parameter schemas are connector-defined, so values are passed through as-is. @@ -63,14 +56,12 @@ def __init__( self, validator_name: str, *, - connection_id: str | None = None, parameters: Sequence[ValidatorParameter] | None = None, ) -> None: """Initialize ByoValidator with a BYOG configuration reference.""" if not validator_name or not validator_name.strip(): raise ValueError("validator_name must be a non-empty string") self.validator_name = validator_name - self.connection_id = connection_id self.parameters = list(parameters or []) def get_built_in_guardrail( @@ -88,7 +79,7 @@ def get_built_in_guardrail( Returns: Configured :class:`BuiltInValidatorGuardrail` referencing the BYOG - configuration via ``byoValidatorName``/``byoConnectionId``. + configuration via ``byoValidatorName``. """ return BuiltInValidatorGuardrail( id=str(uuid4()), @@ -100,5 +91,4 @@ def get_built_in_guardrail( validator_type=BYO_VALIDATOR_TYPE, validator_parameters=self.parameters, byo_validator_name=self.validator_name, - byo_connection_id=self.connection_id, ) diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py b/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py index c7a968a61..dace18019 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py @@ -92,7 +92,6 @@ class BuiltInValidatorGuardrail(BaseGuardrail): default_factory=list, alias="validatorParameters" ) byo_validator_name: str | None = Field(default=None, alias="byoValidatorName") - byo_connection_id: str | None = Field(default=None, alias="byoConnectionId") model_config = ConfigDict(populate_by_name=True, extra="allow") diff --git a/packages/uipath-platform/tests/services/test_guardrails_decorators.py b/packages/uipath-platform/tests/services/test_guardrails_decorators.py index 3d75ba2c3..c6cfee2ad 100644 --- a/packages/uipath-platform/tests/services/test_guardrails_decorators.py +++ b/packages/uipath-platform/tests/services/test_guardrails_decorators.py @@ -1253,29 +1253,22 @@ def test_whitespace_validator_name_raises(self): with pytest.raises(ValueError, match="validator_name"): ByoValidator(" ") - def test_builds_byo_guardrail_with_name_and_connection(self): - v = ByoValidator( - "byog-harmful-content", - connection_id="24887687-6ed1-4fe2-9b87-087ffb232682", - ) + def test_builds_byo_guardrail_from_name_alone(self): + v = ByoValidator("my-harmful-content-guardrail") g = v.get_built_in_guardrail("G", None, True) assert g.validator_type == "byo" - assert g.byo_validator_name == "byog-harmful-content" - assert g.byo_connection_id == "24887687-6ed1-4fe2-9b87-087ffb232682" - - def test_connection_id_defaults_to_none(self): - v = ByoValidator("byog-harmful-content") - g = v.get_built_in_guardrail("G", None, True) - assert g.byo_connection_id is None + assert g.byo_validator_name == "my-harmful-content-guardrail" def test_aliases_serialize_for_the_wire(self): - v = ByoValidator("byog-pii", connection_id="conn-1") + v = ByoValidator("byog-pii") g = v.get_built_in_guardrail("G", None, True) dumped = g.model_dump(by_alias=True) assert dumped["validatorType"] == "byo" assert dumped["byoValidatorName"] == "byog-pii" - assert dumped["byoConnectionId"] == "conn-1" assert dumped["$guardrailType"] == "builtInValidator" + # BYOG resolves by validator name alone (unique per tenant); no + # connection id exists on the wire model. + assert "byoConnectionId" not in dumped def test_parameters_pass_through(self): from uipath.platform.guardrails.guardrails import NumberParameterValue @@ -1308,7 +1301,7 @@ def test_selector_is_none(self): assert g.selector is None def test_run_forwards_byo_guardrail_to_service(self): - v = ByoValidator("byog-harmful-content", connection_id="conn-1") + v = ByoValidator("my-harmful-content-guardrail") mock_uipath = MagicMock() mock_uipath.guardrails.evaluate_guardrail.return_value = ( GuardrailValidationResult( @@ -1323,5 +1316,4 @@ def test_run_forwards_byo_guardrail_to_service(self): data, g = mock_uipath.guardrails.evaluate_guardrail.call_args[0] assert data == "some input" assert g.validator_type == "byo" - assert g.byo_validator_name == "byog-harmful-content" - assert g.byo_connection_id == "conn-1" + assert g.byo_validator_name == "my-harmful-content-guardrail" diff --git a/packages/uipath-platform/tests/services/test_guardrails_service.py b/packages/uipath-platform/tests/services/test_guardrails_service.py index c94bf8be3..d20d531a7 100644 --- a/packages/uipath-platform/tests/services/test_guardrails_service.py +++ b/packages/uipath-platform/tests/services/test_guardrails_service.py @@ -350,7 +350,7 @@ def capture_request(request): assert request_payload["byoValidatorName"] == "my_databricks_pii" assert result.result == GuardrailValidationResultType.PASSED - def test_evaluate_guardrail_byog_forwards_byo_connection_id( + def test_evaluate_guardrail_byog_never_forwards_legacy_connection_id( self, httpx_mock: HTTPXMock, service: GuardrailsService, @@ -358,8 +358,10 @@ def test_evaluate_guardrail_byog_forwards_byo_connection_id( org: str, tenant: str, ) -> None: - """A BYOG guardrail forwards byoConnectionId so the backend can narrow - configuration resolution to the specific connection.""" + """A legacy byoConnectionId (from persisted json or an older caller) + is never forwarded: BYOG resolves by validator name alone, which is + unique per tenant; the connection comes from the configuration + server-side.""" captured_request = None def capture_request(request): @@ -376,16 +378,18 @@ def capture_request(request): callback=capture_request, ) - byog_guardrail = BuiltInValidatorGuardrail( - id="byog-id", - name="Databricks PII (BYOG)", - enabled_for_evals=True, - selector=GuardrailSelector(scopes=[GuardrailScope.LLM]), - guardrail_type="builtInValidator", - validator_type="byo", - byo_validator_name="my_databricks_pii", - byo_connection_id="byog-conn-1", - validator_parameters=[], + byog_guardrail = BuiltInValidatorGuardrail.model_validate( + { + "$guardrailType": "builtInValidator", + "id": "byog-id", + "name": "Databricks PII (BYOG)", + "enabledForEvals": True, + "selector": {"scopes": ["Llm"]}, + "validatorType": "byo", + "byoValidatorName": "my_databricks_pii", + "byoConnectionId": "byog-conn-1", + "validatorParameters": [], + } ) service.evaluate_guardrail("some input", byog_guardrail) @@ -393,9 +397,9 @@ def capture_request(request): assert captured_request is not None request_payload = json.loads(captured_request.content) assert request_payload["byoValidatorName"] == "my_databricks_pii" - assert request_payload["byoConnectionId"] == "byog-conn-1" + assert "byoConnectionId" not in request_payload - def test_evaluate_guardrail_byog_omits_connection_id_when_absent( + def test_evaluate_guardrail_byog_resolves_by_name_alone( self, httpx_mock: HTTPXMock, service: GuardrailsService, @@ -403,8 +407,8 @@ def test_evaluate_guardrail_byog_omits_connection_id_when_absent( org: str, tenant: str, ) -> None: - """byoConnectionId is only forwarded when present; a BYOG guardrail without - one resolves by validator name alone.""" + """A BYOG guardrail carries only byoValidatorName; no connection id + appears in the payload.""" captured_request = None def capture_request(request): @@ -438,8 +442,11 @@ def capture_request(request): request_payload = json.loads(captured_request.content) assert "byoConnectionId" not in request_payload - def test_evaluate_guardrail_byo_connection_id_from_alias(self) -> None: - """byoConnectionId parses into the typed field via its camelCase alias.""" + def test_evaluate_guardrail_legacy_byo_connection_id_still_parses( + self, + ) -> None: + """Legacy json carrying byoConnectionId still parses (extra="allow"), + but the value is not a typed field anymore.""" guardrail = BuiltInValidatorGuardrail.model_validate( { "$guardrailType": "builtInValidator", @@ -451,7 +458,8 @@ def test_evaluate_guardrail_byo_connection_id_from_alias(self) -> None: "validatorParameters": [], } ) - assert guardrail.byo_connection_id == "byog-conn-1" + assert guardrail.byo_validator_name == "my_databricks_pii" + assert "byo_connection_id" not in type(guardrail).model_fields def test_evaluate_guardrail_byo_without_name_raises( self, @@ -529,7 +537,7 @@ def capture_request(request): request_payload = json.loads(captured_request.content) assert "byoValidatorName" not in request_payload - def test_evaluate_guardrail_non_byo_type_does_not_forward_connection_id( + def test_evaluate_guardrail_non_byo_type_never_forwards_byo_fields( self, httpx_mock: HTTPXMock, service: GuardrailsService, @@ -537,8 +545,8 @@ def test_evaluate_guardrail_non_byo_type_does_not_forward_connection_id( org: str, tenant: str, ) -> None: - """byoConnectionId is only forwarded for the "byo" sentinel, never leaked - into a non-BYOG validator payload even if the field happens to be set.""" + """BYO fields are only forwarded for the "byo" sentinel, never leaked + into a non-BYOG validator payload even if a name happens to be set.""" captured_request = None def capture_request(request): @@ -562,7 +570,7 @@ def capture_request(request): selector=GuardrailSelector(scopes=[GuardrailScope.LLM]), guardrail_type="builtInValidator", validator_type="pii_detection", - byo_connection_id="stray-conn", + byo_validator_name="stray-name", validator_parameters=[], ) @@ -570,6 +578,7 @@ def capture_request(request): assert captured_request is not None request_payload = json.loads(captured_request.content) + assert "byoValidatorName" not in request_payload assert "byoConnectionId" not in request_payload def test_evaluate_guardrail_ootb_omits_byo_validator_name( diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index f5331a448..2e506b0af 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.16" +version = "0.2.17" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 208b7064f..3eba8c870 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2760,7 +2760,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.16" +version = "0.2.17" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From c9b2428ad04b10422dd9addeadc129334ba79a6b Mon Sep 17 00:00:00 2001 From: AAgnihotry <95259907+AAgnihotry@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:17:10 -0700 Subject: [PATCH 188/191] feat(cli): add --simulation flag to debug command (#1846) Co-authored-by: Claude Opus 4.6 (1M context) --- packages/uipath/pyproject.toml | 2 +- .../samples/simulate-component-agent/main.py | 3 + packages/uipath/src/uipath/_cli/cli_debug.py | 49 +++- .../debug-simulation-testcase/pyproject.toml | 12 + .../debug-simulation-testcase/run.sh | 30 +++ .../debug-simulation-testcase/src/assert.py | 57 +++++ .../uipath/tests/cli/test_debug_simulation.py | 219 ++++++++++++++++++ packages/uipath/uv.lock | 4 +- 8 files changed, 363 insertions(+), 13 deletions(-) create mode 100644 packages/uipath/testcases/debug-simulation-testcase/pyproject.toml create mode 100755 packages/uipath/testcases/debug-simulation-testcase/run.sh create mode 100644 packages/uipath/testcases/debug-simulation-testcase/src/assert.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index b59c5e95b..ffc945cef 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.13.21" +version = "2.13.22" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/samples/simulate-component-agent/main.py b/packages/uipath/samples/simulate-component-agent/main.py index 0acd8f0cb..499e92c92 100644 --- a/packages/uipath/samples/simulate-component-agent/main.py +++ b/packages/uipath/samples/simulate-component-agent/main.py @@ -9,6 +9,9 @@ Run with per-component simulation (routes each tool call to the API): uipath run main -f input.json --simulation "$(cat simulation.json)" + +Debug with per-component simulation: + uipath debug main -f input.json --simulation "$(cat simulation.json)" """ from pydantic import BaseModel diff --git a/packages/uipath/src/uipath/_cli/cli_debug.py b/packages/uipath/src/uipath/_cli/cli_debug.py index 7f542e871..7d7eceba2 100644 --- a/packages/uipath/src/uipath/_cli/cli_debug.py +++ b/packages/uipath/src/uipath/_cli/cli_debug.py @@ -3,13 +3,14 @@ from typing import Any, cast, get_args import click +from pydantic import ValidationError from uipath._cli._chat._bridge import get_chat_bridge from uipath._cli._debug._bridge import DebugAttachMode, get_debug_bridge from uipath._cli._utils._debug import setup_debugging from uipath._cli._utils._studio_project import StudioClient from uipath._cli._utils._tracing import create_trace_manager -from uipath.eval.mocks import UiPathMockRuntime +from uipath.eval.mocks import SimulationConfig, UiPathMockRuntime, build_mocking_context from uipath.eval.mocks._mock_runtime import load_simulation_config from uipath.platform.common import ( ExecutionSourceContext, @@ -79,6 +80,12 @@ "'console' for local runs." ), ) +@click.option( + "--simulation", + required=False, + default=None, + help="Simulation config as a JSON object (same schema as simulation.json)", +) @track_command("debug") def debug( entrypoint: str | None, @@ -90,6 +97,7 @@ def debug( debug: bool, debug_port: int, attach: str | None, + simulation: str | None, ) -> None: """Debug the project.""" input_file = file or input_file @@ -97,6 +105,14 @@ def debug( if not setup_debugging(debug, debug_port): console.error(f"Failed to start debug server on port {debug_port}") + simulation_config: SimulationConfig | None = None + if simulation: + try: + simulation_config = SimulationConfig.model_validate_json(simulation) + except (ValidationError, ValueError) as e: + console.error(f"Invalid --simulation config: {e}") + return + attach_mode: DebugAttachMode | None = ( cast(DebugAttachMode, attach.lower()) if attach else None ) @@ -223,22 +239,35 @@ async def execute_debug_runtime(): if schema.metadata and "settings" in schema.metadata: agent_model = schema.metadata["settings"].get("model") - mocking_context = load_simulation_config( - agent_model=agent_model - ) - - mock_runtime = UiPathMockRuntime( - delegate=debug_runtime, - mocking_context=mocking_context, + delegate_runtime: UiPathDebugRuntime | UiPathMockRuntime = ( + debug_runtime ) + if simulation_config: + mocking_context = build_mocking_context( + simulation_config, agent_model + ) + if mocking_context: + delegate_runtime = UiPathMockRuntime( + delegate=debug_runtime, + mocking_context=mocking_context, + ) + else: + mocking_context = load_simulation_config( + agent_model=agent_model + ) + delegate_runtime = UiPathMockRuntime( + delegate=debug_runtime, + mocking_context=mocking_context, + ) try: - ctx.result = await mock_runtime.execute( + ctx.result = await delegate_runtime.execute( ctx.get_input(), options=UiPathExecuteOptions(resume=resume), ) finally: - await mock_runtime.dispose() + if delegate_runtime is not debug_runtime: + await delegate_runtime.dispose() await debug_runtime.dispose() if chat_runtime: await chat_runtime.dispose() diff --git a/packages/uipath/testcases/debug-simulation-testcase/pyproject.toml b/packages/uipath/testcases/debug-simulation-testcase/pyproject.toml new file mode 100644 index 000000000..e20c1b585 --- /dev/null +++ b/packages/uipath/testcases/debug-simulation-testcase/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "debug-simulation-testcase" +version = "0.0.1" +description = "debug-simulation-testcase" +authors = [{ name = "UiPath", email = "python-sdk@uipath.com" }] +dependencies = [ + "uipath", +] +requires-python = ">=3.11" + +[tool.uv.sources] +uipath = { path = "../../", editable = true } diff --git a/packages/uipath/testcases/debug-simulation-testcase/run.sh b/packages/uipath/testcases/debug-simulation-testcase/run.sh new file mode 100755 index 000000000..7880fa101 --- /dev/null +++ b/packages/uipath/testcases/debug-simulation-testcase/run.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -euo pipefail + +TESTCASE_DIR="$(cd "$(dirname "$0")" && pwd)" +SAMPLE_DIR="$(cd "$TESTCASE_DIR/../../samples/runtime-simulations-agent" && pwd)" + +echo "Syncing testcase dependencies (local editable uipath)..." +uv sync --project "$TESTCASE_DIR" + +UIPATH_BIN="$TESTCASE_DIR/.venv/bin/uipath" + +# Run auth and agent from the sample dir so credentials are stored and read +# from the same location. +cd "$SAMPLE_DIR" + +echo "Authenticating with UiPath..." +"$UIPATH_BIN" auth \ + --client-id="$CLIENT_ID" \ + --client-secret="$CLIENT_SECRET" \ + --base-url="$BASE_URL" + +echo "Running agent with debug + simulation..." +"$UIPATH_BIN" debug main \ + -f input.json \ + --attach none \ + --simulation "$(cat simulation.json)" 2>&1 | tee "$TESTCASE_DIR/run.log" + +# Copy the runtime output file back to the testcase dir for assert.py +mkdir -p "$TESTCASE_DIR/__uipath" +cp "$SAMPLE_DIR/__uipath/output.json" "$TESTCASE_DIR/__uipath/output.json" diff --git a/packages/uipath/testcases/debug-simulation-testcase/src/assert.py b/packages/uipath/testcases/debug-simulation-testcase/src/assert.py new file mode 100644 index 000000000..fd7e89697 --- /dev/null +++ b/packages/uipath/testcases/debug-simulation-testcase/src/assert.py @@ -0,0 +1,57 @@ +import json +import os + +# ── 1. Verify agent output exists and succeeded ────────────────────────────── +output_file = "__uipath/output.json" +assert os.path.isfile(output_file), "Agent output file not found" + +with open(output_file, "r", encoding="utf-8") as f: + output_data = json.load(f) + +status = output_data.get("status") +assert status == "successful", f"Agent execution failed with status: {status}" + +output = output_data.get("output", {}) + +assert "syntax" in output, "Missing 'syntax' in output" +assert "style" in output, "Missing 'style' in output" +assert "improvements" in output, "Missing 'improvements' in output" +assert "summary" in output, "Missing 'summary' in output" + +assert isinstance(output["syntax"]["valid"], bool), "'syntax.valid' must be a bool" +assert isinstance(output["syntax"]["errors"], list), "'syntax.errors' must be a list" + +score = output["style"]["score"] +assert isinstance(score, int), "'style.score' must be an int" +assert 0 <= score <= 100, f"'style.score' out of range: {score}" +assert isinstance(output["style"]["violations"], list), ( + "'style.violations' must be a list" +) + +assert isinstance(output["improvements"]["suggestions"], list), ( + "'improvements.suggestions' must be a list" +) +assert isinstance(output["improvements"]["refactored_snippet"], str), ( + "'improvements.refactored_snippet' must be a str" +) + +# ── 2. Verify simulation produced non-default values ───────────────────────── +# Real tool impls always return: score=100, violations=[], suggestions=[]. +# The LLM simulation should detect issues in the input code and return richer output. +simulated_something = ( + score < 100 + or len(output["style"]["violations"]) > 0 + or len(output["improvements"]["suggestions"]) > 0 +) +assert simulated_something, ( + "Output matches hardcoded real-tool defaults — simulation may not have run. " + f"style.score={score}, violations={output['style']['violations']}, " + f"suggestions={output['improvements']['suggestions']}" +) + +print( + f"Simulation confirmed: score={score}, " + f"violations={len(output['style']['violations'])}, " + f"suggestions={len(output['improvements']['suggestions'])}" +) +print("All assertions passed.") diff --git a/packages/uipath/tests/cli/test_debug_simulation.py b/packages/uipath/tests/cli/test_debug_simulation.py index 9e66a1a24..9185d98c6 100644 --- a/packages/uipath/tests/cli/test_debug_simulation.py +++ b/packages/uipath/tests/cli/test_debug_simulation.py @@ -368,6 +368,225 @@ def test_simulation_config_enables_tool_mocking( # Clean up clear_execution_context() + +_SIMULATION_JSON = { + "enabled": True, + "toolsToSimulate": [{"name": "check_syntax"}, {"name": "check_style"}], + "instructions": "Simulate.", +} + +_COMPONENT_SIMULATION_JSON = { + "enabled": True, + "components": [ + { + "componentId": "get_current_weather", + "componentType": "tool", + "simulationStrategy": 0, + "simulationInstruction": "Return realistic weather data", + }, + { + "componentId": "get_forecast", + "componentType": "tool", + "simulationStrategy": 0, + "simulationInstruction": "Return a multi-day forecast", + }, + ], +} + + +class TestDebugSimulationFlag: + """Tests for the --simulation flag on the debug command.""" + + def _make_debug_patches(self): + """Create common mock objects for debug command tests.""" + mock_runtime = Mock() + mock_runtime.dispose = AsyncMock() + mock_runtime.get_schema = AsyncMock(return_value=Mock(metadata=None)) + + mock_factory = Mock() + mock_factory.new_runtime = AsyncMock(return_value=mock_runtime) + mock_factory.get_settings = AsyncMock(return_value=Mock(trace_settings=None)) + mock_factory.dispose = AsyncMock() + + mock_debug_runtime = Mock() + mock_debug_runtime.dispose = AsyncMock() + + return mock_factory, mock_runtime, mock_debug_runtime + + def test_invalid_simulation_json_exits_with_error( + self, runner: CliRunner, temp_dir: str + ): + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as f: + json.dump({"functions": {"main": "main.py:main"}}, f) + with open("main.py", "w") as f: + f.write("async def main(input): return {}") + + result = runner.invoke( + cli, ["debug", "main", "--simulation", "{ not valid json }"] + ) + assert result.exit_code == 1 + assert "Invalid" in result.output + + def test_simulation_flag_wraps_runtime_with_mock_runtime( + self, runner: CliRunner, temp_dir: str + ): + mock_factory, mock_runtime, mock_debug_runtime = self._make_debug_patches() + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as f: + json.dump({"functions": {"main": "main.py:main"}}, f) + with open("main.py", "w") as f: + f.write("async def main(input): return {}") + + with ( + patch( + "uipath._cli.cli_debug.Middlewares.next", + return_value=MiddlewareResult( + should_continue=True, + info_message=None, + error_message=None, + should_include_stacktrace=False, + ), + ), + patch( + "uipath._cli.cli_debug.UiPathRuntimeFactoryRegistry.get", + return_value=mock_factory, + ), + patch("uipath._cli.cli_debug.get_debug_bridge"), + patch( + "uipath._cli.cli_debug.UiPathDebugRuntime", + return_value=mock_debug_runtime, + ), + patch("uipath._cli.cli_debug.UiPathMockRuntime") as mock_cls, + ): + mock_cls.return_value = Mock( + execute=AsyncMock( + return_value=Mock(status="SUCCESSFUL", output={}) + ), + dispose=AsyncMock(), + ) + runner.invoke( + cli, + [ + "debug", + "main", + "{}", + "--simulation", + json.dumps(_SIMULATION_JSON), + ], + ) + + assert mock_cls.called + assert mock_cls.call_args.kwargs["mocking_context"] is not None + assert mock_cls.call_args.kwargs["delegate"] is mock_debug_runtime + + def test_simulation_flag_disabled_does_not_wrap_runtime( + self, runner: CliRunner, temp_dir: str + ): + mock_factory, mock_runtime, mock_debug_runtime = self._make_debug_patches() + disabled = {**_SIMULATION_JSON, "enabled": False} + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as f: + json.dump({"functions": {"main": "main.py:main"}}, f) + with open("main.py", "w") as f: + f.write("async def main(input): return {}") + + with ( + patch( + "uipath._cli.cli_debug.Middlewares.next", + return_value=MiddlewareResult( + should_continue=True, + info_message=None, + error_message=None, + should_include_stacktrace=False, + ), + ), + patch( + "uipath._cli.cli_debug.UiPathRuntimeFactoryRegistry.get", + return_value=mock_factory, + ), + patch("uipath._cli.cli_debug.get_debug_bridge"), + patch( + "uipath._cli.cli_debug.UiPathDebugRuntime", + return_value=mock_debug_runtime, + ), + patch("uipath._cli.cli_debug.UiPathMockRuntime") as mock_cls, + patch( + "uipath._cli.cli_debug.load_simulation_config", + return_value=None, + ), + ): + mock_debug_runtime.execute = AsyncMock( + return_value=Mock(status="SUCCESSFUL", output={}) + ) + runner.invoke( + cli, + ["debug", "main", "{}", "--simulation", json.dumps(disabled)], + ) + + assert not mock_cls.called + + def test_simulation_flag_with_component_format( + self, runner: CliRunner, temp_dir: str + ): + """Test that --simulation with new component format sets components on MockingContext.""" + mock_factory, mock_runtime, mock_debug_runtime = self._make_debug_patches() + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as f: + json.dump({"functions": {"main": "main.py:main"}}, f) + with open("main.py", "w") as f: + f.write("async def main(input): return {}") + + with ( + patch( + "uipath._cli.cli_debug.Middlewares.next", + return_value=MiddlewareResult( + should_continue=True, + info_message=None, + error_message=None, + should_include_stacktrace=False, + ), + ), + patch( + "uipath._cli.cli_debug.UiPathRuntimeFactoryRegistry.get", + return_value=mock_factory, + ), + patch("uipath._cli.cli_debug.get_debug_bridge"), + patch( + "uipath._cli.cli_debug.UiPathDebugRuntime", + return_value=mock_debug_runtime, + ), + patch("uipath._cli.cli_debug.UiPathMockRuntime") as mock_cls, + ): + mock_cls.return_value = Mock( + execute=AsyncMock( + return_value=Mock(status="SUCCESSFUL", output={}) + ), + dispose=AsyncMock(), + ) + runner.invoke( + cli, + [ + "debug", + "main", + "{}", + "--simulation", + json.dumps(_COMPONENT_SIMULATION_JSON), + ], + ) + + assert mock_cls.called + mocking_context = mock_cls.call_args.kwargs["mocking_context"] + assert mocking_context is not None + assert mocking_context.components is not None + assert len(mocking_context.components) == 2 + assert mocking_context.components[0].component_id == "get_current_weather" + assert mocking_context.components[1].component_id == "get_forecast" + assert mocking_context.strategy is None + def test_middleware_short_circuits_before_mock_runtime( self, runner: CliRunner, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 3eba8c870..eca6b2c62 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-07-29T07:23:41.8582141Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.21" +version = "2.13.22" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 94e43708ffc53c4697a8b855c1a00b0a0b753720 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Thu, 6 Aug 2026 14:59:49 +0300 Subject: [PATCH 189/191] feat: emit conversation meta events (#1843) --- packages/uipath/pyproject.toml | 4 +- .../uipath/src/uipath/_cli/_chat/_bridge.py | 57 +++++--- .../testcases/langchain-cross/pyproject.toml | 7 +- packages/uipath/tests/cli/chat/test_bridge.py | 125 ++++++++++++++++++ packages/uipath/uv.lock | 10 +- 5 files changed, 177 insertions(+), 26 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index ffc945cef..675a9d735 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "uipath" -version = "2.13.22" +version = "2.14.0" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.30, <0.6.0", - "uipath-runtime>=0.12.2, <0.13.0", + "uipath-runtime>=0.13.0, <0.14.0", "uipath-platform>=0.2.14, <0.3.0", "click>=8.3.1", "httpx>=0.28.1", diff --git a/packages/uipath/src/uipath/_cli/_chat/_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_bridge.py index bdf620caa..397b7454e 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_bridge.py @@ -273,6 +273,14 @@ async def disconnect(self) -> None: finally: await self._cleanup_client() + def _require_client(self) -> Any: + client = self._client + if client is None: + raise RuntimeError("WebSocket client not connected. Call connect() first.") + if not self._connected_event.is_set() and not self._websocket_disabled: + raise RuntimeError("WebSocket client not in connected state") + return client + async def emit_message_event( self, message_event: UiPathConversationMessageEvent ) -> None: @@ -284,11 +292,7 @@ async def emit_message_event( Raises: RuntimeError: If client is not connected """ - if self._client is None: - raise RuntimeError("WebSocket client not connected. Call connect() first.") - - if not self._connected_event.is_set() and not self._websocket_disabled: - raise RuntimeError("WebSocket client not in connected state") + client = self._require_client() try: # Wrap message event with conversation/exchange IDs @@ -309,7 +313,7 @@ async def emit_message_event( f"SocketIOChatBridge is in debug mode. Not sending event: {json.dumps(event_data)}" ) else: - await self._client.emit("ConversationEvent", event_data) + await client.emit("ConversationEvent", event_data) # Store the current message ID, used for emitting interrupt events. self._current_message_id = message_event.message_id @@ -318,6 +322,31 @@ async def emit_message_event( logger.error(f"Error sending conversation event to WebSocket: {e}") raise RuntimeError(f"Failed to send conversation event: {e}") from e + async def emit_meta_event(self, meta_event: dict[str, Any]) -> None: + """Send an exchange-scoped conversation metadata event.""" + client = self._require_client() + + try: + event = UiPathConversationEvent( + conversation_id=self.conversation_id, + exchange=UiPathConversationExchangeEvent( + exchange_id=self.exchange_id, + meta_event=meta_event, + ), + ) + event_data = event.model_dump(mode="json", exclude_none=True, by_alias=True) + + if self._websocket_disabled: + logger.info( + "SocketIOChatBridge is in debug mode. Not sending event: %s", + json.dumps(event_data), + ) + else: + await client.emit("ConversationEvent", event_data) + except Exception as e: + logger.error(f"Error sending conversation event to WebSocket: {e}") + raise RuntimeError(f"Failed to send conversation event: {e}") from e + async def emit_exchange_end_event(self) -> None: """Send an exchange end event. @@ -331,11 +360,7 @@ async def emit_exchange_end_event(self) -> None: logger.info("end_exchange is False; leaving the exchange open.") return - if self._client is None: - raise RuntimeError("WebSocket client not connected. Call connect() first.") - - if not self._connected_event.is_set() and not self._websocket_disabled: - raise RuntimeError("WebSocket client not in connected state") + client = self._require_client() try: exchange_end_event = UiPathConversationEvent( @@ -355,7 +380,7 @@ async def emit_exchange_end_event(self) -> None: f"SocketIOChatBridge is in debug mode. Not sending event: {json.dumps(event_data)}" ) else: - await self._client.emit("ConversationEvent", event_data) + await client.emit("ConversationEvent", event_data) except Exception as e: logger.error(f"Error sending conversation event to WebSocket: {e}") @@ -371,11 +396,7 @@ async def emit_exchange_error_event(self, error: Exception) -> None: Args: error: The exception that caused the error. """ - if self._client is None: - raise RuntimeError("WebSocket client not connected. Call connect() first.") - - if not self._connected_event.is_set() and not self._websocket_disabled: - raise RuntimeError("WebSocket client not in connected state") + client = self._require_client() # Extract and map error to CAS-specific error ID and message. cas_error_id, cas_message = _resolve_cas_error(error) @@ -403,7 +424,7 @@ async def emit_exchange_error_event(self, error: Exception) -> None: f"SocketIOChatBridge is in debug mode. Not sending event: {json.dumps(event_data)}" ) else: - await self._client.emit("ConversationEvent", event_data) + await client.emit("ConversationEvent", event_data) except Exception as e: logger.error(f"Error sending exchange error event to WebSocket: {e}") diff --git a/packages/uipath/testcases/langchain-cross/pyproject.toml b/packages/uipath/testcases/langchain-cross/pyproject.toml index 153ea57d8..58c3c38d2 100644 --- a/packages/uipath/testcases/langchain-cross/pyproject.toml +++ b/packages/uipath/testcases/langchain-cross/pyproject.toml @@ -18,4 +18,9 @@ uipath-platform = { path = "../../../uipath-platform", editable = true } # the published uipath-langchain package. Mirrors the uv override used in the # cross-repo test workflows. [tool.uv] -override-dependencies = ["uipath", "uipath-core", "uipath-platform"] +override-dependencies = [ + "uipath", + "uipath-core", + "uipath-platform", + "uipath-runtime>=0.13.0,<0.14.0", +] diff --git a/packages/uipath/tests/cli/chat/test_bridge.py b/packages/uipath/tests/cli/chat/test_bridge.py index 6c67bc6a9..2c4aedbe9 100644 --- a/packages/uipath/tests/cli/chat/test_bridge.py +++ b/packages/uipath/tests/cli/chat/test_bridge.py @@ -10,6 +10,7 @@ from uipath._cli._chat._bridge import SocketIOChatBridge, get_chat_bridge from uipath._cli._debug._bridge import SignalRDebugBridge +from uipath.core.chat import UiPathConversationMessageEvent from uipath.core.triggers import UiPathApiTrigger, UiPathResumeTrigger from uipath.platform.constants import ( HEADER_INTERNAL_ACCOUNT_ID, @@ -362,6 +363,130 @@ async def test_emit_exchange_end_raises_without_client(self) -> None: assert "not connected" in str(exc_info.value).lower() + @pytest.mark.anyio + async def test_emit_message_event_sends_when_connected(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._client = AsyncMock() + bridge._connected_event.set() + + await bridge.emit_message_event( + UiPathConversationMessageEvent(message_id="msg-123") + ) + + bridge._client.emit.assert_awaited_once() + + @pytest.mark.anyio + async def test_emit_exchange_error_event_sends_when_connected(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._client = AsyncMock() + bridge._connected_event.set() + + await bridge.emit_exchange_error_event(ValueError("failed")) + + bridge._client.emit.assert_awaited_once() + + @pytest.mark.anyio + async def test_emit_meta_event_sends_exchange_scoped_event(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._client = AsyncMock() + bridge._connected_event.set() + + await bridge.emit_meta_event( + {"workspaceFiles": [{"path": "plan.md", "attachmentKey": "key-1"}]} + ) + + bridge._client.emit.assert_awaited_once_with( + "ConversationEvent", + { + "conversationId": "conv-123", + "exchange": { + "exchangeId": "exch-456", + "metaEvent": { + "workspaceFiles": [ + {"path": "plan.md", "attachmentKey": "key-1"} + ] + }, + }, + }, + ) + + @pytest.mark.anyio + async def test_emit_meta_event_requires_client(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + + with pytest.raises(RuntimeError, match="not connected"): + await bridge.emit_meta_event({}) + + @pytest.mark.anyio + async def test_emit_meta_event_requires_connected_client(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._client = AsyncMock() + + with pytest.raises(RuntimeError, match="not in connected state"): + await bridge.emit_meta_event({}) + + @pytest.mark.anyio + async def test_emit_meta_event_does_not_send_in_debug_mode(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._client = AsyncMock() + bridge._websocket_disabled = True + + await bridge.emit_meta_event({"workspaceFiles": []}) + + bridge._client.emit.assert_not_awaited() + + @pytest.mark.anyio + async def test_emit_meta_event_wraps_send_error(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._client = AsyncMock() + bridge._client.emit.side_effect = ValueError("socket failed") + bridge._connected_event.set() + + with pytest.raises(RuntimeError, match="Failed to send conversation event"): + await bridge.emit_meta_event({}) + class TestSocketIOChatBridgeEndExchange: """The bridge owns whether to honor the exchange-end event (CAS-specific).""" diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index eca6b2c62..d94a3297d 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.22" +version = "2.14.0" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2682,7 +2682,7 @@ requires-dist = [ { name = "uipath-core", editable = "../uipath-core" }, { name = "uipath-ipc", marker = "extra == 'ipc'", specifier = ">=2.5.1,<2.6.0" }, { name = "uipath-platform", editable = "../uipath-platform" }, - { name = "uipath-runtime", specifier = ">=0.12.2,<0.13.0" }, + { name = "uipath-runtime", specifier = ">=0.13.0,<0.14.0" }, ] provides-extras = ["ipc"] @@ -2800,16 +2800,16 @@ dev = [ [[package]] name = "uipath-runtime" -version = "0.12.2" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "chardet" }, { name = "uipath-core" }, { name = "vadersentiment" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/eb/1638b8307c9305527a449664c95a03a2af1bfaf1bd150819fb882ef71415/uipath_runtime-0.12.2.tar.gz", hash = "sha256:0d1f56f41add0d932bbe0c975d473ec27a86c58e14e9aa745d6501356c51284a", size = 235789, upload-time = "2026-07-07T11:02:12.599Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/c0/d55cf48ee43758c2c1c65f52fd0596fd75f5bd5067a5016e6553b4d2c5fe/uipath_runtime-0.13.0.tar.gz", hash = "sha256:8ae3150df5fb0043210faacf39148789c1fb252c6a8d6bc27174c0d9ce7304f5", size = 243594, upload-time = "2026-08-04T11:19:04.886Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/7a/9042e41a71726386d6f7673f843decd5405daff0787bfe127a8ac15abaa4/uipath_runtime-0.12.2-py3-none-any.whl", hash = "sha256:8faa88ac0af208b48f08abfff11530ae0279f1e88a12e9d2935f7f74111ebde1", size = 92087, upload-time = "2026-07-07T11:02:11.221Z" }, + { url = "https://files.pythonhosted.org/packages/dc/88/9518dcbeedaa8117e5fd3d0424c4a7354bae1f3ede1b2e3f48524e4b7efc/uipath_runtime-0.13.0-py3-none-any.whl", hash = "sha256:2a7c128cf14fdbef899b272ee5995da63baeb882acc904f39ef8f8f52bcb019f", size = 96349, upload-time = "2026-08-04T11:19:03.376Z" }, ] [[package]] From 2e39d8af7b0ce40ce7ed7b899bc4f396db9805dc Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Thu, 6 Aug 2026 16:01:14 +0300 Subject: [PATCH 190/191] fix(cli): reject project files clashing with generated package metadata (#1848) --- packages/uipath/pyproject.toml | 2 +- packages/uipath/src/uipath/_cli/cli_pack.py | 108 ++++++++---- packages/uipath/tests/cli/test_pack.py | 175 ++++++++++++++++++++ packages/uipath/uv.lock | 2 +- 4 files changed, 256 insertions(+), 31 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 675a9d735..599b65f33 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.14.0" +version = "2.14.1" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/cli_pack.py b/packages/uipath/src/uipath/_cli/cli_pack.py index 19a9bad69..510eeadff 100644 --- a/packages/uipath/src/uipath/_cli/cli_pack.py +++ b/packages/uipath/src/uipath/_cli/cli_pack.py @@ -22,6 +22,7 @@ from ._utils._common import determine_project_type from ._utils._console import ConsoleLogger from ._utils._project_files import ( + FileInfo, ensure_config_file, files_to_include, get_project_config, @@ -35,6 +36,12 @@ schema = "https://cloud.uipath.com/draft/2024-12/entry-point" +pack_options_spec_url = "https://github.com/UiPath/uipath-python/blob/main/packages/uipath/specs/uipath.spec.md#4-packoptions" + + +class PackageMetadataConflictError(Exception): + """Raised when project files would be packaged over generated package metadata.""" + def get_project_version(directory): toml_path = os.path.join(directory, PYTHON_CONFIGURATION_FILE) @@ -205,6 +212,44 @@ def is_venv_dir(d): ) +def archive_path_for(file: FileInfo) -> str: + """Return the path a project file is packaged under.""" + return f"content/{file.relative_path}" + + +def raise_on_metadata_conflicts( + metadata_files: dict[str, str], files: list[FileInfo] +) -> None: + """Reject project files that would be written over generated package metadata. + + The zip format allows several entries to share a name, so a project file + packaged at the same archive path as a generated metadata file yields a + package with duplicate entries that fails at extraction time. + + Args: + metadata_files: Archive path -> content of the generated metadata files + files: Project files that would be packaged + + Raises: + PackageMetadataConflictError: If any project file collides with metadata + """ + reserved = {path.casefold() for path in metadata_files} + conflicts = sorted( + file.relative_path + for file in files + if archive_path_for(file).casefold() in reserved + ) + if not conflicts: + return + + conflict_list = "\n".join(f" - {path}" for path in conflicts) + raise PackageMetadataConflictError( + f"These project files clash with generated package metadata:\n{conflict_list}\n" + "Delete, rename, or exclude them via packOptions.filesExcluded: " + f"{pack_options_spec_url}" + ) + + def pack_fn( project_name, description, @@ -239,6 +284,7 @@ def pack_fn( ) # try to read bindings from bindings.json + bindings_data: Bindings | None = None bindings_path = os.path.join(directory, str(UiPathConfig.bindings_file_path)) if os.path.exists(bindings_path): with open(bindings_path, "r") as f: @@ -257,57 +303,59 @@ def pack_fn( ) package_descriptor_content = generate_package_descriptor_content(entrypoints) + metadata_files = { + f"./package/services/metadata/core-properties/{psmdcp_file_name}": psmdcp_content, + "[Content_Types].xml": content_types_content, + "content/package-descriptor.json": json.dumps( + package_descriptor_content, indent=4 + ), + "content/operate.json": json.dumps(operate_file, indent=4), + } + if bindings_data: + metadata_files["content/bindings_v2.json"] = json.dumps( + bindings_data.model_dump(by_alias=True), indent=4 + ) + metadata_files[f"{project_name}.nuspec"] = nuspec_content + metadata_files["_rels/.rels"] = rels_content + + files, skipped_files = files_to_include( + config_data.pack_options, + directory, + include_uv_lock, + directories_to_ignore=[LEGACY_EVAL_FOLDER, EVALS_FOLDER], + ) + + raise_on_metadata_conflicts(metadata_files, files) + # Create .uipath directory if it doesn't exist os.makedirs(".uipath", exist_ok=True) with zipfile.ZipFile( f".uipath/{project_name}.{version}.nupkg", "w", zipfile.ZIP_DEFLATED ) as z: - # Add metadata files - z.writestr( - f"./package/services/metadata/core-properties/{psmdcp_file_name}", - psmdcp_content, - ) - z.writestr("[Content_Types].xml", content_types_content) - z.writestr( - "content/package-descriptor.json", - json.dumps(package_descriptor_content, indent=4), - ) - z.writestr("content/operate.json", json.dumps(operate_file, indent=4)) - if bindings_data: - z.writestr( - "content/bindings_v2.json", - json.dumps(bindings_data.model_dump(by_alias=True), indent=4), - ) - z.writestr(f"{project_name}.nuspec", nuspec_content) - z.writestr("_rels/.rels", rels_content) - - files, skipped_files = files_to_include( - config_data.pack_options, - directory, - include_uv_lock, - directories_to_ignore=[LEGACY_EVAL_FOLDER, EVALS_FOLDER], - ) + for archive_path, content in metadata_files.items(): + z.writestr(archive_path, content) for file in files: + archive_path = archive_path_for(file) if file.is_binary: # Read binary files in binary mode with open(file.file_path, "rb") as f: - z.writestr(f"content/{file.relative_path}", f.read()) + z.writestr(archive_path, f.read()) else: try: # Try UTF-8 first with open(file.file_path, "r", encoding="utf-8") as f: - z.writestr(f"content/{file.relative_path}", f.read()) + z.writestr(archive_path, f.read()) except UnicodeDecodeError: # If UTF-8 fails, try with utf-8-sig (for files with BOM) try: with open(file.file_path, "r", encoding="utf-8-sig") as f: - z.writestr(f"content/{file.relative_path}", f.read()) + z.writestr(archive_path, f.read()) except UnicodeDecodeError: # If that also fails, try with latin-1 as a fallback with open(file.file_path, "r", encoding="latin-1") as f: - z.writestr(f"content/{file.relative_path}", f.read()) + z.writestr(archive_path, f.read()) def display_project_info(config): @@ -362,6 +410,8 @@ def pack(root, nolock): display_project_info(config) console.success("Project successfully packaged.") + except PackageMetadataConflictError as e: + console.error(str(e)) except Exception as e: console.error( f"Failed to create package {config['project_name']}.{version or config['version']}: {str(e)}" diff --git a/packages/uipath/tests/cli/test_pack.py b/packages/uipath/tests/cli/test_pack.py index 96d45d07e..cfb1f1a0f 100644 --- a/packages/uipath/tests/cli/test_pack.py +++ b/packages/uipath/tests/cli/test_pack.py @@ -5,6 +5,7 @@ import zipfile from unittest.mock import patch +import pytest from click.testing import CliRunner from utils.project_details import ProjectDetails @@ -1460,3 +1461,177 @@ def test_pack_warns_mixed_entrypoint_types( assert ( "We recommend using a single type for all entrypoints" in result.output ) + + +class TestPackMetadataConflicts: + """Test that project files cannot shadow generated package metadata.""" + + def _setup_project(self, project_details: ProjectDetails, pack_options=None): + with open("uipath.json", "w") as f: + json.dump(create_uipath_json(pack_options=pack_options), f) + with open("pyproject.toml", "w") as f: + f.write(project_details.to_toml()) + with open("main.py", "w") as f: + f.write("def main(input): return input") + create_bindings_file() + create_entry_points_file() + + @pytest.mark.parametrize( + "conflicting_file", + ["operate.json", "package-descriptor.json", "bindings_v2.json"], + ) + def test_pack_fails_when_metadata_file_exists_in_project( + self, + runner: CliRunner, + temp_dir: str, + project_details: ProjectDetails, + conflicting_file: str, + ) -> None: + """Test that a project file named like generated metadata blocks packing.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + self._setup_project(project_details) + with open(conflicting_file, "w") as f: + json.dump({"stale": True}, f) + + result = runner.invoke(cli, ["pack", "./"], env={}) + + assert result.exit_code == 1 + assert "These project files clash with generated package metadata:" in ( + result.output + ) + assert f"- {conflicting_file}" in result.output + assert "packOptions.filesExcluded" in result.output + assert "specs/uipath.spec.md#4-packoptions" in result.output + assert not os.path.exists( + f".uipath/{project_details.name}.{project_details.version}.nupkg" + ) + + def test_pack_reports_all_conflicting_files( + self, + runner: CliRunner, + temp_dir: str, + project_details: ProjectDetails, + ) -> None: + """Test that every conflicting file is listed, not just the first one.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + self._setup_project(project_details) + for conflicting_file in ("operate.json", "package-descriptor.json"): + with open(conflicting_file, "w") as f: + json.dump({}, f) + + result = runner.invoke(cli, ["pack", "./"], env={}) + + assert result.exit_code == 1 + assert "- operate.json" in result.output + assert "- package-descriptor.json" in result.output + + def test_pack_conflict_detection_is_case_insensitive( + self, + runner: CliRunner, + temp_dir: str, + project_details: ProjectDetails, + ) -> None: + """Test that a case variant is rejected, since extraction is case-insensitive.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + self._setup_project(project_details) + with open("Operate.json", "w") as f: + json.dump({}, f) + + result = runner.invoke(cli, ["pack", "./"], env={}) + + assert result.exit_code == 1 + assert "- Operate.json" in result.output + + def test_pack_allows_metadata_names_in_subdirectories( + self, + runner: CliRunner, + temp_dir: str, + project_details: ProjectDetails, + ) -> None: + """Test that the same file name below the project root is not a conflict.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + self._setup_project(project_details) + os.makedirs("fixtures") + with open(os.path.join("fixtures", "operate.json"), "w") as f: + json.dump({"fixture": True}, f) + + result = runner.invoke(cli, ["pack", "./"], env={}) + + assert result.exit_code == 0 + nupkg_path = ( + f".uipath/{project_details.name}.{project_details.version}.nupkg" + ) + with zipfile.ZipFile(nupkg_path, "r") as z: + names = z.namelist() + assert "content/fixtures/operate.json" in names + assert names.count("content/operate.json") == 1 + + def test_pack_succeeds_when_conflicting_file_is_excluded( + self, + runner: CliRunner, + temp_dir: str, + project_details: ProjectDetails, + ) -> None: + """Test that packOptions.filesExcluded resolves the conflict.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + self._setup_project( + project_details, pack_options={"filesExcluded": ["operate.json"]} + ) + with open("operate.json", "w") as f: + json.dump({"stale": True}, f) + + result = runner.invoke(cli, ["pack", "./"], env={}) + + assert result.exit_code == 0 + nupkg_path = ( + f".uipath/{project_details.name}.{project_details.version}.nupkg" + ) + with zipfile.ZipFile(nupkg_path, "r") as z: + names = z.namelist() + assert names.count("content/operate.json") == 1 + assert json.loads(z.read("content/operate.json")) != {"stale": True} + + def test_nupkg_has_no_duplicate_entries( + self, + runner: CliRunner, + temp_dir: str, + project_details: ProjectDetails, + ) -> None: + """Test that a packed project never contains duplicate archive entries.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + self._setup_project(project_details) + + result = runner.invoke(cli, ["pack", "./"], env={}) + assert result.exit_code == 0 + + nupkg_path = ( + f".uipath/{project_details.name}.{project_details.version}.nupkg" + ) + with zipfile.ZipFile(nupkg_path, "r") as z: + names = z.namelist() + assert len(names) == len(set(names)) + + def test_pack_without_bindings_file( + self, + runner: CliRunner, + temp_dir: str, + project_details: ProjectDetails, + ) -> None: + """Test that packing works when bindings.json is absent.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as f: + json.dump(create_uipath_json(), f) + with open("pyproject.toml", "w") as f: + f.write(project_details.to_toml()) + with open("main.py", "w") as f: + f.write("def main(input): return input") + create_entry_points_file() + + result = runner.invoke(cli, ["pack", "./"], env={}) + + assert result.exit_code == 0, result.output + nupkg_path = ( + f".uipath/{project_details.name}.{project_details.version}.nupkg" + ) + with zipfile.ZipFile(nupkg_path, "r") as z: + assert "content/bindings_v2.json" not in z.namelist() diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index d94a3297d..c905ec7c3 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.14.0" +version = "2.14.1" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From da3cfd6df0526bfaa24c954a2660f95be24e8c8f Mon Sep 17 00:00:00 2001 From: tibrnui Date: Fri, 7 Aug 2026 15:26:00 +0300 Subject: [PATCH 191/191] chore: change to centralized managed GitHub pool (#1847) --- .github/workflows/auto-label.yml | 4 +- .github/workflows/build-package.yml | 10 +-- .github/workflows/cd.yml | 36 +++++------ .github/workflows/check-dependency-bumps.yml | 6 +- .../workflows/check-version-availability.yml | 6 +- .github/workflows/commitlint.yml | 6 +- .github/workflows/integration_tests.yml | 16 ++--- .github/workflows/lint-packages.yml | 32 +++++----- .github/workflows/publish-dev.yml | 14 ++-- .github/workflows/publish-docs.yml | 8 +-- .github/workflows/test-cd-scripts.yml | 6 +- .github/workflows/test-packages.yml | 64 +++++++++---------- .../workflows/test-uipath-integrations.yml | 40 ++++++------ .github/workflows/test-uipath-langchain.yml | 36 +++++------ .github/workflows/test-uipath-runtime.yml | 20 +++--- 15 files changed, 152 insertions(+), 152 deletions(-) diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index a5f67bcc7..4bfccae4c 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -10,10 +10,10 @@ permissions: jobs: label: - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest steps: - name: Label PR - uses: actions/labeler@v5 + uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0 with: configuration-path: '.github/labeler.yml' sync-labels: true diff --git a/.github/workflows/build-package.yml b/.github/workflows/build-package.yml index 54cd05352..faaf7df3f 100644 --- a/.github/workflows/build-package.yml +++ b/.github/workflows/build-package.yml @@ -23,21 +23,21 @@ permissions: jobs: build: name: Build ${{ inputs.package }} - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest defaults: run: working-directory: packages/${{ inputs.package }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: enable-cache: true - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version-file: "packages/${{ inputs.package }}/.python-version" @@ -72,7 +72,7 @@ jobs: run: uv build --no-sources --package ${{ inputs.package }} - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: release-dists-${{ inputs.package }} path: packages/${{ inputs.package }}/dist/ diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 82aa4ccb2..434e9d57d 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -18,16 +18,16 @@ permissions: jobs: detect-publishable-packages: - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest outputs: packages: ${{ steps.detect.outputs.packages }} count: ${{ steps.detect.outputs.count }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: '3.11' @@ -48,20 +48,20 @@ jobs: name: Publish uipath-core needs: [detect-publishable-packages, build-uipath-core] if: contains(fromJson(needs.detect-publishable-packages.outputs.packages), 'uipath-core') - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest environment: pypi permissions: contents: read id-token: write steps: - name: Retrieve release distributions - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: release-dists-uipath-core path: dist/ - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 with: verbose: true skip-existing: true @@ -73,15 +73,15 @@ jobs: always() && (contains(fromJson(needs.detect-publishable-packages.outputs.packages), 'uipath-platform') || contains(fromJson(needs.detect-publishable-packages.outputs.packages), 'uipath')) - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest steps: - name: Checkout if: contains(fromJson(needs.detect-publishable-packages.outputs.packages), 'uipath-core') - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup Python if: contains(fromJson(needs.detect-publishable-packages.outputs.packages), 'uipath-core') - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: '3.11' @@ -110,20 +110,20 @@ jobs: if: | always() && contains(fromJson(needs.detect-publishable-packages.outputs.packages), 'uipath-platform') - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest environment: pypi permissions: contents: read id-token: write steps: - name: Retrieve release distributions - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: release-dists-uipath-platform path: dist/ - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 with: verbose: true skip-existing: true @@ -134,15 +134,15 @@ jobs: if: | always() && contains(fromJson(needs.detect-publishable-packages.outputs.packages), 'uipath') - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest steps: - name: Checkout if: contains(fromJson(needs.detect-publishable-packages.outputs.packages), 'uipath-platform') - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup Python if: contains(fromJson(needs.detect-publishable-packages.outputs.packages), 'uipath-platform') - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: '3.11' @@ -173,20 +173,20 @@ jobs: if: | always() && contains(fromJson(needs.detect-publishable-packages.outputs.packages), 'uipath') - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest environment: pypi permissions: contents: read id-token: write steps: - name: Retrieve release distributions - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: release-dists-uipath path: dist/ - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 with: verbose: true skip-existing: true diff --git a/.github/workflows/check-dependency-bumps.yml b/.github/workflows/check-dependency-bumps.yml index acdff63a6..8cf3de004 100644 --- a/.github/workflows/check-dependency-bumps.yml +++ b/.github/workflows/check-dependency-bumps.yml @@ -8,15 +8,15 @@ permissions: jobs: check-dependency-bumps: - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: '3.11' diff --git a/.github/workflows/check-version-availability.yml b/.github/workflows/check-version-availability.yml index 02ace93c1..8c0897375 100644 --- a/.github/workflows/check-version-availability.yml +++ b/.github/workflows/check-version-availability.yml @@ -8,15 +8,15 @@ permissions: jobs: check-version-availability: - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: '3.11' diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 8562e455d..d88c4910f 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -6,18 +6,18 @@ on: jobs: commitlint: name: Commit Lint - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest permissions: contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1 with: node-version: 22 diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 69311181f..4e9ba0e71 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -11,18 +11,18 @@ permissions: jobs: detect-changed-packages: - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest outputs: packages: ${{ steps.detect.outputs.packages }} count: ${{ steps.detect.outputs.count }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: '3.11' @@ -37,13 +37,13 @@ jobs: discover-testcases: needs: [detect-changed-packages] if: needs.detect-changed-packages.outputs.count > 0 - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest outputs: testcases: ${{ steps.discover.outputs.testcases }} count: ${{ steps.discover.outputs.count }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Discover testcases id: discover @@ -75,7 +75,7 @@ jobs: integration-tests: needs: [discover-testcases] if: needs.discover-testcases.outputs.count > 0 - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest container: image: ghcr.io/astral-sh/uv:python3.12-bookworm env: @@ -91,7 +91,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Install dependencies working-directory: packages/${{ matrix.testcase.package }} @@ -125,7 +125,7 @@ jobs: summarize-results: needs: [detect-changed-packages, discover-testcases, integration-tests] - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest if: always() steps: - name: Check integration tests status diff --git a/.github/workflows/lint-packages.yml b/.github/workflows/lint-packages.yml index d530e25a9..c005b8ba9 100644 --- a/.github/workflows/lint-packages.yml +++ b/.github/workflows/lint-packages.yml @@ -8,18 +8,18 @@ permissions: jobs: detect-changed-packages: - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest outputs: packages: ${{ steps.detect.outputs.packages }} count: ${{ steps.detect.outputs.count }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: '3.11' @@ -34,7 +34,7 @@ jobs: lint-uipath-core: name: Lint uipath-core needs: detect-changed-packages - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest steps: - name: Check if package changed id: check @@ -51,17 +51,17 @@ jobs: - name: Checkout if: steps.check.outputs.skip != 'true' - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup uv if: steps.check.outputs.skip != 'true' - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: enable-cache: true - name: Setup Python if: steps.check.outputs.skip != 'true' - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version-file: "packages/uipath-core/.python-version" @@ -88,7 +88,7 @@ jobs: lint-uipath-platform: name: Lint uipath-platform needs: detect-changed-packages - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest steps: - name: Check if package changed id: check @@ -105,17 +105,17 @@ jobs: - name: Checkout if: steps.check.outputs.skip != 'true' - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup uv if: steps.check.outputs.skip != 'true' - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: enable-cache: true - name: Setup Python if: steps.check.outputs.skip != 'true' - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version-file: "packages/uipath-platform/.python-version" @@ -142,7 +142,7 @@ jobs: lint-uipath: name: Lint uipath needs: detect-changed-packages - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest steps: - name: Check if package changed id: check @@ -159,17 +159,17 @@ jobs: - name: Checkout if: steps.check.outputs.skip != 'true' - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup uv if: steps.check.outputs.skip != 'true' - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: enable-cache: true - name: Setup Python if: steps.check.outputs.skip != 'true' - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version-file: "packages/uipath/.python-version" @@ -201,7 +201,7 @@ jobs: lint-gate: name: Lint needs: [lint-uipath-core, lint-uipath-platform, lint-uipath] - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest if: always() steps: - name: Check lint results diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml index d1b218f99..a24503feb 100644 --- a/.github/workflows/publish-dev.yml +++ b/.github/workflows/publish-dev.yml @@ -15,18 +15,18 @@ permissions: jobs: detect-changed-packages: if: contains(github.event.pull_request.labels.*.name, 'build:dev') - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest outputs: packages: ${{ steps.detect.outputs.packages }} count: ${{ steps.detect.outputs.count }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: '3.11' @@ -42,7 +42,7 @@ jobs: name: Publish Dev Build - ${{ matrix.package }} needs: detect-changed-packages if: contains(github.event.pull_request.labels.*.name, 'build:dev') && needs.detect-changed-packages.outputs.count > 0 - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest defaults: run: working-directory: packages/${{ matrix.package }} @@ -53,15 +53,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: enable-cache: true - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version-file: "packages/${{ matrix.package }}/.python-version" diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 417cd09c9..7bfd790a2 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -18,22 +18,22 @@ on: jobs: publish-docs: - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest if: ${{ github.repository == 'UiPath/uipath-python' }} permissions: contents: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: enable-cache: true - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version-file: "packages/uipath/.python-version" diff --git a/.github/workflows/test-cd-scripts.yml b/.github/workflows/test-cd-scripts.yml index 3c6655a9d..0ac5e5f86 100644 --- a/.github/workflows/test-cd-scripts.yml +++ b/.github/workflows/test-cd-scripts.yml @@ -20,13 +20,13 @@ permissions: jobs: test-cd-scripts: - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: '3.11' diff --git a/.github/workflows/test-packages.yml b/.github/workflows/test-packages.yml index f35d77e8f..b6f5f6428 100644 --- a/.github/workflows/test-packages.yml +++ b/.github/workflows/test-packages.yml @@ -11,19 +11,19 @@ permissions: jobs: detect-changed-packages: - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest outputs: packages: ${{ steps.detect.outputs.packages }} count: ${{ steps.detect.outputs.count }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.11" @@ -43,7 +43,7 @@ jobs: fail-fast: false matrix: python-version: ["3.11", "3.12", "3.13"] - os: [ubuntu-latest, windows-latest] + os: [uipath-ubuntu-latest, uipath-windows-latest] steps: - name: Check if package changed id: check @@ -62,15 +62,15 @@ jobs: - name: Checkout if: steps.check.outputs.skip != 'true' - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup uv if: steps.check.outputs.skip != 'true' - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 - name: Setup Python if: steps.check.outputs.skip != 'true' - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} @@ -91,7 +91,7 @@ jobs: - name: Upload coverage HTML report if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-html-uipath-core path: packages/uipath-core/htmlcov/ @@ -99,7 +99,7 @@ jobs: - name: Upload coverage XML report if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-xml-uipath-core path: packages/uipath-core/coverage.xml @@ -113,7 +113,7 @@ jobs: fail-fast: false matrix: python-version: ["3.11", "3.12", "3.13"] - os: [ubuntu-latest, windows-latest] + os: [uipath-ubuntu-latest, uipath-windows-latest] steps: - name: Check if package changed id: check @@ -132,15 +132,15 @@ jobs: - name: Checkout if: steps.check.outputs.skip != 'true' - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup uv if: steps.check.outputs.skip != 'true' - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 - name: Setup Python if: steps.check.outputs.skip != 'true' - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} @@ -161,7 +161,7 @@ jobs: - name: Upload coverage HTML report if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-html-uipath-platform path: packages/uipath-platform/htmlcov/ @@ -169,7 +169,7 @@ jobs: - name: Upload coverage XML report if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-xml-uipath-platform path: packages/uipath-platform/coverage.xml @@ -178,7 +178,7 @@ jobs: e2e-uipath-platform: name: E2E (uipath-platform, memory) needs: detect-changed-packages - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest steps: - name: Check if package changed id: check @@ -197,15 +197,15 @@ jobs: - name: Checkout if: steps.check.outputs.skip != 'true' - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup uv if: steps.check.outputs.skip != 'true' - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 - name: Setup Python if: steps.check.outputs.skip != 'true' - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.11" @@ -233,7 +233,7 @@ jobs: fail-fast: false matrix: python-version: ["3.11", "3.12", "3.13"] - os: [ubuntu-latest, windows-latest] + os: [uipath-ubuntu-latest, uipath-windows-latest] steps: - name: Check if package changed id: check @@ -252,15 +252,15 @@ jobs: - name: Checkout if: steps.check.outputs.skip != 'true' - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup uv if: steps.check.outputs.skip != 'true' - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 - name: Setup Python if: steps.check.outputs.skip != 'true' - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} @@ -281,7 +281,7 @@ jobs: - name: Upload coverage HTML report if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-html-uipath path: packages/uipath/htmlcov/ @@ -289,7 +289,7 @@ jobs: - name: Upload coverage XML report if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-xml-uipath path: packages/uipath/coverage.xml @@ -300,32 +300,32 @@ jobs: sonarcloud: name: SonarCloud needs: [test-uipath-core, test-uipath-platform, test-uipath] - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest if: always() && needs.test-uipath-core.result != 'failure' && needs.test-uipath-platform.result != 'failure' && needs.test-uipath.result != 'failure' permissions: contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Download uipath-core coverage - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 continue-on-error: true with: name: coverage-xml-uipath-core path: packages/uipath-core - name: Download uipath-platform coverage - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 continue-on-error: true with: name: coverage-xml-uipath-platform path: packages/uipath-platform - name: Download uipath coverage - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 continue-on-error: true with: name: coverage-xml-uipath @@ -338,14 +338,14 @@ jobs: sed -i 's|src|packages/uipath/src|g' packages/uipath/coverage.xml || true - name: SonarCloud Scan - uses: SonarSource/sonarqube-scan-action@2f77a1ec69fb1d595b06f35ab27e97605bdef703 # v5 + uses: SonarSource/sonarqube-scan-action@2f77a1ec69fb1d595b06f35ab27e97605bdef703 # v5 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} test-gate: name: Test needs: [test-uipath-core, test-uipath-platform, test-uipath, e2e-uipath-platform] - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest if: always() steps: - name: Check test results diff --git a/.github/workflows/test-uipath-integrations.yml b/.github/workflows/test-uipath-integrations.yml index 95c621941..6bf5ceef0 100644 --- a/.github/workflows/test-uipath-integrations.yml +++ b/.github/workflows/test-uipath-integrations.yml @@ -6,19 +6,19 @@ on: jobs: build-wheels: - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest permissions: contents: read if: contains(github.event.pull_request.labels.*.name, 'test:uipath-integrations') steps: - name: Checkout uipath-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" @@ -35,21 +35,21 @@ jobs: run: uv build - name: Upload wheels - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: uipath-wheels path: packages/*/dist/*.whl discover-packages: needs: [build-wheels] - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest permissions: contents: read outputs: packages: ${{ steps.discover.outputs.packages }} steps: - name: Checkout uipath-integrations-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: 'UiPath/uipath-integrations-python' path: 'uipath-integrations-python' @@ -83,27 +83,27 @@ jobs: steps: - name: Setup uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} - name: Download wheels - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: uipath-wheels path: wheels - name: Checkout uipath-integrations-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: 'UiPath/uipath-integrations-python' path: 'uipath-integrations-python' - name: Checkout uipath-python scripts - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: path: _scripts sparse-checkout: .github/scripts @@ -127,7 +127,7 @@ jobs: discover-testcases: needs: [test-package, discover-packages] - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest permissions: contents: read outputs: @@ -135,7 +135,7 @@ jobs: has_testcases: ${{ steps.discover.outputs.has_testcases }} steps: - name: Checkout uipath-integrations-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: 'UiPath/uipath-integrations-python' path: 'uipath-integrations-python' @@ -177,7 +177,7 @@ jobs: run-integration-tests: needs: [build-wheels, discover-testcases] if: needs.discover-testcases.outputs.has_testcases == 'true' - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest container: image: ghcr.io/astral-sh/uv:python3.12-bookworm env: @@ -195,19 +195,19 @@ jobs: steps: - name: Download wheels - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: uipath-wheels path: wheels - name: Checkout uipath-integrations-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: 'UiPath/uipath-integrations-python' path: 'uipath-integrations-python' - name: Checkout uipath-python scripts - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: path: _scripts sparse-checkout: .github/scripts @@ -240,12 +240,12 @@ jobs: notify-on-failure: needs: [test-package, run-integration-tests] if: always() && contains(github.event.pull_request.labels.*.name, 'test:uipath-integrations') && (needs.test-package.result == 'failure' || needs.run-integration-tests.result == 'failure') - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest permissions: pull-requests: write steps: - name: Comment on PR - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const marker = ''; diff --git a/.github/workflows/test-uipath-langchain.yml b/.github/workflows/test-uipath-langchain.yml index 25b027633..475efc2e1 100644 --- a/.github/workflows/test-uipath-langchain.yml +++ b/.github/workflows/test-uipath-langchain.yml @@ -6,19 +6,19 @@ on: jobs: build-wheels: - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest permissions: contents: read if: contains(github.event.pull_request.labels.*.name, 'test:uipath-langchain') steps: - name: Checkout uipath-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" @@ -35,7 +35,7 @@ jobs: run: uv build - name: Upload wheels - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: uipath-wheels path: packages/*/dist/*.whl @@ -53,27 +53,27 @@ jobs: steps: - name: Setup uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} - name: Download wheels - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: uipath-wheels path: wheels - name: Checkout uipath-langchain-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: 'UiPath/uipath-langchain-python' path: 'uipath-langchain-python' - name: Checkout uipath-python scripts - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: path: _scripts sparse-checkout: .github/scripts @@ -91,7 +91,7 @@ jobs: uv run pytest discover-testcases: - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest permissions: contents: read needs: [test-uipath-langchain] @@ -99,7 +99,7 @@ jobs: testcases: ${{ steps.discover.outputs.testcases }} steps: - name: Checkout uipath-langchain-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: 'UiPath/uipath-langchain-python' path: 'uipath-langchain-python' @@ -119,7 +119,7 @@ jobs: echo "testcases=$testcases_json" >> $GITHUB_OUTPUT run-uipath-langchain-integration-tests: - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest needs: [build-wheels, discover-testcases] container: image: ghcr.io/astral-sh/uv:python3.12-bookworm @@ -139,19 +139,19 @@ jobs: steps: - name: Download wheels - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: uipath-wheels path: wheels - name: Checkout uipath-langchain-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: 'UiPath/uipath-langchain-python' path: 'uipath-langchain-python' - name: Checkout uipath-python scripts - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: path: _scripts sparse-checkout: .github/scripts @@ -187,12 +187,12 @@ jobs: notify-on-failure: needs: [test-uipath-langchain, run-uipath-langchain-integration-tests] if: always() && contains(github.event.pull_request.labels.*.name, 'test:uipath-langchain') && (needs.test-uipath-langchain.result == 'failure' || needs.run-uipath-langchain-integration-tests.result == 'failure') - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest permissions: pull-requests: write steps: - name: Comment on PR - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const marker = ''; diff --git a/.github/workflows/test-uipath-runtime.yml b/.github/workflows/test-uipath-runtime.yml index 200a1b8ca..bb72c2f97 100644 --- a/.github/workflows/test-uipath-runtime.yml +++ b/.github/workflows/test-uipath-runtime.yml @@ -6,19 +6,19 @@ on: jobs: build-wheels: - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest permissions: contents: read if: contains(github.event.pull_request.labels.*.name, 'test:uipath-runtime') steps: - name: Checkout uipath-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" @@ -27,7 +27,7 @@ jobs: run: uv build - name: Upload wheels - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: uipath-core-wheel path: packages/uipath-core/dist/*.whl @@ -45,27 +45,27 @@ jobs: steps: - name: Setup uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} - name: Download wheels - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: uipath-core-wheel path: wheels - name: Checkout uipath-runtime-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: 'UiPath/uipath-runtime-python' path: 'uipath-runtime-python' - name: Checkout uipath-python scripts - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: path: _scripts sparse-checkout: .github/scripts

    $ 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