Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/uipath-platform/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import os
import shutil
import tempfile
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
*,
Expand Down
49 changes: 49 additions & 0 deletions packages/uipath-platform/tests/services/test_jobs_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/uipath-platform/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/uipath/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
70 changes: 70 additions & 0 deletions packages/uipath/src/uipath/_cli/_chat/_custom.py
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +45 to +47

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.
Comment on lines +57 to +60

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)
Comment on lines +65 to +67
if bridge is None:
logger.debug("chatBridge %s declined this run.", spec)
return bridge
20 changes: 19 additions & 1 deletion packages/uipath/src/uipath/_cli/cli_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
)
Comment on lines +298 to +302
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(
Expand All @@ -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
)
Expand All @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions packages/uipath/src/uipath/_cli/models/uipath_json_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions packages/uipath/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading