Skip to content

Commit afcf7a5

Browse files
viswa-uipathclaude
andcommitted
feat(core): add get_policy_async to GovernancePolicyProvider protocol
Unblocks architecture-review §2.4 on the uipath-runtime side. The prescription there is to hoist the policy fetch from the runtime-layer ``PolicyLoader`` (which today spins a daemon thread inside an async runtime and blocks on ``threading.Event.wait(timeout=10s)``) up to the async host: the CLI calls ``await provider.get_policy_async(ctx)`` itself, builds the ``PolicyIndex``, and passes the resolved index + mode into ``GovernanceRuntime``. The runtime collapses to a pure, synchronous-to-construct decorator — no thread, no Event, no ``is_conversational`` in the ctor. For that to type-check on the runtime side, the structural ``GovernancePolicyProvider`` Protocol in uipath-core needs to declare ``get_policy_async``. The concrete platform provider (``UiPathPlatformGovernanceProvider``) already implements it; the contract was just lying about what providers expose. Changes - ``GovernancePolicyProvider`` now declares both ``get_policy`` and ``async get_policy_async``. Both required (the platform impl ships both today, and the doc's recommended caller path is the async variant — sync stays for non-event-loop callers like integration tests and CLI tools). - ``_FakePolicyProvider`` in the conformance tests grew the async method and a separate ``async_calls`` recorder. - New ``test_policy_round_trip_async`` exercises the async path via ``@pytest.mark.asyncio`` and pins that the two entry points are independent (calling one doesn't touch the other's recorder). Verified - uipath-core: ruff clean, mypy clean (45 source files), 32 governance tests passed. - uipath-platform: protocol-conformance tests still pass (9 passed) — ``UiPathPlatformGovernanceProvider`` already exposed ``get_policy_async``, so the now-stricter protocol still accepts it structurally. No version bump — rides on the unreleased 0.5.23 that already carries PR #1761's §1.1 (adapter-registry deletion) and §1.2 (typed EvaluatorProtocol returns). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 651a7dd commit afcf7a5

2 files changed

Lines changed: 45 additions & 2 deletions

File tree

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,14 +135,34 @@ class GovernRequest(BaseModel):
135135
class GovernancePolicyProvider(Protocol):
136136
"""Contract for fetching the governance policy pack.
137137
138-
Any object exposing a ``get_policy(context) -> PolicyResponse``
139-
method satisfies this protocol.
138+
Implementations expose both a sync and an async fetch. The async
139+
variant is the preferred entry point for hosts running on an event
140+
loop (the host can overlap policy fetch with the rest of agent
141+
setup via ``asyncio.create_task`` and ``await`` the resolved
142+
:class:`PolicyResponse` before constructing the governance
143+
wrapper). The sync variant is kept for callers outside an event
144+
loop (CLI tools, integration tests).
145+
146+
Any object exposing both ``get_policy(context) -> PolicyResponse``
147+
and ``async def get_policy_async(context) -> PolicyResponse``
148+
satisfies this protocol.
140149
"""
141150

142151
def get_policy(self, context: PolicyContext) -> PolicyResponse:
143152
"""Fetch the policy pack for the active org/tenant."""
144153
...
145154

155+
async def get_policy_async(
156+
self, context: PolicyContext
157+
) -> PolicyResponse:
158+
"""Async variant of :meth:`get_policy`.
159+
160+
Hosts running on an event loop should use this so the fetch
161+
doesn't block the loop and can overlap with other startup
162+
work.
163+
"""
164+
...
165+
146166

147167
@runtime_checkable
148168
class GovernanceCompensationProvider(Protocol):

packages/uipath-core/tests/governance/test_providers.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,16 @@
1818
class _FakePolicyProvider:
1919
def __init__(self) -> None:
2020
self.calls: list[PolicyContext] = []
21+
self.async_calls: list[PolicyContext] = []
2122

2223
def get_policy(self, context: PolicyContext) -> PolicyResponse:
2324
self.calls.append(context)
2425
return PolicyResponse(mode=EnforcementMode.ENFORCE, policies="rules: []")
2526

27+
async def get_policy_async(self, context: PolicyContext) -> PolicyResponse:
28+
self.async_calls.append(context)
29+
return PolicyResponse(mode=EnforcementMode.ENFORCE, policies="rules: []")
30+
2631

2732
class _FakeCompensationProvider:
2833
def __init__(self) -> None:
@@ -141,6 +146,24 @@ def test_policy_round_trip(self) -> None:
141146
assert response.mode is EnforcementMode.ENFORCE
142147
assert provider.calls == [PolicyContext(is_conversational=True)]
143148

149+
@pytest.mark.asyncio
150+
async def test_policy_round_trip_async(self) -> None:
151+
"""The async variant is the preferred entry point for event-loop hosts.
152+
153+
Hosts running ``await provider.get_policy_async(ctx)`` overlap
154+
the fetch with the rest of agent setup; the sync ``get_policy``
155+
path remains for callers outside an event loop.
156+
"""
157+
provider = _FakePolicyProvider()
158+
response = await provider.get_policy_async(
159+
PolicyContext(is_conversational=False)
160+
)
161+
162+
assert response.mode is EnforcementMode.ENFORCE
163+
assert provider.async_calls == [PolicyContext(is_conversational=False)]
164+
# Sync slot stays untouched — the two entrypoints are independent.
165+
assert provider.calls == []
166+
144167
def test_compensation_round_trip(self) -> None:
145168
provider = _FakeCompensationProvider()
146169
request = _make_request()

0 commit comments

Comments
 (0)