Skip to content

Commit 08e0d09

Browse files
viswa-uipathclaude
andcommitted
feat(governance): wire platform governance through core protocols via DI
Declare GovernancePolicyProvider / GovernanceCompensationProvider in uipath-core so the runtime depends only on the lowest layer. Implement both in uipath-platform via UiPathPlatformGovernanceProvider, backed by the GovernanceService HTTP client. Inject the providers into the runtime by construction rather than a global registry: a new uipath._governance package owns the GovernanceRuntime wrapper and a wrap_with_governance() helper that the CLI (uipath run / debug) calls to wrap the delegate. The helper fails open — when UIPATH_URL / UIPATH_ACCESS_TOKEN are unset or the platform client cannot be built, the delegate is returned unchanged so governance is never on the critical path for agent execution. GovernanceRuntime caches the policy pack on the first execute/stream call for the lifetime of the wrapper (one per CLI session). Policies are tenant-stable; refresh requires constructing a fresh wrapper. The cache flag is set on first attempt regardless of outcome, so a transient fetch failure keeps the wrapper running policy-less in the documented fail-open mode rather than retrying on every call. Also adds resolve_trace_id() in platform common to capture the canonical trace id before hopping off the OTel-context-owning thread. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3753803 commit 08e0d09

26 files changed

Lines changed: 2130 additions & 18 deletions

File tree

packages/uipath-core/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-core"
3-
version = "0.5.20"
3+
version = "0.5.21"
44
description = "UiPath Core abstractions"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

packages/uipath-core/src/uipath/core/governance/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@
1919
Severity,
2020
)
2121
from .models import Action, AuditRecord, EnforcementMode, LifecycleHook, RuleEvaluation
22+
from .providers import (
23+
FiredRule,
24+
GovernanceCompensationProvider,
25+
GovernancePolicyProvider,
26+
GovernRequest,
27+
PolicyContext,
28+
PolicyResponse,
29+
)
2230

