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" },