Skip to content

Commit acec4dd

Browse files
viswa-uipathclaude
andcommitted
feat(governance): add core protocols and platform service for agenticgovernance_ ingress
Declare GovernancePolicyProvider / GovernanceCompensationProvider in uipath-core alongside EvaluatorProtocol — runtime consumers depend on the lowest layer; concrete providers live outside this package. Add GovernanceService in uipath-platform for the agenticgovernance_ ingress: - retrieve_policy(*, is_conversational=None) — GET the tenant policy pack. - compensate(*, hook, validators, rules, data, trace_id, ...) — POST the compensating /runtime/govern call when a guardrail_fallback rule matches; job-context fields auto-fill from UiPathConfig with caller values (including empty strings) taking precedence over the env-backed fallback. - Honors UIPATH_SERVICE_URL_AGENTICGOVERNANCE for local dev — the override path injects routing headers since the platform router is bypassed. Add UiPathPlatformGovernanceProvider as the dedicated adapter that satisfies both core protocols by delegating to the service; runtime consumers receive this object via constructor injection without importing uipath-platform directly. Add resolve_trace_id() helper in platform common to capture the canonical trace id before hopping off the OTel-context-owning thread. The runtime-side wrapper that consumes these protocols is intentionally not in this PR — it will land in uipath-runtime in a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6ac022a commit acec4dd

19 files changed

Lines changed: 1625 additions & 12 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: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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 ``agenticgovernance_`` ingress.
11+
Defining the contracts here — alongside :class:`EvaluatorProtocol` —
12+
lets runtime consumers depend on stable protocols and receive a
13+
concrete provider via constructor injection. Concrete providers live
14+
outside this package; ``uipath-core`` does not import them.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
from typing import Any, Protocol, runtime_checkable
20+
21+
from pydantic import BaseModel, ConfigDict, Field, field_validator
22+
23+
from .models import EnforcementMode
24+
25+
# ----------------------------------------------------------------------
26+
# Wire-format models
27+
# ----------------------------------------------------------------------
28+
29+
30+
class PolicyContext(BaseModel):
31+
"""Caller-supplied selectors for the policy fetch.
32+
33+
Wrapping the selectors in a model keeps the protocol surface stable
34+
when the server grows new selector dimensions — adding a field here
35+
doesn't change :meth:`GovernancePolicyProvider.get_policy`.
36+
37+
Today carries only :attr:`is_conversational`; future selectors land
38+
here.
39+
"""
40+
41+
model_config = ConfigDict(extra="ignore")
42+
43+
is_conversational: bool | None = None
44+
45+
46+
class PolicyResponse(BaseModel):
47+
"""Parsed governance backend response.
48+
49+
Wire envelope::
50+
51+
{
52+
"mode": "audit" | "enforce" | "disabled",
53+
"policies": "<YAML string>"
54+
}
55+
56+
Attributes:
57+
mode: Platform-controlled enforcement mode for the tenant. May
58+
be ``None`` when the backend omits it. A wire value the SDK
59+
doesn't know about parses as ``None`` rather than raising,
60+
so a server-side mode addition can't break agent startup.
61+
policies: Policy pack YAML the caller compiles into its policy
62+
index. May be an empty string when no rules are configured.
63+
"""
64+
65+
model_config = ConfigDict(extra="ignore")
66+
67+
mode: EnforcementMode | None = Field(default=None)
68+
policies: str = Field(default="")
69+
70+
@field_validator("mode", mode="before")
71+
@classmethod
72+
def _coerce_mode(cls, value: object) -> EnforcementMode | None:
73+
if value is None or isinstance(value, EnforcementMode):
74+
return value
75+
try:
76+
return EnforcementMode(value)
77+
except ValueError:
78+
return None
79+
80+
81+
class FiredRule(BaseModel):
82+
"""Per-rule metadata carried in the ``/runtime/govern`` payload.
83+
84+
One entry per matching ``guardrail_fallback`` condition. The server
85+
writes one LLMOps trace record per entry, so callers must include
86+
every fired rule even when multiple share the same ``validator``.
87+
"""
88+
89+
model_config = ConfigDict(populate_by_name=True)
90+
91+
rule_id: str = Field(alias="ruleId")
92+
rule_name: str = Field(alias="ruleName")
93+
pack_name: str = Field(alias="packName")
94+
validator: str
95+
96+
97+
class GovernRequest(BaseModel):
98+
"""Request body for the ``/runtime/govern`` compensating governance POST.
99+
100+
Field aliases match the on-the-wire JSON keys. ``src_timestamp`` is
101+
snake_case on the wire (intentional — preserved verbatim); every
102+
other key is camelCase.
103+
104+
Job-context fields (``folder_key`` / ``job_key`` / ``process_key`` /
105+
``reference_id`` / ``agent_version``) are optional: the platform
106+
fills them from :class:`UiPathConfig` at call time when the caller
107+
leaves them ``None``, and emits the key only when a value resolves.
108+
"""
109+
110+
model_config = ConfigDict(populate_by_name=True)
111+
112+
validators: list[str] = Field(alias="type")
113+
rules: list[FiredRule]
114+
data: dict[str, Any]
115+
hook: str
116+
trace_id: str = Field(alias="traceId")
117+
src_timestamp: str # wire key is intentionally snake_case
118+
agent_name: str = Field(alias="agentName")
119+
runtime_id: str = Field(alias="runtimeId")
120+
121+
folder_key: str | None = Field(default=None, alias="folderKey")
122+
job_key: str | None = Field(default=None, alias="jobKey")
123+
process_key: str | None = Field(default=None, alias="processKey")
124+
reference_id: str | None = Field(default=None, alias="referenceId")
125+
agent_version: str | None = Field(default=None, alias="agentVersion")
126+
127+
128+
# ----------------------------------------------------------------------
129+
# Provider protocols
130+
# ----------------------------------------------------------------------
131+
132+
133+
@runtime_checkable
134+
class GovernancePolicyProvider(Protocol):
135+
"""Contract for fetching the governance policy pack.
136+
137+
Any object exposing a ``get_policy(context) -> PolicyResponse``
138+
method satisfies this protocol.
139+
"""
140+
141+
def get_policy(self, context: PolicyContext) -> PolicyResponse:
142+
"""Fetch the policy pack for the active org/tenant."""
143+
...
144+
145+
146+
@runtime_checkable
147+
class GovernanceCompensationProvider(Protocol):
148+
"""Contract for firing the compensating ``/runtime/govern`` POST."""
149+
150+
def compensate(self, request: GovernRequest) -> None:
151+
"""Fire the compensating governance POST. Fire-and-forget."""
152+
...
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: 5 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
2526
from .guardrails import GuardrailsService
2627
from .memory import MemoryService
2728
from .orchestrator import (
@@ -169,6 +170,10 @@ 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+
172177
@property
173178
def agenthub(self) -> AgentHubService:
174179
return AgentHubService(self._config, self._execution_context, self.folders)

packages/uipath-platform/src/uipath/platform/common/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"""
55

66
from ._api_client import ApiClient
7-
from ._base_service import BaseService
7+
from ._base_service import BaseService, resolve_trace_id
88
from ._bindings import (
99
ConnectionResourceOverwrite,
1010
EntityResourceOverwrite,
@@ -112,6 +112,7 @@
112112
"_SpanUtils",
113113
"resolve_service_url",
114114
"inject_routing_headers",
115+
"resolve_trace_id",
115116
]
116117

117118
from .validation import validate_pagination_params

0 commit comments

Comments
 (0)