2331
__all__ = [
2432
# Output models (cross adapter boundary)
@@ -35,4 +43,11 @@
3543
"GovernanceConfigError",
3644
"GovernanceViolation",
3745
"Severity",
46+
# Provider protocols + wire models
47+
"FiredRule",
48+
"GovernanceCompensationProvider",
49+
"GovernancePolicyProvider",
50+
"GovernRequest",
51+
"PolicyContext",
52+
"PolicyResponse",
3853
]
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""Provider protocols for governance backend interactions.
2+
3+
The runtime needs two backend interactions to function:
4+
5+
- Fetching the policy pack at startup.
6+
- Firing the compensating ``/runtime/govern`` POST when a
7+
``guardrail_fallback`` rule matches so the server can run the disabled
8+
centralised guardrail and write the per-rule LLMOps audit records.
9+
10+
Both have wire formats owned by the platform's ``agenticgovernance_``
11+
ingress. Defining the contracts here in ``uipath-core`` — alongside
12+
:class:`EvaluatorProtocol` — lets the runtime depend only on the lowest
13+
layer; the HTTP implementation lives in ``uipath-platform`` (or any
14+
other implementation a deployment chooses) and is supplied to runtime
15+
consumers via constructor injection.
16+
17+
Concrete platform-backed implementations:
18+
:class:`uipath.platform.governance.UiPathPlatformGovernanceProvider`
19+
(satisfies both protocols below).
20+
"""
21+
22+
from __future__ import annotations
23+
24+
from typing import Any, Optional, Protocol, runtime_checkable
25+
26+
from pydantic import BaseModel, ConfigDict, Field, field_validator
27+
28+
from .models import EnforcementMode
29+
30+
# ----------------------------------------------------------------------
31+
# Wire-format models
32+
# ----------------------------------------------------------------------
33+
34+
35+
class PolicyContext(BaseModel):
36+
"""Caller-supplied selectors for the policy fetch.
37+
38+
Wrapping the selectors in a model keeps the protocol surface stable
39+
when the server grows new selector dimensions — adding a field here
40+
doesn't change :meth:`GovernancePolicyProvider.get_policy`.
41+
42+
Today carries only :attr:`is_conversational`; future selectors land
43+
here.
44+
"""
45+
46+
model_config = ConfigDict(extra="ignore")
47+
48+
is_conversational: Optional[bool] = None
49+
50+
51+
class PolicyResponse(BaseModel):
52+
"""Parsed governance backend response.
53+
54+
Wire envelope::
55+
56+
{
57+
"mode": "audit" | "enforce" | "disabled",
58+
"policies": "<YAML string>"
59+
}
60+
61+
Attributes:
62+
mode: Platform-controlled enforcement mode for the tenant. May
63+
be ``None`` when the backend omits it. A wire value the SDK
64+
doesn't know about parses as ``None`` rather than raising,
65+
so a server-side mode addition can't break agent startup.
66+
policies: Policy pack YAML the caller compiles into its policy
67+
index. May be an empty string when no rules are configured.
68+
"""
69+
70+
model_config = ConfigDict(extra="ignore")
71+
72+
mode: Optional[EnforcementMode] = Field(default=None)
73+
policies: str = Field(default="")
74+
75+
@field_validator("mode", mode="before")
76+
@classmethod
77+
def _coerce_mode(cls, value: object) -> Optional[EnforcementMode]:
78+
if value is None or isinstance(value, EnforcementMode):
79+
return value
80+
try:
81+
return EnforcementMode(value)
82+
except ValueError:
83+
return None
84+
85+
86+
class FiredRule(BaseModel):
87+
"""Per-rule metadata carried in the ``/runtime/govern`` payload.
88+
89+
One entry per matching ``guardrail_fallback`` condition. The server
90+
writes one LLMOps trace record per entry, so callers must include
91+
every fired rule even when multiple share the same ``validator``.
92+
"""
93+
94+
model_config = ConfigDict(populate_by_name=True)
95+
96+
rule_id: str = Field(alias="ruleId")
97+
rule_name: str = Field(alias="ruleName")
98+
pack_name: str = Field(alias="packName")
99+
validator: str
100+
101+
102+
class GovernRequest(BaseModel):
103+
"""Request body for the ``/runtime/govern`` compensating governance POST.
104+
105+
Field aliases match the on-the-wire JSON keys. ``src_timestamp`` is
106+
snake_case on the wire (intentional — preserved verbatim); every
107+
other key is camelCase.
108+
109+
Job-context fields (``folder_key`` / ``job_key`` / ``process_key`` /
110+
``reference_id`` / ``agent_version``) are optional: the platform
111+
fills them from :class:`UiPathConfig` at call time when the caller
112+
leaves them ``None``, and emits the key only when a value resolves.
113+
"""
114+
115+
model_config = ConfigDict(populate_by_name=True)
116+
117+
validators: list[str] = Field(alias="type")
118+
rules: list[FiredRule]
119+
data: dict[str, Any]
120+
hook: str
121+
trace_id: str = Field(alias="traceId")
122+
src_timestamp: str # wire key is intentionally snake_case
123+
agent_name: str = Field(alias="agentName")
124+
runtime_id: str = Field(alias="runtimeId")
125+
126+
folder_key: Optional[str] = Field(default=None, alias="folderKey")
127+
job_key: Optional[str] = Field(default=None, alias="jobKey")
128+
process_key: Optional[str] = Field(default=None, alias="processKey")
129+
reference_id: Optional[str] = Field(default=None, alias="referenceId")
130+
agent_version: Optional[str] = Field(default=None, alias="agentVersion")
131+
132+
133+
# ----------------------------------------------------------------------
134+
# Provider protocols
135+
# ----------------------------------------------------------------------
136+
137+
138+
@runtime_checkable
139+
class GovernancePolicyProvider(Protocol):
140+
"""Contract for fetching the governance policy pack.
141+
142+
Any object exposing a ``get_policy(context) -> PolicyResponse``
143+
method satisfies this protocol. The platform-backed implementation
144+
is :class:`uipath.platform.governance.UiPathPlatformGovernanceProvider`.
145+
"""
146+
147+
def get_policy(self, context: PolicyContext) -> PolicyResponse:
148+
"""Fetch the policy pack for the active org/tenant."""
149+
...
150+
151+
152+
@runtime_checkable
153+
class GovernanceCompensationProvider(Protocol):
154+
"""Contract for firing the compensating ``/runtime/govern`` POST."""
155+
156+
def compensate(self, request: GovernRequest) -> None:
157+
"""Fire the compensating governance POST. Fire-and-forget."""
158+
...
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""Tests for the governance provider protocols + wire-format models."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from uipath.core.governance import (
8+
EnforcementMode,
9+
FiredRule,
10+
GovernanceCompensationProvider,
11+
GovernancePolicyProvider,
12+
GovernRequest,
13+
PolicyContext,
14+
PolicyResponse,
15+
)
16+
17+
18+
class _FakePolicyProvider:
19+
def __init__(self) -> None:
20+
self.calls: list[PolicyContext] = []
21+
22+
def get_policy(self, context: PolicyContext) -> PolicyResponse:
23+
self.calls.append(context)
24+
return PolicyResponse(mode=EnforcementMode.ENFORCE, policies="rules: []")
25+
26+
27+
class _FakeCompensationProvider:
28+
def __init__(self) -> None:
29+
self.calls: list[GovernRequest] = []
30+
31+
def compensate(self, request: GovernRequest) -> None:
32+
self.calls.append(request)
33+
34+
35+
def _make_request() -> GovernRequest:
36+
return GovernRequest(
37+
validators=["pii_detection"],
38+
rules=[
39+
FiredRule(
40+
rule_id="ASI-01",
41+
rule_name="Block PII in flight",
42+
pack_name="agent-safety",
43+
validator="pii_detection",
44+
)
45+
],
46+
data={"prompt": "hi"},
47+
hook="before_model",
48+
trace_id="0123456789abcdef0123456789abcdef",
49+
src_timestamp="2026-06-22T10:00:00Z",
50+
agent_name="my-agent",
51+
runtime_id="runtime-1",
52+
)
53+
54+
55+
class TestPolicyContext:
56+
def test_defaults(self) -> None:
57+
ctx = PolicyContext()
58+
assert ctx.is_conversational is None
59+
60+
def test_ignores_unknown_fields(self) -> None:
61+
ctx = PolicyContext.model_validate(
62+
{"is_conversational": True, "future_selector": "x"}
63+
)
64+
assert ctx.is_conversational is True
65+
66+
67+
class TestPolicyResponse:
68+
def test_defaults(self) -> None:
69+
response = PolicyResponse()
70+
assert response.mode is None
71+
assert response.policies == ""
72+
73+
@pytest.mark.parametrize(
74+
("wire_value", "expected"),
75+
[
76+
("audit", EnforcementMode.AUDIT),
77+
("enforce", EnforcementMode.ENFORCE),
78+
("disabled", EnforcementMode.DISABLED),
79+
],
80+
)
81+
def test_parses_known_modes(
82+
self, wire_value: str, expected: EnforcementMode
83+
) -> None:
84+
response = PolicyResponse.model_validate({"mode": wire_value})
85+
assert response.mode is expected
86+
87+
def test_unknown_mode_falls_back_to_none(self) -> None:
88+
# Forward-compat: a server-added mode the SDK doesn't know about
89+
# must not break agent startup. Parses as None so the runtime
90+
# falls back to its safe default rather than raising.
91+
response = PolicyResponse.model_validate({"mode": "ludicrous"})
92+
assert response.mode is None
93+
94+
95+
class TestGovernRequest:
96+
def test_serializes_wire_aliases(self) -> None:
97+
payload = _make_request().model_dump(by_alias=True, exclude_none=True)
98+
assert payload["type"] == ["pii_detection"]
99+
assert payload["traceId"] == "0123456789abcdef0123456789abcdef"
100+
assert payload["agentName"] == "my-agent"
101+
assert payload["runtimeId"] == "runtime-1"
102+
# src_timestamp is intentionally snake_case on the wire.
103+
assert payload["src_timestamp"] == "2026-06-22T10:00:00Z"
104+
# Optional job-context fields left None → excluded.
105+
for absent in (
106+
"folderKey",
107+
"jobKey",
108+
"processKey",
109+
"referenceId",
110+
"agentVersion",
111+
):
112+
assert absent not in payload
113+
114+
115+
class TestProtocolConformance:
116+
"""`runtime_checkable` Protocols should accept structurally-matching objects."""
117+
118+
def test_fake_policy_provider_satisfies_protocol(self) -> None:
119+
provider = _FakePolicyProvider()
120+
assert isinstance(provider, GovernancePolicyProvider)
121+
122+
def test_fake_compensation_provider_satisfies_protocol(self) -> None:
123+
provider = _FakeCompensationProvider()
124+
assert isinstance(provider, GovernanceCompensationProvider)
125+
126+
def test_object_without_methods_rejected(self) -> None:
127+
class _NotAProvider:
128+
pass
129+
130+
assert not isinstance(_NotAProvider(), GovernancePolicyProvider)
131+
assert not isinstance(_NotAProvider(), GovernanceCompensationProvider)
132+
133+
134+
class TestEndToEndDispatch:
135+
"""Caller passes a provider directly to the consumer (no global registry)."""
136+
137+
def test_policy_round_trip(self) -> None:
138+
provider = _FakePolicyProvider()
139+
response = provider.get_policy(PolicyContext(is_conversational=True))
140+
141+
assert response.mode is EnforcementMode.ENFORCE
142+
assert provider.calls == [PolicyContext(is_conversational=True)]
143+
144+
def test_compensation_round_trip(self) -> None:
145+
provider = _FakeCompensationProvider()
146+
request = _make_request()
147+
provider.compensate(request)
148+
149+
assert provider.calls == [request]

packages/uipath-core/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/uipath-platform/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
[project]
22
name = "uipath-platform"
3-
version = "0.1.71"
3+
version = "0.1.73"
44
description = "HTTP client library for programmatic access to UiPath Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
77
dependencies = [
88
"httpx>=0.28.1",
99
"tenacity>=9.0.0",
1010
"truststore>=0.10.1",
11-
"uipath-core>=0.5.20, <0.6.0",
11+
"uipath-core>=0.5.21, <0.6.0",
1212
"pydantic-function-models>=0.1.11",
1313
"sqlparse>=0.5.5",
1414
]

packages/uipath-platform/src/uipath/platform/_uipath.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from .documents import DocumentsService
2323
from .entities import EntitiesService
2424
from .errors import BaseUrlMissingError, SecretMissingError
25+
from .governance import GovernanceService, UiPathPlatformGovernanceProvider
2526
from .guardrails import GuardrailsService
2627
from .memory import MemoryService
2728
from .orchestrator import (
@@ -169,6 +170,20 @@ def mcp(self) -> McpService:
169170
def guardrails(self) -> GuardrailsService:
170171
return GuardrailsService(self._config, self._execution_context)
171172

173+
@cached_property
174+
def governance(self) -> GovernanceService:
175+
return GovernanceService(self._config, self._execution_context)
176+
177+
@cached_property
178+
def governance_provider(self) -> UiPathPlatformGovernanceProvider:
179+
"""Core-protocol-shaped facade over :attr:`governance`.
180+
181+
Use this when handing governance to a runtime/factory that
182+
depends on the :mod:`uipath.core.governance` protocols and must
183+
not import ``uipath-platform`` directly.
184+
"""
185+
return UiPathPlatformGovernanceProvider(service=self.governance)
186+
172187
@property
173188
def agenthub(self) -> AgentHubService:
174189
return AgentHubService(self._config, self._execution_context, self.folders)

0 commit comments

Comments
 (0)