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-core/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
15 changes: 15 additions & 0 deletions packages/uipath-core/src/uipath/core/governance/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -35,4 +43,11 @@
"GovernanceConfigError",
"GovernanceViolation",
"Severity",
# Provider protocols + wire models
"FiredRule",
"GovernanceCompensationProvider",
"GovernancePolicyProvider",
"GovernRequest",
"PolicyContext",
"PolicyResponse",
]
153 changes: 153 additions & 0 deletions packages/uipath-core/src/uipath/core/governance/providers.py
Original file line number Diff line number Diff line change
@@ -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": "<YAML string>"
}

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`` /
Comment thread
viswa-uipath marked this conversation as resolved.
``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:
Comment thread
viswa-uipath marked this conversation as resolved.
"""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."""
...
149 changes: 149 additions & 0 deletions packages/uipath-core/tests/governance/test_providers.py
Original file line number Diff line number Diff line change
@@ -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]
2 changes: 1 addition & 1 deletion packages/uipath-core/uv.lock

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

4 changes: 2 additions & 2 deletions packages/uipath-platform/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
[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"
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",
]
Expand Down
5 changes: 5 additions & 0 deletions packages/uipath-platform/src/uipath/platform/_uipath.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading