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" },