diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 45f24aa63..c66fd8c42 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.30" +version = "0.2.31" 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 0f6a16209..7cc8c5235 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py @@ -47,6 +47,7 @@ BYO_VALIDATOR_TYPE, BuiltInValidatorGuardrail, EnumListParameterValue, + GuardrailAttachment, GuardrailType, MapEnumParameterValue, ) @@ -56,6 +57,7 @@ "GuardrailsService", # Guardrail models "BYO_VALIDATOR_TYPE", + "GuardrailAttachment", "BuiltInValidatorGuardrail", "GuardrailType", "GuardrailValidationResultType", 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..6a25e7bc4 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py @@ -8,16 +8,25 @@ ) from uipath.core.tracing import traced -from uipath.platform.constants import HEADER_GUARDRAILS_SOURCE +from uipath.platform.constants import HEADER_FOLDER_KEY, 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._config import UiPathApiConfig, UiPathConfig from ..common._execution_context import UiPathExecutionContext from ..common._job_context import header_job_key from ..common._models import Endpoint, RequestSpec from ..errors import EnrichedException -from .guardrails import BYO_VALIDATOR_TYPE, BuiltInValidatorGuardrail +from .guardrails import ( + BYO_VALIDATOR_TYPE, + BuiltInValidatorGuardrail, + GuardrailAttachment, +) + +#: Timeout for a validate call carrying attachments. The backend fetches and decodes each +#: file inside the request, which the default 30s client timeout does not allow for. +_ATTACHMENT_VALIDATE_TIMEOUT_SECONDS = 60.0 + # 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. @@ -102,12 +111,18 @@ def evaluate_guardrail( self, input_data: str | dict[str, Any], guardrail: BuiltInValidatorGuardrail, + *, + attachments: list[GuardrailAttachment] | None = None, ) -> GuardrailValidationResult: """Validate input text using the provided guardrail. Args: input_data: The text or structured data to validate. Dictionaries will be converted to a string before validation. guardrail: A guardrail instance used for validation. + attachments: Files attached to the run that the guardrail may inspect, so a + validator can evaluate a file's contents rather than only its metadata. + Which validators can use them, and which file types are readable, is + decided server-side. Omitted from the request body when empty. Returns: GuardrailValidationResult: The outcome of the guardrail evaluation. @@ -127,6 +142,8 @@ def evaluate_guardrail( "BYO (Bring Your Own) guardrails require byo_validator_name." ) payload["byoValidatorName"] = guardrail.byo_validator_name + if attachments: + payload["attachments"] = [a.model_dump(by_alias=True) for a in attachments] spec = RequestSpec( method="POST", endpoint=Endpoint("/agentsruntime_/api/execution/guardrails/validate"), @@ -141,19 +158,35 @@ def evaluate_guardrail( execution_source = self._execution_context.execution_source if execution_source: source_headers[HEADER_GUARDRAILS_SOURCE] = execution_source + # When attachments are present, tell helix which folder the run executed in + # so it can resolve each attachment id through Orchestrator's folder-scoped + # API. Only sent alongside attachments: it is meaningless otherwise. + folder_headers: dict[str, str] = {} + if attachments and UiPathConfig.folder_key: + folder_headers[HEADER_FOLDER_KEY] = UiPathConfig.folder_key request_headers = { **(spec.headers or {}), **trace_headers, **source_headers, **header_job_key(), + **folder_headers, } + # The default client timeout is 30s (common/_http_config.py). A validate call + # carrying attachments waits for the backend to fetch and decode each one, so give + # it more room. RequestSpec.timeout exists but is never forwarded, so pass it here. + request_kwargs: dict[str, Any] = { + "json": spec.json, + "headers": request_headers, + } + if attachments: + request_kwargs["timeout"] = _ATTACHMENT_VALIDATE_TIMEOUT_SECONDS + span_id = None try: response = self.request( spec.method, url=spec.endpoint, - json=spec.json, - headers=request_headers, + **request_kwargs, ) span_id = self._extract_span_id_from_traceparent( response.headers.get("x-uipath-traceparent-id") diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py b/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py index dace18019..c6935f361 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py @@ -96,6 +96,33 @@ class BuiltInValidatorGuardrail(BaseGuardrail): model_config = ConfigDict(populate_by_name=True, extra="allow") +class GuardrailAttachment(BaseModel): + """A reference to a file attached to the run that a guardrail may inspect. + + Passed to [`GuardrailsService.evaluate_guardrail`][uipath.platform.guardrails.GuardrailsService.evaluate_guardrail] + so the guardrails backend can read the file's contents rather than only its metadata. + + Only the Orchestrator attachment id crosses the wire: helix resolves it through + its own Orchestrator client (folder-scoped when the run's folder key is sent + alongside), so Orchestrator's access control is what decides whether the file can + be read — the runtime never resolves or forwards a signed URL itself. + + Attributes: + id: The Orchestrator attachment id, as a string UUID. Used by the backend to + resolve the file through Orchestrator, as an extraction cache key, and for + trace correlation. + file_name: Original file name, shown to a judge model so it can name the + offending file. + mime_type: Original mime type. The backend decides what it can inspect. + """ + + id: str + file_name: str = Field(alias="fileName") + mime_type: str = Field(alias="mimeType") + + model_config = ConfigDict(populate_by_name=True) + + class GuardrailType(str, Enum): """Guardrail type enumeration.""" diff --git a/packages/uipath-platform/tests/services/test_guardrails_service.py b/packages/uipath-platform/tests/services/test_guardrails_service.py index d20d531a7..95ba141ef 100644 --- a/packages/uipath-platform/tests/services/test_guardrails_service.py +++ b/packages/uipath-platform/tests/services/test_guardrails_service.py @@ -14,6 +14,7 @@ from uipath.platform.guardrails import ( BuiltInValidatorGuardrail, EnumListParameterValue, + GuardrailAttachment, GuardrailsService, MapEnumParameterValue, ) @@ -897,3 +898,223 @@ def test_invalid_format(self) -> None: assert ( GuardrailsService._extract_span_id_from_traceparent("not-valid") is None ) + + +_VALIDATE_PATH = "/agentsruntime_/api/execution/guardrails/validate" +_ATTACHMENT_ID = "7f2c1e44-0b3a-4a1e-9d55-2f9a1c3b8e10" + + +def _judge_guardrail() -> BuiltInValidatorGuardrail: + return BuiltInValidatorGuardrail( + id="g1", + name="Injection check", + description="Test judge", + enabled_for_evals=True, + selector=GuardrailSelector(scopes=[GuardrailScope.AGENT]), + guardrail_type="builtInValidator", + validator_type="llm_as_judge", + validator_parameters=[], + ) + + +def _attachment() -> GuardrailAttachment: + return GuardrailAttachment( + id=_ATTACHMENT_ID, + file_name="Tickets.csv", + mime_type="text/csv", + ) + + +class TestGuardrailAttachments: + """evaluate_guardrail forwards attachment references to the validate API.""" + + def test_attachments_are_sent_with_camel_case_aliases( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}{_VALIDATE_PATH}", + status_code=200, + json={"result": "PASSED", "details": ""}, + ) + + service.evaluate_guardrail( + "see attached", _judge_guardrail(), attachments=[_attachment()] + ) + + body = json.loads(httpx_mock.get_requests()[0].content) + assert body["attachments"] == [ + { + "id": _ATTACHMENT_ID, + "fileName": "Tickets.csv", + "mimeType": "text/csv", + } + ] + + def test_attachments_key_is_absent_when_not_supplied( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """An older backend must see a byte-identical body to today.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}{_VALIDATE_PATH}", + status_code=200, + json={"result": "PASSED", "details": ""}, + ) + + service.evaluate_guardrail("no files here", _judge_guardrail()) + + assert "attachments" not in json.loads(httpx_mock.get_requests()[0].content) + + def test_attachments_key_is_absent_when_empty_list( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}{_VALIDATE_PATH}", + status_code=200, + json={"result": "PASSED", "details": ""}, + ) + + service.evaluate_guardrail("x", _judge_guardrail(), attachments=[]) + + assert "attachments" not in json.loads(httpx_mock.get_requests()[0].content) + + def test_attachment_round_trips_the_wire_shape(self) -> None: + """The camelCase body the API emits parses back into the model unchanged.""" + wire = { + "id": _ATTACHMENT_ID, + "fileName": "a.csv", + "mimeType": "text/csv", + } + + parsed = GuardrailAttachment.model_validate(wire) + + assert parsed.file_name == "a.csv" + assert parsed.mime_type == "text/csv" + assert parsed.model_dump(by_alias=True) == wire + + +class TestGuardrailAttachmentFolderHeader: + """evaluate_guardrail tells helix which folder the run executed in so it can + resolve each attachment id through Orchestrator's folder-scoped API.""" + + def test_folder_header_sent_when_folder_key_configured( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "folder-key-123") + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}{_VALIDATE_PATH}", + status_code=200, + json={"result": "PASSED", "details": ""}, + ) + + service.evaluate_guardrail("x", _judge_guardrail(), attachments=[_attachment()]) + + request = httpx_mock.get_requests()[0] + assert request.headers["x-uipath-folderkey"] == "folder-key-123" + + def test_folder_header_absent_when_no_folder_key_configured( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_FOLDER_KEY", raising=False) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}{_VALIDATE_PATH}", + status_code=200, + json={"result": "PASSED", "details": ""}, + ) + + service.evaluate_guardrail("x", _judge_guardrail(), attachments=[_attachment()]) + + request = httpx_mock.get_requests()[0] + assert "x-uipath-folderkey" not in request.headers + + def test_folder_header_absent_without_attachments_even_if_configured( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The header is meaningless without attachments, so it is never sent.""" + monkeypatch.setenv("UIPATH_FOLDER_KEY", "folder-key-123") + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}{_VALIDATE_PATH}", + status_code=200, + json={"result": "PASSED", "details": ""}, + ) + + service.evaluate_guardrail("x", _judge_guardrail()) + + request = httpx_mock.get_requests()[0] + assert "x-uipath-folderkey" not in request.headers + + +class TestGuardrailAttachmentTiming: + """Attachments still get the longer, file-fetching-aware timeout.""" + + def test_evaluate_guardrail_forwards_a_longer_timeout_with_attachments( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """The default client timeout is 30s; a validate call that waits on the backend + fetching files gets 60s. Without this assertion the kwarg could vanish silently.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}{_VALIDATE_PATH}", + status_code=200, + json={"result": "PASSED", "details": ""}, + ) + + service.evaluate_guardrail("x", _judge_guardrail(), attachments=[_attachment()]) + + timeout = httpx_mock.get_requests()[0].extensions["timeout"] + assert timeout["read"] == 60.0 + + def test_evaluate_guardrail_keeps_default_timeout_without_attachments( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}{_VALIDATE_PATH}", + status_code=200, + json={"result": "PASSED", "details": ""}, + ) + + service.evaluate_guardrail("x", _judge_guardrail()) + + timeout = httpx_mock.get_requests()[0].extensions["timeout"] + assert timeout["read"] != 60.0 diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index e719a4198..8affb138e 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.30" +version = "0.2.31" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index efa9ea4ef..7eea2b2c8 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.14.21" +version = "2.14.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" dependencies = [ "uipath-core>=0.5.30, <0.6.0", "uipath-runtime>=0.13.5, <0.14.0", - "uipath-platform>=0.2.30, <0.3.0", + "uipath-platform>=0.2.31, <0.3.0", "uipath-ipc>=2.5.1, <2.6.0", "click>=8.3.3, <9.0.0", "httpx>=0.28.1", diff --git a/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md b/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md index 704dfa078..6494e9b0d 100644 --- a/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md +++ b/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md @@ -668,7 +668,7 @@ Guardrails service ```python # Validate input text using the provided guardrail. -sdk.guardrails.evaluate_guardrail(input_data: str | dict[str, Any], guardrail: uipath.platform.guardrails.guardrails.BuiltInValidatorGuardrail) -> uipath.core.guardrails.guardrails.GuardrailValidationResult +sdk.guardrails.evaluate_guardrail(input_data: str | dict[str, Any], guardrail: uipath.platform.guardrails.guardrails.BuiltInValidatorGuardrail, attachments: list[uipath.platform.guardrails.guardrails.GuardrailAttachment] | None=None) -> uipath.core.guardrails.guardrails.GuardrailValidationResult ``` diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 896d8a07b..618d86085 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.14.21" +version = "2.14.22" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.30" +version = "0.2.31" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" },