Skip to content
Merged
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.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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
BYO_VALIDATOR_TYPE,
BuiltInValidatorGuardrail,
EnumListParameterValue,
GuardrailAttachment,
GuardrailType,
MapEnumParameterValue,
)
Expand All @@ -56,6 +57,7 @@
"GuardrailsService",
# Guardrail models
"BYO_VALIDATOR_TYPE",
"GuardrailAttachment",
"BuiltInValidatorGuardrail",
"GuardrailType",
"GuardrailValidationResultType",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -102,12 +111,18 @@ def evaluate_guardrail(
self,
input_data: str | dict[str, Any],
guardrail: BuiltInValidatorGuardrail,
*,
attachments: list[GuardrailAttachment] | None = None,
Comment thread
apetraru-uipath marked this conversation as resolved.
) -> 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.
Expand All @@ -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"),
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here. Drop the helix terminology. In fact, do we need this comment at all? Let's reduce the comments' verbosity and keep only what's really needed and useful.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in #1903 — project name dropped and the surrounding comments trimmed.

# 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
Comment thread
apetraru-uipath marked this conversation as resolved.

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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't refer the helix project name in the SDK docs please

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in #1903 — project name dropped and the surrounding comments trimmed.

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."""

Expand Down
221 changes: 221 additions & 0 deletions packages/uipath-platform/tests/services/test_guardrails_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from uipath.platform.guardrails import (
BuiltInValidatorGuardrail,
EnumListParameterValue,
GuardrailAttachment,
GuardrailsService,
MapEnumParameterValue,
)
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion packages/uipath-platform/uv.lock

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

Loading
Loading