diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index eab210d34..b12e287e3 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.17" +version = "0.2.18" 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/_jobs_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py index fa0103b2c..0dc329d4e 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py @@ -1,3 +1,4 @@ +import json import os import shutil import tempfile @@ -163,6 +164,99 @@ async def main(): # noqa: D103 json=spec.json, ) + @traced(name="jobs_resume_job", run_type="uipath") + def resume_job( + self, + *, + job_key: str, + input_arguments: Optional[Dict[str, Any]] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> None: + """Resumes a suspended job that is not waiting on an inbox. + + :meth:`resume` delivers a payload to the inbox of a job suspended on an + API resume trigger. A job can also suspend without one, and then there is + no inbox to deliver to. A conversational agent ending an exchange is the + common case. Such a job is resumed at the job level instead, carrying the + next input as arguments. + + Args: + job_key (str): The key of the job to resume. + input_arguments (Optional[Dict[str, Any]]): Arguments the resumed + execution receives as its input. + folder_key (Optional[str]): The key of the folder the job runs in. + Override the default one set in the SDK config. + folder_path (Optional[str]): The path of the folder the job runs in. + Override the default one set in the SDK config. + """ + spec = self._resume_job_spec( + job_key=job_key, + input_arguments=input_arguments, + folder_key=folder_key, + folder_path=folder_path, + ) + self.request( + spec.method, + url=spec.endpoint, + headers=spec.headers, + json=spec.json, + ) + + @traced(name="jobs_resume_job", run_type="uipath") + async def resume_job_async( + self, + *, + job_key: str, + input_arguments: Optional[Dict[str, Any]] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> None: + """Asynchronously resumes a suspended job that is not waiting on an inbox. + + See :meth:`resume_job`. + + Args: + job_key (str): The key of the job to resume. + input_arguments (Optional[Dict[str, Any]]): Arguments the resumed + execution receives as its input. + folder_key (Optional[str]): The key of the folder the job runs in. + Override the default one set in the SDK config. + folder_path (Optional[str]): The path of the folder the job runs in. + Override the default one set in the SDK config. + + Examples: + ```python + import asyncio + + from uipath.platform import UiPath + + sdk = UiPath() + + + async def main(): # noqa: D103 + await sdk.jobs.resume_job_async( + job_key="ccd177d7-e477-40b6-9f27-477b0506ef65", + input_arguments={"message": "and what about last quarter?"}, + ) + + + asyncio.run(main()) + ``` + """ + spec = self._resume_job_spec( + job_key=job_key, + input_arguments=input_arguments, + folder_key=folder_key, + folder_path=folder_path, + ) + await self.request_async( + spec.method, + url=spec.endpoint, + headers=spec.headers, + json=spec.json, + ) + @property def custom_headers(self) -> Dict[str, str]: return self.folder_headers @@ -821,6 +915,28 @@ def _resume_spec( }, ) + def _resume_job_spec( + self, + *, + job_key: str, + input_arguments: Optional[Dict[str, Any]] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + return RequestSpec( + method="POST", + endpoint=Endpoint( + "/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.ResumeJob" + ), + json={ + "jobKey": job_key, + "inputArguments": json.dumps(input_arguments or {}), + }, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + def _retrieve_spec( self, *, diff --git a/packages/uipath-platform/tests/services/test_jobs_service.py b/packages/uipath-platform/tests/services/test_jobs_service.py index 6782855d4..4d461fd35 100644 --- a/packages/uipath-platform/tests/services/test_jobs_service.py +++ b/packages/uipath-platform/tests/services/test_jobs_service.py @@ -235,6 +235,55 @@ def test_resume_with_inbox_id( == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.JobsService.resume/{version}" ) + def test_resume_job_needs_no_inbox( + self, + httpx_mock: HTTPXMock, + service: JobsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """A job that suspended without an API trigger has no inbox to deliver to.""" + endpoint = ( + f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/" + "UiPath.Server.Configuration.OData.ResumeJob" + ) + httpx_mock.add_response(url=endpoint, status_code=200) + + service.resume_job( + job_key="ccd177d7-e477-40b6-9f27-477b0506ef65", + input_arguments={"message": "and what about last quarter?"}, + ) + + sent_request = httpx_mock.get_request() + assert sent_request is not None + assert sent_request.method == "POST" + assert str(sent_request.url) == endpoint + assert json.loads(sent_request.content.decode()) == { + "jobKey": "ccd177d7-e477-40b6-9f27-477b0506ef65", + "inputArguments": json.dumps({"message": "and what about last quarter?"}), + } + + def test_resume_job_without_arguments_sends_an_empty_object( + self, + httpx_mock: HTTPXMock, + service: JobsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + endpoint = ( + f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/" + "UiPath.Server.Configuration.OData.ResumeJob" + ) + httpx_mock.add_response(url=endpoint, status_code=200) + + service.resume_job(job_key="ccd177d7-e477-40b6-9f27-477b0506ef65") + + sent_request = httpx_mock.get_request() + assert sent_request is not None + assert json.loads(sent_request.content.decode())["inputArguments"] == "{}" + def test_resume_with_job_id( self, httpx_mock: HTTPXMock, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 2e506b0af..99067a7a6 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-29T07:23:36.9681123Z" +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.17" +version = "0.2.18" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 599b65f33..9d03dcc1e 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.14.1" +version = "2.14.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" diff --git a/packages/uipath/src/uipath/_cli/_chat/_custom.py b/packages/uipath/src/uipath/_cli/_chat/_custom.py new file mode 100644 index 000000000..ef66bed9e --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_chat/_custom.py @@ -0,0 +1,70 @@ +"""Loading a chat bridge the project supplies itself. + +The Conversational Agent Service is one place an exchange can be spoken to, not +the only one. A project that names a ``chatBridge`` in uipath.json is telling +the runtime where its messages go instead: Slack, Teams, a webhook, a test +double. The runtime already speaks to all of them through +``UiPathChatProtocol``, so nothing framework-specific is involved and every +agent framework gets the same reach for free. +""" + +import importlib.util +import logging +import sys +from pathlib import Path +from typing import Any, Callable + +from uipath.runtime.chat import UiPathChatProtocol +from uipath.runtime.context import UiPathRuntimeContext + +logger = logging.getLogger(__name__) + +ChatBridgeFactory = Callable[[UiPathRuntimeContext], UiPathChatProtocol | None] + + +class UiPathChatBridgeError(Exception): + """A configured chat bridge could not be loaded.""" + + +def _load_factory(spec: str) -> ChatBridgeFactory: + if ":" not in spec: + raise UiPathChatBridgeError( + f"chatBridge must be 'file_path:factory_name', got {spec!r}." + ) + file_part, _, factory_name = spec.partition(":") + + path = Path(file_part).resolve() + if not path.is_file(): + raise UiPathChatBridgeError(f"chatBridge file not found: {path}") + + module_name = f"_uipath_chat_bridge_{path.stem}" + module_spec = importlib.util.spec_from_file_location(module_name, path) + if module_spec is None or module_spec.loader is None: + raise UiPathChatBridgeError(f"chatBridge file is not importable: {path}") + + module = importlib.util.module_from_spec(module_spec) + sys.modules[module_name] = module + module_spec.loader.exec_module(module) + + factory: Any = getattr(module, factory_name, None) + if factory is None: + raise UiPathChatBridgeError(f"{path.name} has no attribute {factory_name!r}.") + if not callable(factory): + raise UiPathChatBridgeError(f"{spec} is not callable.") + return factory + + +def resolve_chat_bridge( + spec: str | None, context: UiPathRuntimeContext +) -> UiPathChatProtocol | None: + """Build the project's own chat bridge, if it declared one and it wants this run. + + A factory returning ``None`` declines, which is how one agent serves both a + custom surface and the Conversational Agent Service without branching. + """ + if not spec: + return None + bridge = _load_factory(spec)(context) + if bridge is None: + logger.debug("chatBridge %s declined this run.", spec) + return bridge diff --git a/packages/uipath/src/uipath/_cli/cli_run.py b/packages/uipath/src/uipath/_cli/cli_run.py index 9d12a86c3..028bb5762 100644 --- a/packages/uipath/src/uipath/_cli/cli_run.py +++ b/packages/uipath/src/uipath/_cli/cli_run.py @@ -5,10 +5,12 @@ from pydantic import ValidationError from uipath._cli._chat._bridge import get_chat_bridge +from uipath._cli._chat._custom import resolve_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._cli._utils._tracing import create_trace_manager +from uipath._cli.models.uipath_json_schema import UiPathJsonConfig from uipath.eval.mocks import SimulationConfig, UiPathMockRuntime, build_mocking_context from uipath.platform.common import ( ExecutionSourceContext, @@ -293,6 +295,16 @@ async def execute() -> None: mocking_context=mocking_context, ) + # A project's own bridge is not tied to a platform job, + # so it works the same locally and deployed. + custom_bridge = resolve_chat_bridge( + UiPathJsonConfig.load_from_file().chat_bridge, ctx + ) + if custom_bridge is not None: + chat_runtime = UiPathChatRuntime( + delegate=runtime, chat_bridge=custom_bridge + ) + if ctx.job_id: if UiPathConfig.is_tracing_enabled: trace_manager.add_span_processor( @@ -302,7 +314,11 @@ async def execute() -> None: ) ) - if ctx.conversation_id and ctx.exchange_id: + if ( + chat_runtime is None + and ctx.conversation_id + and ctx.exchange_id + ): chat_bridge: UiPathChatProtocol = get_chat_bridge( context=ctx ) @@ -313,6 +329,8 @@ async def execute() -> None: ctx.result = await execute_runtime( ctx, chat_runtime or runtime ) + elif chat_runtime is not None: + ctx.result = await execute_runtime(ctx, chat_runtime) else: ctx.result = await debug_runtime(ctx, runtime) finally: 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 ca5818b86..6cbed8736 100644 --- a/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py +++ b/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py @@ -110,6 +110,15 @@ class UiPathJsonConfig(BaseModelWithDefaultConfig): "Each key is an entrypoint name, and each value is a path in format 'file_path:agent_name'", ) + chat_bridge: str | None = Field( + default=None, + alias="chatBridge", + description="Where a conversational agent's messages go when the " + "Conversational Agent Service is not driving it, in the format " + "'file_path:factory_name'. The factory takes the runtime context and " + "returns a UiPathChatProtocol, or None to decline.", + ) + def to_json_string(self, indent: int = 2) -> str: """Export to JSON string with proper formatting.""" return self.model_dump_json( diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index c905ec7c3..f9c60b86a 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.14.1" +version = "2.14.2" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2760,7 +2760,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.17" +version = "0.2.18" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" },