From b523a6d04c6982ef22c32a24d100730541885fce Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Mon, 29 Jun 2026 15:07:23 +0530 Subject: [PATCH] refactor(core): drop AdapterRegistry + BaseAdapter; keep EvaluatorProtocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete the AdapterRegistry / BaseAdapter / GovernedAgentBase plumbing from uipath-core plus the uipath.governance.adapters entry-point group and the get_adapter_registry / reset_adapter_registry helpers. Keep EvaluatorProtocol — the one contract framework plugins still consume. The adapter registry was a second plugin-discovery system keyed on the same fact (which framework is active) that UiPathRuntimeFactoryRegistry already resolves statically. It existed to support an open-PR pattern where the runtime layer would sniff the framework off an opaque agent; that approach is being abandoned in favor of framework plugins wiring governance at their own native seam, so the registry becomes dead weight. Grep across the monorepo confirms zero remaining consumers outside the deleted files. Companion changes landing in the same PR ---------------------------------------- - EvaluatorProtocol returns are typed as AuditRecord (was an untyped list); the trace_id field is dropped from AuditRecord since per-evaluation trace ids aren't part of the contract. - GovernancePolicyProvider grows get_policy_async so async hosts can fetch the policy pack without thread-pool offloading. - GovernRequest.trace_id models absence as str | None (default None) instead of a magic empty-string sentinel — core no longer encodes the "empty means resolve at the platform boundary" platform-side convention. ``""`` is now a legitimate caller value distinct from absence; ``None`` lets the concrete provider fill it. - GovernanceService.compensate self-resolves trace_id only when the caller passes None; ``""`` and any other value pass through. The _resolve_request_trace_id helper switched from a truthiness check to an explicit `is not None` check. - uipath-core governance docstrings drop all uipath-platform / uipath-runtime name-drops (per radu's PR #1738 layering rule) — AuditRecord, GovernRequest, the module __init__ and config.py now describe the contracts without referencing higher-layer packages. Version bumps and pins ---------------------- - uipath-core 0.5.22 → 0.5.24 - uipath-platform 0.1.7x → 0.1.79; pin raised to uipath-core>=0.5.24 - uipath is unchanged (no source-level edits in this PR); its uv.lock refreshes only to pick up the bumped uipath-core / uipath-platform versions Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/adapters/__init__.py | 27 +- .../src/uipath/core/adapters/base.py | 116 ----- .../src/uipath/core/adapters/evaluator.py | 37 +- .../src/uipath/core/adapters/registry.py | 176 ------- .../src/uipath/core/governance/__init__.py | 11 +- .../src/uipath/core/governance/config.py | 8 +- .../src/uipath/core/governance/models.py | 15 +- .../src/uipath/core/governance/providers.py | 29 +- .../uipath-core/tests/adapters/test_base.py | 163 ------ .../tests/adapters/test_evaluator.py | 12 +- .../tests/adapters/test_registry.py | 492 ------------------ .../tests/governance/test_exceptions.py | 1 - .../tests/governance/test_providers.py | 23 + packages/uipath-core/uv.lock | 2 +- packages/uipath-platform/pyproject.toml | 4 +- .../governance/_governance_service.py | 58 ++- .../tests/services/test_governance_service.py | 93 ++++ packages/uipath-platform/uv.lock | 4 +- packages/uipath/uv.lock | 4 +- 20 files changed, 243 insertions(+), 1034 deletions(-) delete mode 100644 packages/uipath-core/src/uipath/core/adapters/base.py delete mode 100644 packages/uipath-core/src/uipath/core/adapters/registry.py delete mode 100644 packages/uipath-core/tests/adapters/test_base.py delete mode 100644 packages/uipath-core/tests/adapters/test_registry.py diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 93be7ebc0..22d665720 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.23" +version = "0.5.25" 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/adapters/__init__.py b/packages/uipath-core/src/uipath/core/adapters/__init__.py index 5906b1b39..c3675b404 100644 --- a/packages/uipath-core/src/uipath/core/adapters/__init__.py +++ b/packages/uipath-core/src/uipath/core/adapters/__init__.py @@ -2,34 +2,19 @@ This package holds only the abstract contracts — concrete adapter implementations live in framework-specific plugin packages (e.g. -``uipath-langchain``, ``uipath-openai``) that target the framework they -integrate with. Plugin packages register their concrete adapters with -the global :class:`AdapterRegistry` via the -``uipath.governance.adapters`` entry-point group. +``uipath-langchain``, ``uipath-openai``). A framework plugin is the one +that knows its own native wiring seam (callback handler list, hook +registry, …) and installs governance there directly; uipath-core only +defines the protocol an evaluator must satisfy. Public surface: -- :class:`BaseAdapter` – abstract base every adapter inherits from. -- :class:`GovernedAgentBase` – proxy base for governed agent wrappers. -- :class:`EvaluatorProtocol` – structural protocol the adapter expects - from any policy evaluator. -- :class:`AdapterRegistry` – ordered list of adapters that resolves - the first match for a given agent. +- :class:`EvaluatorProtocol` – structural protocol the framework + plugin expects from any policy evaluator. """ -from .base import BaseAdapter, GovernedAgentBase from .evaluator import EvaluatorProtocol -from .registry import ( - AdapterRegistry, - get_adapter_registry, - reset_adapter_registry, -) __all__ = [ - "BaseAdapter", - "GovernedAgentBase", "EvaluatorProtocol", - "AdapterRegistry", - "get_adapter_registry", - "reset_adapter_registry", ] diff --git a/packages/uipath-core/src/uipath/core/adapters/base.py b/packages/uipath-core/src/uipath/core/adapters/base.py deleted file mode 100644 index 3afaad6a7..000000000 --- a/packages/uipath-core/src/uipath/core/adapters/base.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Base adapter contracts for framework-specific integrations. - -An adapter's job: - -1. Detect whether it can handle a given agent object. -2. Attach hooks to that agent (framework-specific). -3. Publish events to a policy evaluator when those hooks fire. - -The evaluator subscribes to events and runs policy checks; it never -knows or cares which adapter fired the event. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import Any -from uuid import uuid4 - -from .evaluator import EvaluatorProtocol - - -class BaseAdapter(ABC): - """Base class for framework-specific governance adapters.""" - - #: Higher value = more specific = inserted earlier in the registry. - #: Plugin authors should set this above ``0`` on adapters that target - #: a narrower agent type than another already-registered adapter, so - #: the specific one wins ``can_handle`` resolution regardless of the - #: order in which plugins happen to be imported. Among adapters with - #: the same priority, registration order is preserved (stable). - priority: int = 0 - - #: Set to True on a catch-all adapter that should always sort last in - #: the registry. The registry uses this flag (not the class name or - #: :attr:`priority`) to keep the fallback in last position when new - #: adapters register. - is_fallback: bool = False - - @property - def name(self) -> str: - """Return adapter name for logging.""" - return self.__class__.__name__ - - @abstractmethod - def can_handle(self, agent: Any) -> bool: - """Return True if this adapter knows how to hook into this agent type.""" - - @abstractmethod - def attach( - self, - agent: Any, - agent_id: str, - session_id: str, - evaluator: EvaluatorProtocol, - ) -> Any: - """Attach governance hooks to the agent. - - Args: - agent: The agent to govern. - agent_id: Unique identifier for the agent. - session_id: Session identifier for tracing. - evaluator: Policy evaluator implementing - :class:`EvaluatorProtocol`. - - Returns: - A governed proxy (or the original agent with hooks installed). - """ - - def detach(self, governed: Any) -> Any: - """Detach governance and return the original agent. - - Default implementation uses the public :attr:`GovernedAgentBase.unwrapped` - contract; non-proxy adapters that return the original agent from - :meth:`attach` get back ``governed`` unchanged. - """ - return getattr(governed, "unwrapped", governed) - - def _generate_trace_id(self) -> str: - """Generate a trace ID for governance events.""" - return str(uuid4()) - - -class GovernedAgentBase: - """Base class for governed agent proxies. - - Provides common functionality for all governed agents: - - - Stores reference to original agent - - Forwards unknown attributes to original agent - - Tracks governance metadata - """ - - def __init__( - self, - agent: Any, - adapter: BaseAdapter, - agent_id: str, - session_id: str, - evaluator: EvaluatorProtocol, - ) -> None: - """Initialize with the wrapped agent and governance metadata.""" - self._agent = agent - self._adapter = adapter - self._agent_id = agent_id - self._session_id = session_id - self._evaluator = evaluator - self._trace_id = adapter._generate_trace_id() - - @property - def unwrapped(self) -> Any: - """Get the original unwrapped agent.""" - return self._agent - - def __getattr__(self, name: str) -> Any: - """Forward attribute access to the original agent.""" - return getattr(self._agent, name) diff --git a/packages/uipath-core/src/uipath/core/adapters/evaluator.py b/packages/uipath-core/src/uipath/core/adapters/evaluator.py index ee5b92dad..4f25097c5 100644 --- a/packages/uipath-core/src/uipath/core/adapters/evaluator.py +++ b/packages/uipath-core/src/uipath/core/adapters/evaluator.py @@ -1,9 +1,9 @@ -"""Structural contract for the policy evaluator an adapter talks to. +"""Structural contract for the policy evaluator a framework plugin talks to. -Framework adapters call into a policy evaluator at each lifecycle hook. +Framework plugins call into a policy evaluator at each lifecycle hook. Concrete evaluator implementations (the native runtime evaluator, a Microsoft AGT bridge, a composite, …) live in packages outside -``uipath-core`` — adapters depend only on this structural protocol so +``uipath-core`` — plugins depend only on this structural protocol so they can be swapped against any of them without code change. ``EvaluatorProtocol`` is a :class:`typing.Protocol` so any class whose @@ -15,15 +15,18 @@ from typing import Any, Protocol, runtime_checkable +from uipath.core.governance.models import AuditRecord + @runtime_checkable class EvaluatorProtocol(Protocol): - """Structural protocol an adapter expects from a policy evaluator. + """Structural protocol a framework plugin expects from a policy evaluator. - Return types are intentionally :class:`typing.Any`: the concrete - audit record shape lives in the plugin package that owns the - evaluator and the policy model. Adapters in that package cast the - return value back to the concrete type they know. + Every ``evaluate_*`` method returns an :class:`AuditRecord` — the + per-hook audit envelope holding the per-rule + :class:`RuleEvaluation` list, the final action, and the trace / + agent metadata. Callers get a typed result; no downcasting is + required. """ def evaluate_before_agent( @@ -31,10 +34,9 @@ def evaluate_before_agent( agent_input: str, agent_name: str, runtime_id: str, - trace_id: str, model_name: str = "", **kwargs: Any, - ) -> Any: + ) -> AuditRecord: """Evaluate BEFORE_AGENT rules.""" ... @@ -43,9 +45,8 @@ def evaluate_after_agent( agent_output: str, agent_name: str, runtime_id: str, - trace_id: str, **kwargs: Any, - ) -> Any: + ) -> AuditRecord: """Evaluate AFTER_AGENT rules.""" ... @@ -54,11 +55,10 @@ def evaluate_before_model( model_input: str, agent_name: str, runtime_id: str, - trace_id: str, messages: list[dict[str, Any]] | None = None, model_name: str = "", **kwargs: Any, - ) -> Any: + ) -> AuditRecord: """Evaluate BEFORE_MODEL rules.""" ... @@ -67,9 +67,8 @@ def evaluate_after_model( model_output: str, agent_name: str, runtime_id: str, - trace_id: str, **kwargs: Any, - ) -> Any: + ) -> AuditRecord: """Evaluate AFTER_MODEL rules.""" ... @@ -79,10 +78,9 @@ def evaluate_tool_call( tool_args: dict[str, Any], agent_name: str, runtime_id: str, - trace_id: str, session_state: dict[str, Any] | None = None, **kwargs: Any, - ) -> Any: + ) -> AuditRecord: """Evaluate TOOL_CALL rules.""" ... @@ -92,8 +90,7 @@ def evaluate_after_tool( tool_result: str, agent_name: str, runtime_id: str, - trace_id: str, **kwargs: Any, - ) -> Any: + ) -> AuditRecord: """Evaluate AFTER_TOOL rules.""" ... diff --git a/packages/uipath-core/src/uipath/core/adapters/registry.py b/packages/uipath-core/src/uipath/core/adapters/registry.py deleted file mode 100644 index adebe780a..000000000 --- a/packages/uipath-core/src/uipath/core/adapters/registry.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Ordered registry of framework adapters. - -The registry is a pure, implementation-agnostic container — it does -**not** know about any concrete adapter. Plugin packages (e.g. -``uipath-langchain``) populate it by either: - -1. Declaring a ``uipath.governance.adapters`` entry point whose value - is a zero-arg callable that calls :meth:`AdapterRegistry.register`. - These are auto-discovered on first call to - :func:`get_adapter_registry`. -2. Calling :meth:`AdapterRegistry.register` directly at import time - (e.g. side-effect on importing the plugin's governance submodule). - -Adapters are checked in priority order (highest first): more specific -adapters get a higher :attr:`BaseAdapter.priority` so they win -``can_handle`` resolution over generic ones, regardless of the order in -which plugin packages happen to be imported. Among adapters with the -same priority, registration order is preserved. Adapters with -``is_fallback=True`` sort last when registered without an explicit -``position`` — passing ``position`` to :meth:`AdapterRegistry.register` -is an escape hatch that bypasses both priority and fallback ordering, -so callers using it own the resulting list order. -""" - -from __future__ import annotations - -import logging -from typing import Any - -from .base import BaseAdapter - -logger = logging.getLogger(__name__) - -ENTRY_POINT_GROUP = "uipath.governance.adapters" - - -class AdapterRegistry: - """Ordered list of adapters; resolves the first match for an agent.""" - - def __init__(self) -> None: - """Initialize an empty registry.""" - self._adapters: list[BaseAdapter] = [] - - def register(self, adapter: BaseAdapter, position: int | None = None) -> None: - """Register an adapter. - - Args: - adapter: The adapter to register. - position: Explicit insertion index (``0`` = highest priority) - that bypasses both priority-based ordering AND fallback - semantics — the adapter is inserted blindly at the given - index, so callers using ``position`` are responsible for - not placing a fallback before a specific adapter (or a - specific adapter after an existing fallback). When - ``None`` the adapter is inserted by - :attr:`BaseAdapter.priority` (higher first, stable on - ties) and before any adapter marked - :attr:`BaseAdapter.is_fallback`; adapters whose own - ``is_fallback`` is set are appended last. - """ - if position is not None: - self._adapters.insert(position, adapter) - elif adapter.is_fallback: - self._adapters.append(adapter) - else: - insert_at = len(self._adapters) - for i, existing in enumerate(self._adapters): - if existing.is_fallback or existing.priority < adapter.priority: - insert_at = i - break - self._adapters.insert(insert_at, adapter) - logger.debug("Registered adapter: %s", adapter.name) - - def resolve(self, agent: Any) -> BaseAdapter | None: - """Return the first adapter that can handle ``agent`` (or ``None``).""" - for adapter in self._adapters: - try: - if adapter.can_handle(agent): - logger.debug( - "AdapterRegistry: %s -> %s", - type(agent).__name__, - adapter.name, - ) - return adapter - except Exception as exc: - logger.warning( - "Adapter %s.can_handle() failed: %s", - adapter.name, - exc, - ) - continue - return None - - def get_all(self) -> list[BaseAdapter]: - """Return a copy of the registered adapters in priority order.""" - return self._adapters.copy() - - def clear(self) -> None: - """Remove all registered adapters.""" - self._adapters.clear() - - -_registry: AdapterRegistry | None = None - - -def _discover_entry_point_adapters() -> None: - """Load every adapter advertised under the ``uipath.governance.adapters`` group. - - Each entry-point value must be a zero-arg callable (typically a - ``register_*`` function in the plugin package) that calls - :meth:`AdapterRegistry.register`. A failure to load or invoke any - one entry point is logged and skipped — a single broken plugin - must never block governance startup. - """ - try: - from importlib.metadata import entry_points - except ImportError: # pragma: no cover - importlib.metadata is stdlib in py3.11+ - return - - try: - eps = entry_points(group=ENTRY_POINT_GROUP) - except Exception as exc: # noqa: BLE001 - discovery failures must never raise - logger.debug("Adapter entry-point discovery failed: %s", exc, exc_info=True) - return - - for ep in eps: - try: - registrar = ep.load() - except Exception as exc: # noqa: BLE001 - one broken plugin must not block others - logger.debug( - "Failed to load governance adapter entry point '%s' (%s): %s", - ep.name, - ep.value, - exc, - exc_info=True, - ) - continue - if not callable(registrar): - logger.warning( - "Governance adapter entry point '%s' is not callable: %r", - ep.name, - registrar, - ) - continue - try: - registrar() - except Exception as exc: # noqa: BLE001 - one broken plugin must not block others - logger.debug( - "Governance adapter '%s' register call failed: %s", - ep.name, - exc, - exc_info=True, - ) - - -def get_adapter_registry() -> AdapterRegistry: - """Return the process-wide adapter registry singleton. - - On first call, discovers and registers every adapter declared under - the ``uipath.governance.adapters`` entry-point group, so framework - SDKs (``uipath-langchain``, ``uipath-openai``, …) just need to be - installed — no explicit import is required. - """ - global _registry - if _registry is None: - _registry = AdapterRegistry() - _discover_entry_point_adapters() - return _registry - - -def reset_adapter_registry() -> None: - """Drop the singleton registry (intended for tests).""" - global _registry - if _registry is not None: - _registry.clear() - _registry = None diff --git a/packages/uipath-core/src/uipath/core/governance/__init__.py b/packages/uipath-core/src/uipath/core/governance/__init__.py index e3dcab741..4bf855b82 100644 --- a/packages/uipath-core/src/uipath/core/governance/__init__.py +++ b/packages/uipath-core/src/uipath/core/governance/__init__.py @@ -1,11 +1,10 @@ """UiPath governance shared contracts. -Evaluator-agnostic types every governance consumer references — -adapter packages (``uipath-langchain``, ``uipath-openai``, …), the -runtime layer (``uipath.runtime.governance``), and customer code that -catches :class:`GovernanceBlockException`. The full runtime / audit / -native-evaluator implementation lives in ``uipath.runtime.governance``; -this core surface is just the contracts. +Evaluator-agnostic types every governance consumer references — the +runtime layer, adapter packages, and customer code that catches +:class:`GovernanceBlockException`. The full runtime / audit / +native-evaluator implementation lives outside this package; this +core surface is just the contracts. """ from .config import ( diff --git a/packages/uipath-core/src/uipath/core/governance/config.py b/packages/uipath-core/src/uipath/core/governance/config.py index 7fec33848..cbcbd577a 100644 --- a/packages/uipath-core/src/uipath/core/governance/config.py +++ b/packages/uipath-core/src/uipath/core/governance/config.py @@ -5,7 +5,7 @@ :class:`uipath.core.governance.EnforcementMode` value type is defined in :mod:`uipath.core.governance.models`; the per-policy runtime state that selects a mode (backend-supplied via the ``/runtime/policy`` -client) lives in the ``uipath-runtime`` package. +client) lives outside this package. """ from __future__ import annotations @@ -13,9 +13,9 @@ from uipath.core.feature_flags import FeatureFlags # Feature flag name controlling whether governance runs. -# Mirrors the gate in ``uipath-runtime`` so the platform-injection path -# and direct callers (agents constructing an evaluator themselves) -# honour the same toggle. +# A single shared gate so the host-driven injection path and direct +# callers (agents constructing an evaluator themselves) honour the +# same toggle. GOVERNANCE_FEATURE_FLAG = "EnablePythonGovernanceChecker" diff --git a/packages/uipath-core/src/uipath/core/governance/models.py b/packages/uipath-core/src/uipath/core/governance/models.py index 9fc5e2084..29dccc121 100644 --- a/packages/uipath-core/src/uipath/core/governance/models.py +++ b/packages/uipath-core/src/uipath/core/governance/models.py @@ -9,9 +9,9 @@ boundary at evaluation time: every evaluator implementation (native, AGT, composite, …) produces them, and every adapter consumes them. - **Configuration value types** (:class:`EnforcementMode`) — describe - governance configuration shared by core, runtime, and consumers. The - runtime state that selects an enforcement mode lives in - ``uipath-runtime``; only the value type lives here. + governance configuration shared by core and its consumers. The + per-policy runtime state that selects a mode lives outside this + package; only the value type lives here. """ from __future__ import annotations @@ -66,12 +66,17 @@ class RuleEvaluation: @dataclass class AuditRecord: - """Complete audit record for a governance evaluation.""" + """Complete audit record for a governance evaluation. + + ``trace_id`` is intentionally absent. Trace correlation is resolved + by the concrete provider at request time (via OpenTelemetry's + native span identity) — per-evaluation trace ids aren't part of + the audit-record contract. + """ timestamp: datetime agent_name: str runtime_id: str - trace_id: str hook: LifecycleHook evaluations: list[RuleEvaluation] final_action: Action diff --git a/packages/uipath-core/src/uipath/core/governance/providers.py b/packages/uipath-core/src/uipath/core/governance/providers.py index 29f435edb..2a0b7155a 100644 --- a/packages/uipath-core/src/uipath/core/governance/providers.py +++ b/packages/uipath-core/src/uipath/core/governance/providers.py @@ -106,6 +106,11 @@ class GovernRequest(BaseModel): 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. + + ``trace_id`` is optional. When ``None`` the field is omitted from + the wire JSON (via ``exclude_none=True`` at serialisation). Whether + a concrete provider chooses to populate a missing value before + sending is the provider's concern, not part of this contract. """ model_config = ConfigDict(populate_by_name=True) @@ -114,7 +119,7 @@ class GovernRequest(BaseModel): rules: list[FiredRule] data: dict[str, Any] hook: str - trace_id: str = Field(alias="traceId") + trace_id: str | None = Field(default=None, alias="traceId") src_timestamp: str # wire key is intentionally snake_case agent_name: str = Field(alias="agentName") runtime_id: str = Field(alias="runtimeId") @@ -135,14 +140,32 @@ class GovernRequest(BaseModel): class GovernancePolicyProvider(Protocol): """Contract for fetching the governance policy pack. - Any object exposing a ``get_policy(context) -> PolicyResponse`` - method satisfies this protocol. + Implementations expose both a sync and an async fetch. The async + variant is the preferred entry point for hosts running on an event + loop (the host can overlap policy fetch with the rest of agent + setup via ``asyncio.create_task`` and ``await`` the resolved + :class:`PolicyResponse` before constructing the governance + wrapper). The sync variant is kept for callers outside an event + loop (CLI tools, integration tests). + + Any object exposing both ``get_policy(context) -> PolicyResponse`` + and ``async def get_policy_async(context) -> PolicyResponse`` + satisfies this protocol. """ def get_policy(self, context: PolicyContext) -> PolicyResponse: """Fetch the policy pack for the active org/tenant.""" ... + async def get_policy_async(self, context: PolicyContext) -> PolicyResponse: + """Async variant of :meth:`get_policy`. + + Hosts running on an event loop should use this so the fetch + doesn't block the loop and can overlap with other startup + work. + """ + ... + @runtime_checkable class GovernanceCompensationProvider(Protocol): diff --git a/packages/uipath-core/tests/adapters/test_base.py b/packages/uipath-core/tests/adapters/test_base.py deleted file mode 100644 index 9be6346ed..000000000 --- a/packages/uipath-core/tests/adapters/test_base.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Tests for BaseAdapter defaults and GovernedAgentBase proxy behavior.""" - -from __future__ import annotations - -from typing import Any - -import pytest - -from uipath.core.adapters import BaseAdapter, EvaluatorProtocol -from uipath.core.adapters.base import GovernedAgentBase - - -class _StubEvaluator: - """No-op evaluator that structurally matches EvaluatorProtocol.""" - - def evaluate_before_agent(self, *args: Any, **kwargs: Any) -> Any: - return None - - def evaluate_after_agent(self, *args: Any, **kwargs: Any) -> Any: - return None - - def evaluate_before_model(self, *args: Any, **kwargs: Any) -> Any: - return None - - def evaluate_after_model(self, *args: Any, **kwargs: Any) -> Any: - return None - - def evaluate_tool_call(self, *args: Any, **kwargs: Any) -> Any: - return None - - def evaluate_after_tool(self, *args: Any, **kwargs: Any) -> Any: - return None - - -class _MinimalAdapter(BaseAdapter): - """Concrete adapter that does NOT override ``name`` — exercises the default.""" - - def can_handle(self, agent: Any) -> bool: - return True - - def attach( - self, - agent: Any, - agent_id: str, - session_id: str, - evaluator: EvaluatorProtocol, - ) -> Any: - return agent - - -class _Agent: - """Simple stand-in for a framework agent with one attribute and one method.""" - - foo = "bar" - - def greet(self) -> str: - return "hello" - - -# --------------------------------------------------------------------------- -# BaseAdapter defaults -# --------------------------------------------------------------------------- - - -def test_default_name_is_class_name(): - """The default ``name`` property returns the class name.""" - assert _MinimalAdapter().name == "_MinimalAdapter" - - -def test_detach_returns_unwrapped_when_present(): - """``detach`` honours the ``unwrapped`` contract on a governed proxy.""" - adapter = _MinimalAdapter() - original = object() - - class _Proxy: - unwrapped = original - - assert adapter.detach(_Proxy()) is original - - -def test_detach_returns_input_when_no_unwrapped_attribute(): - """For non-proxy adapters, ``detach`` returns the input unchanged.""" - adapter = _MinimalAdapter() - raw = object() - assert adapter.detach(raw) is raw - - -def test_generate_trace_id_returns_unique_uuid_string(): - """``_generate_trace_id`` returns a string UUID; consecutive calls differ.""" - adapter = _MinimalAdapter() - a = adapter._generate_trace_id() - b = adapter._generate_trace_id() - assert isinstance(a, str) - assert a != b - assert len(a) == 36 # canonical UUID4 form: 32 hex + 4 dashes - - -# --------------------------------------------------------------------------- -# GovernedAgentBase proxy -# --------------------------------------------------------------------------- - - -def test_governed_agent_base_stores_metadata_and_generates_trace_id(): - """Constructor wires every governance field and pulls a trace id from the adapter.""" - agent = _Agent() - adapter = _MinimalAdapter() - evaluator = _StubEvaluator() - - governed = GovernedAgentBase( - agent=agent, - adapter=adapter, - agent_id="agent-123", - session_id="session-abc", - evaluator=evaluator, - ) - - assert governed._agent is agent - assert governed._adapter is adapter - assert governed._agent_id == "agent-123" - assert governed._session_id == "session-abc" - assert governed._evaluator is evaluator - assert isinstance(governed._trace_id, str) - assert len(governed._trace_id) == 36 - - -def test_governed_agent_base_unwrapped_returns_original_agent(): - agent = _Agent() - governed = GovernedAgentBase( - agent=agent, - adapter=_MinimalAdapter(), - agent_id="a", - session_id="s", - evaluator=_StubEvaluator(), - ) - assert governed.unwrapped is agent - - -def test_governed_agent_base_forwards_attribute_access_to_agent(): - """Unknown attributes fall through to the wrapped agent via __getattr__.""" - governed = GovernedAgentBase( - agent=_Agent(), - adapter=_MinimalAdapter(), - agent_id="a", - session_id="s", - evaluator=_StubEvaluator(), - ) - - assert governed.foo == "bar" - assert governed.greet() == "hello" - - -def test_governed_agent_base_attribute_miss_raises_attribute_error(): - """If the wrapped agent also lacks the attribute, AttributeError surfaces.""" - governed = GovernedAgentBase( - agent=_Agent(), - adapter=_MinimalAdapter(), - agent_id="a", - session_id="s", - evaluator=_StubEvaluator(), - ) - - with pytest.raises(AttributeError): - _ = governed.does_not_exist diff --git a/packages/uipath-core/tests/adapters/test_evaluator.py b/packages/uipath-core/tests/adapters/test_evaluator.py index 5c9e5c9e5..83aa70b5e 100644 --- a/packages/uipath-core/tests/adapters/test_evaluator.py +++ b/packages/uipath-core/tests/adapters/test_evaluator.py @@ -96,9 +96,9 @@ def test_protocol_subclass_methods_execute_stub_bodies(): """Calling each method via ``super()`` executes the stub body and returns None.""" e = _ProtocolSubclass() - assert e.evaluate_before_agent("input", "agent", "rt", "trace") is None - assert e.evaluate_after_agent("output", "agent", "rt", "trace") is None - assert e.evaluate_before_model("input", "agent", "rt", "trace") is None - assert e.evaluate_after_model("output", "agent", "rt", "trace") is None - assert e.evaluate_tool_call("tool", {"arg": 1}, "agent", "rt", "trace") is None - assert e.evaluate_after_tool("tool", "result", "agent", "rt", "trace") is None + assert e.evaluate_before_agent("input", "agent", "rt") is None + assert e.evaluate_after_agent("output", "agent", "rt") is None + assert e.evaluate_before_model("input", "agent", "rt") is None + assert e.evaluate_after_model("output", "agent", "rt") is None + assert e.evaluate_tool_call("tool", {"arg": 1}, "agent", "rt") is None + assert e.evaluate_after_tool("tool", "result", "agent", "rt") is None diff --git a/packages/uipath-core/tests/adapters/test_registry.py b/packages/uipath-core/tests/adapters/test_registry.py deleted file mode 100644 index b16b29b1e..000000000 --- a/packages/uipath-core/tests/adapters/test_registry.py +++ /dev/null @@ -1,492 +0,0 @@ -"""Tests for AdapterRegistry — ordering, resolution, entry-point discovery.""" - -from __future__ import annotations - -from typing import Any -from unittest.mock import MagicMock - -import pytest - -from uipath.core.adapters import BaseAdapter, EvaluatorProtocol -from uipath.core.adapters.registry import ( - AdapterRegistry, - _discover_entry_point_adapters, - get_adapter_registry, - reset_adapter_registry, -) - -# --------------------------------------------------------------------------- -# Test adapters -# --------------------------------------------------------------------------- - - -class _SpecificAdapter(BaseAdapter): - """Matches only objects with a ``__specific__`` marker.""" - - @property - def name(self) -> str: - return "specific" - - def can_handle(self, agent: Any) -> bool: - return hasattr(agent, "__specific__") - - def attach( - self, - agent: Any, - agent_id: str, - session_id: str, - evaluator: EvaluatorProtocol, - ) -> Any: - return agent - - -class _FallbackAdapter(BaseAdapter): - """Matches anything — must always sort last.""" - - is_fallback = True - - @property - def name(self) -> str: - return "fallback" - - def can_handle(self, agent: Any) -> bool: - return True - - def attach( - self, - agent: Any, - agent_id: str, - session_id: str, - evaluator: EvaluatorProtocol, - ) -> Any: - return agent - - -class _SecondaryAdapter(BaseAdapter): - """Another specific adapter, used to test ordering between two specifics.""" - - @property - def name(self) -> str: - return "secondary" - - def can_handle(self, agent: Any) -> bool: - return hasattr(agent, "__secondary__") - - def attach( - self, - agent: Any, - agent_id: str, - session_id: str, - evaluator: EvaluatorProtocol, - ) -> Any: - return agent - - -class _HighPriorityAdapter(BaseAdapter): - """Specific adapter with an elevated priority.""" - - priority = 100 - - @property - def name(self) -> str: - return "high" - - def can_handle(self, agent: Any) -> bool: - return True - - def attach( - self, - agent: Any, - agent_id: str, - session_id: str, - evaluator: EvaluatorProtocol, - ) -> Any: - return agent - - -class _LowPriorityAdapter(BaseAdapter): - """Generic adapter that should yield to higher-priority specifics.""" - - priority = -10 - - @property - def name(self) -> str: - return "low" - - def can_handle(self, agent: Any) -> bool: - return True - - def attach( - self, - agent: Any, - agent_id: str, - session_id: str, - evaluator: EvaluatorProtocol, - ) -> Any: - return agent - - -class _BrokenAdapter(BaseAdapter): - """``can_handle`` raises — must be skipped, not crash resolution.""" - - @property - def name(self) -> str: - return "broken" - - def can_handle(self, agent: Any) -> bool: - raise RuntimeError("can_handle exploded") - - def attach( - self, - agent: Any, - agent_id: str, - session_id: str, - evaluator: EvaluatorProtocol, - ) -> Any: - raise RuntimeError("attach exploded") - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture(autouse=True) -def _isolate_global_registry(): - """Each test starts with no singleton registry.""" - reset_adapter_registry() - yield - reset_adapter_registry() - - -# --------------------------------------------------------------------------- -# register / resolve / get_all / clear -# --------------------------------------------------------------------------- - - -def test_empty_registry_resolves_to_none(): - reg = AdapterRegistry() - assert reg.resolve(object()) is None - assert reg.get_all() == [] - - -def test_register_appends_in_order(): - reg = AdapterRegistry() - a, b = _SpecificAdapter(), _SecondaryAdapter() - reg.register(a) - reg.register(b) - assert reg.get_all() == [a, b] - - -def test_resolve_returns_first_matching_adapter(): - reg = AdapterRegistry() - reg.register(_SpecificAdapter()) - reg.register(_SecondaryAdapter()) - - agent = MagicMock() - agent.__secondary__ = True # only secondary should match - resolved = reg.resolve(agent) - assert resolved is not None - assert resolved.name == "secondary" - - -def test_resolve_skips_broken_can_handle_and_continues(): - """A can_handle() that raises must not break the whole resolve loop.""" - reg = AdapterRegistry() - reg.register(_BrokenAdapter()) - reg.register(_SpecificAdapter()) - - agent = MagicMock() - agent.__specific__ = True - resolved = reg.resolve(agent) - assert resolved is not None - assert resolved.name == "specific" - - -def test_register_position_inserts_at_index(): - reg = AdapterRegistry() - a, b, c = _SpecificAdapter(), _SecondaryAdapter(), _SpecificAdapter() - reg.register(a) - reg.register(b) - reg.register(c, position=0) # c jumps to head - assert reg.get_all()[0] is c - assert reg.get_all()[1:] == [a, b] - - -def test_higher_priority_adapter_inserted_before_lower_priority(): - """A specific (higher-priority) adapter must sort before a generic one - even when the generic one was registered first.""" - reg = AdapterRegistry() - generic = _LowPriorityAdapter() - specific = _HighPriorityAdapter() - reg.register(generic) - reg.register(specific) # registered later, but higher priority - - adapters = reg.get_all() - assert adapters[0] is specific - assert adapters[1] is generic - - -def test_same_priority_preserves_registration_order(): - """Adapters with equal priority should fall back to insertion order.""" - reg = AdapterRegistry() - a, b = _SpecificAdapter(), _SecondaryAdapter() # both priority=0 - reg.register(a) - reg.register(b) - assert reg.get_all() == [a, b] - - -def test_higher_priority_adapter_inserted_before_fallback(): - """High-priority adapter goes in front of an already-registered fallback.""" - reg = AdapterRegistry() - fallback = _FallbackAdapter() - reg.register(fallback) - reg.register(_HighPriorityAdapter()) - - adapters = reg.get_all() - assert adapters[0].name == "high" - assert adapters[-1] is fallback - - -def test_lower_priority_adapter_inserted_before_fallback_after_specifics(): - """Negative-priority adapter sorts after default-priority specifics but - still before the fallback.""" - reg = AdapterRegistry() - reg.register(_SpecificAdapter()) # priority=0 - reg.register(_FallbackAdapter()) - reg.register(_LowPriorityAdapter()) # priority=-10 - - adapters = reg.get_all() - assert adapters[0].name == "specific" - assert adapters[1].name == "low" - assert adapters[-1].name == "fallback" - - -def test_priority_overrides_registration_order_in_resolve(): - """Resolution must follow priority ordering, not registration order.""" - reg = AdapterRegistry() - reg.register(_LowPriorityAdapter()) # both adapters match every agent, - reg.register(_HighPriorityAdapter()) # so priority decides which wins. - - resolved = reg.resolve(object()) - assert resolved is not None - assert resolved.name == "high" - - -def test_fallback_stays_last_when_new_adapter_registered(): - """When the last entry has ``is_fallback`` set, new adapters insert before it.""" - reg = AdapterRegistry() - fallback = _FallbackAdapter() - reg.register(fallback) - reg.register(_SpecificAdapter()) # this should insert BEFORE fallback - - adapters = reg.get_all() - assert adapters[-1] is fallback - assert adapters[0].name == "specific" - - -def test_fallback_resolves_only_when_no_specific_matches(): - reg = AdapterRegistry() - reg.register(_SpecificAdapter()) - reg.register(_FallbackAdapter()) - - # Agent without the __specific__ marker → fallback wins. - resolved = reg.resolve(object()) - assert resolved is not None - assert resolved.name == "fallback" - - -def test_clear_removes_all_adapters(): - reg = AdapterRegistry() - reg.register(_SpecificAdapter()) - reg.register(_SecondaryAdapter()) - reg.clear() - assert reg.get_all() == [] - assert reg.resolve(object()) is None - - -def test_get_all_returns_copy_not_internal_list(): - """Callers must not be able to mutate the registry through get_all().""" - reg = AdapterRegistry() - reg.register(_SpecificAdapter()) - snapshot = reg.get_all() - snapshot.clear() - assert len(reg.get_all()) == 1 # unaffected - - -# --------------------------------------------------------------------------- -# Singleton + entry-point discovery -# --------------------------------------------------------------------------- - - -def test_get_adapter_registry_returns_singleton(): - reg1 = get_adapter_registry() - reg2 = get_adapter_registry() - assert reg1 is reg2 - - -def test_reset_adapter_registry_drops_singleton(): - first = get_adapter_registry() - reset_adapter_registry() - second = get_adapter_registry() - assert first is not second - - -def test_entry_point_discovery_invokes_registrars(monkeypatch): - """Each entry-point's zero-arg callable must be loaded and called.""" - called: list[str] = [] - - def make_registrar(name: str): - def _register() -> None: - called.append(name) - - return _register - - ep_a = MagicMock() - ep_a.name = "a" - ep_a.value = "pkg_a:register" - ep_a.load.return_value = make_registrar("a") - - ep_b = MagicMock() - ep_b.name = "b" - ep_b.value = "pkg_b:register" - ep_b.load.return_value = make_registrar("b") - - monkeypatch.setattr( - "uipath.core.adapters.registry.entry_points", - lambda group: [ep_a, ep_b] if group == "uipath.governance.adapters" else [], - raising=False, - ) - - # entry_points lives in importlib.metadata; the registry imports it - # lazily inside the function. Patch the import target directly. - import importlib.metadata as importlib_metadata - - monkeypatch.setattr( - importlib_metadata, - "entry_points", - lambda group=None: ( - [ep_a, ep_b] if group == "uipath.governance.adapters" else [] - ), - ) - - _discover_entry_point_adapters() - assert sorted(called) == ["a", "b"] - - -def test_entry_point_discovery_skips_broken_loader(monkeypatch): - """One broken entry-point must not stop the others from registering.""" - called: list[str] = [] - - ep_broken = MagicMock() - ep_broken.name = "broken" - ep_broken.value = "pkg_broken:register" - ep_broken.load.side_effect = ImportError("cannot import") - - ep_ok = MagicMock() - ep_ok.name = "ok" - ep_ok.value = "pkg_ok:register" - ep_ok.load.return_value = lambda: called.append("ok") - - import importlib.metadata as importlib_metadata - - monkeypatch.setattr( - importlib_metadata, - "entry_points", - lambda group=None: ( - [ep_broken, ep_ok] if group == "uipath.governance.adapters" else [] - ), - ) - - _discover_entry_point_adapters() # must not raise - assert called == ["ok"] - - -def test_entry_point_discovery_skips_non_callable(monkeypatch): - """An entry-point that resolves to a non-callable must be logged and skipped.""" - called: list[str] = [] - - ep_bad = MagicMock() - ep_bad.name = "bad" - ep_bad.value = "pkg_bad:NOT_A_FUNCTION" - ep_bad.load.return_value = "not callable" - - ep_ok = MagicMock() - ep_ok.name = "ok" - ep_ok.value = "pkg_ok:register" - ep_ok.load.return_value = lambda: called.append("ok") - - import importlib.metadata as importlib_metadata - - monkeypatch.setattr( - importlib_metadata, - "entry_points", - lambda group=None: ( - [ep_bad, ep_ok] if group == "uipath.governance.adapters" else [] - ), - ) - - _discover_entry_point_adapters() - assert called == ["ok"] - - -def test_entry_point_discovery_swallows_registrar_exception(monkeypatch): - """A registrar that raises mid-call must not stop subsequent registrars.""" - called: list[str] = [] - - def _raises() -> None: - raise RuntimeError("registrar exploded") - - ep_raising = MagicMock() - ep_raising.name = "raises" - ep_raising.value = "pkg:register" - ep_raising.load.return_value = _raises - - ep_ok = MagicMock() - ep_ok.name = "ok" - ep_ok.value = "pkg:register2" - ep_ok.load.return_value = lambda: called.append("ok") - - import importlib.metadata as importlib_metadata - - monkeypatch.setattr( - importlib_metadata, - "entry_points", - lambda group=None: ( - [ep_raising, ep_ok] if group == "uipath.governance.adapters" else [] - ), - ) - - _discover_entry_point_adapters() - assert called == ["ok"] - - -def test_entry_point_discovery_swallows_entry_points_failure(monkeypatch): - """If ``entry_points()`` itself raises, discovery must log and return cleanly.""" - import importlib.metadata as importlib_metadata - - def _boom(group=None): - raise RuntimeError("entry_points API exploded") - - monkeypatch.setattr(importlib_metadata, "entry_points", _boom) - - # Must not raise — and must not register anything. - _discover_entry_point_adapters() - reg = get_adapter_registry() - assert reg.get_all() == [] - - -# --------------------------------------------------------------------------- -# Protocol conformance smoke tests -# --------------------------------------------------------------------------- - - -def test_baseadapter_is_abc(): - """BaseAdapter must be abstract — direct instantiation must fail.""" - with pytest.raises(TypeError): - BaseAdapter() # type: ignore[abstract] - - -def test_concrete_adapter_is_baseadapter(): - """A concrete subclass must be recognized as a BaseAdapter.""" - assert isinstance(_SpecificAdapter(), BaseAdapter) diff --git a/packages/uipath-core/tests/governance/test_exceptions.py b/packages/uipath-core/tests/governance/test_exceptions.py index 257feb3d4..5e8738a15 100644 --- a/packages/uipath-core/tests/governance/test_exceptions.py +++ b/packages/uipath-core/tests/governance/test_exceptions.py @@ -109,7 +109,6 @@ def _audit_record_with(*evaluations: RuleEvaluation) -> AuditRecord: timestamp=datetime.now(timezone.utc), agent_name="agent", runtime_id="run-1", - trace_id="trace-1", hook=LifecycleHook.BEFORE_AGENT, evaluations=list(evaluations), final_action=Action.DENY, diff --git a/packages/uipath-core/tests/governance/test_providers.py b/packages/uipath-core/tests/governance/test_providers.py index 083b62663..21e5f1703 100644 --- a/packages/uipath-core/tests/governance/test_providers.py +++ b/packages/uipath-core/tests/governance/test_providers.py @@ -18,11 +18,16 @@ class _FakePolicyProvider: def __init__(self) -> None: self.calls: list[PolicyContext] = [] + self.async_calls: list[PolicyContext] = [] def get_policy(self, context: PolicyContext) -> PolicyResponse: self.calls.append(context) return PolicyResponse(mode=EnforcementMode.ENFORCE, policies="rules: []") + async def get_policy_async(self, context: PolicyContext) -> PolicyResponse: + self.async_calls.append(context) + return PolicyResponse(mode=EnforcementMode.ENFORCE, policies="rules: []") + class _FakeCompensationProvider: def __init__(self) -> None: @@ -141,6 +146,24 @@ def test_policy_round_trip(self) -> None: assert response.mode is EnforcementMode.ENFORCE assert provider.calls == [PolicyContext(is_conversational=True)] + @pytest.mark.asyncio + async def test_policy_round_trip_async(self) -> None: + """The async variant is the preferred entry point for event-loop hosts. + + Hosts running ``await provider.get_policy_async(ctx)`` overlap + the fetch with the rest of agent setup; the sync ``get_policy`` + path remains for callers outside an event loop. + """ + provider = _FakePolicyProvider() + response = await provider.get_policy_async( + PolicyContext(is_conversational=False) + ) + + assert response.mode is EnforcementMode.ENFORCE + assert provider.async_calls == [PolicyContext(is_conversational=False)] + # Sync slot stays untouched — the two entrypoints are independent. + assert provider.calls == [] + def test_compensation_round_trip(self) -> None: provider = _FakeCompensationProvider() request = _make_request() diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index 67d937f3a..6969d38ab 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.23" +version = "0.5.25" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index d46a6de4b..32e85b47e 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.79" +version = "0.1.80" 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.21, <0.6.0", + "uipath-core>=0.5.25, <0.6.0", "pydantic-function-models>=0.1.11", "sqlparse>=0.5.5", ] diff --git a/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py b/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py index 5ceabf479..afd5701b7 100644 --- a/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py +++ b/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py @@ -22,7 +22,7 @@ PolicyResponse, ) -from ..common._base_service import BaseService +from ..common._base_service import BaseService, resolve_trace_id from ..common._config import UiPathConfig from ..common._service_url_overrides import ( inject_routing_headers, @@ -139,10 +139,10 @@ def compensate( validators: list[str], rules: list[FiredRule], data: dict[str, Any], - trace_id: str, src_timestamp: str, agent_name: str, runtime_id: str, + trace_id: str | None = None, folder_key: str | None = None, job_key: str | None = None, process_key: str | None = None, @@ -154,8 +154,8 @@ def compensate( 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. + records bound to the agent's trace. The agent does not inspect + the response body. Job-context fields (``folder_key`` / ``job_key`` / ``process_key`` / ``reference_id`` / ``agent_version``) are @@ -171,9 +171,12 @@ def compensate( 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. + trace_id: Canonical 32-char hex trace id. Optional — when + ``None`` (default) the service resolves the value + itself at call time via :func:`resolve_trace_id`. + Callers that already hold a resolved id (typically + captured on the hook thread before a background-pool + hop) pass it in to win over the auto-resolve. src_timestamp: ISO-8601 timestamp on the source side. agent_name: Agent identifier as known to the platform. runtime_id: Runtime instance identifier. @@ -189,10 +192,11 @@ def compensate( 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. + OpenTelemetry context is thread-local; callers that + background-pool the compensation call must capture the + canonical trace id (via :func:`resolve_trace_id`) on the + hook thread and pass it in explicitly — the auto-resolve + on the worker thread will see a detached context. """ self._compensate( GovernRequest( @@ -219,10 +223,10 @@ async def compensate_async( validators: list[str], rules: list[FiredRule], data: dict[str, Any], - trace_id: str, src_timestamp: str, agent_name: str, runtime_id: str, + trace_id: str | None = None, folder_key: str | None = None, job_key: str | None = None, process_key: str | None = None, @@ -262,18 +266,46 @@ def _compensate(self, request: GovernRequest) -> None: to satisfy :class:`uipath.core.governance.GovernanceCompensationProvider` without unpacking the request. The public ergonomic counterpart is :meth:`compensate`. + + When ``request.trace_id`` is ``None`` the service resolves the + canonical trace id itself via :func:`resolve_trace_id` — same + fallback ``track_event`` uses. Callers that have a resolved + value still pass it in; callers that don't (e.g. the runtime + layer, which intentionally stays env-free) leave it ``None`` + and let the service do the work. """ + request = self._resolve_request_trace_id(request) 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`.""" + """Async variant of :meth:`_compensate`. + + Same ``trace_id`` self-resolution behavior as the sync variant. + """ + request = self._resolve_request_trace_id(request) 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) + @staticmethod + def _resolve_request_trace_id(request: GovernRequest) -> GovernRequest: + """Fill ``request.trace_id`` from :func:`resolve_trace_id` when absent. + + Caller-supplied values (including ``""``) win — the runtime + captures on the hook thread (via ``contextvars.copy_context`` + for the background pool) and the resolver here only fires when + the field was left ``None``. + """ + if request.trace_id is not None: + return request + resolved = resolve_trace_id() + if not resolved: + return request + return request.model_copy(update={"trace_id": resolved}) + # ── Internals ──────────────────────────────────────────────────── def _build_org_scoped_request(self, path: str) -> tuple[str, dict[str, str]]: diff --git a/packages/uipath-platform/tests/services/test_governance_service.py b/packages/uipath-platform/tests/services/test_governance_service.py index e437fdda0..2c808b943 100644 --- a/packages/uipath-platform/tests/services/test_governance_service.py +++ b/packages/uipath-platform/tests/services/test_governance_service.py @@ -433,6 +433,99 @@ def test_raises_when_org_id_missing( with pytest.raises(ValueError, match="UIPATH_ORGANIZATION_ID"): service.compensate(**_compensate_kwargs()) + def test_self_resolves_trace_id_when_caller_leaves_none( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """``trace_id=None`` from the caller is filled via resolve_trace_id(). + + The runtime layer intentionally stays env-free; the platform + service fills the canonical trace id at HTTP-call time from + the OTel/env source. ``UIPATH_TRACE_ID`` covers the + resolver-finds-a-value branch of ``_resolve_request_trace_id``. + """ + monkeypatch.setenv("UIPATH_TRACE_ID", TENANT_ID_HEX) + 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(trace_id=None)) + + body = json.loads(captured["request"].content) + assert body["traceId"] == TENANT_ID_HEX + + def test_omits_trace_id_when_no_source_resolves( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Resolver returns nothing → traceId is omitted from the body. + + Covers the resolver-finds-nothing branch of + ``_resolve_request_trace_id``: no ``UIPATH_TRACE_ID``, no + active OTel context → ``trace_id`` stays ``None`` on the + request and ``model_dump(exclude_none=True)`` drops it from + the wire JSON. + """ + monkeypatch.delenv("UIPATH_TRACE_ID", 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(trace_id=None)) + + body = json.loads(captured["request"].content) + assert "traceId" not in body + + def test_caller_empty_string_wins_over_resolver( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """An explicit ``trace_id=""`` from the caller is not overridden. + + With the absence-via-``None`` contract, the empty string is + a legitimate caller-supplied value — it must not trigger + the auto-resolve. + """ + monkeypatch.setenv("UIPATH_TRACE_ID", TENANT_ID_HEX) + 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(trace_id="")) + + body = json.loads(captured["request"].content) + assert body["traceId"] == "" + class TestCompensateAsync: """Test compensate_async.""" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index da9ec91d6..d74fe6e6b 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.23" +version = "0.5.25" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.79" +version = "0.1.80" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 1a3771d8b..83e05445e 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2659,7 +2659,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.23" +version = "0.5.25" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.79" +version = "0.1.80" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" },