diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 473d485a3..93be7ebc0 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.22" +version = "0.5.23" 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..b56fad632 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( @@ -34,7 +37,7 @@ def evaluate_before_agent( trace_id: str, model_name: str = "", **kwargs: Any, - ) -> Any: + ) -> AuditRecord: """Evaluate BEFORE_AGENT rules.""" ... @@ -45,7 +48,7 @@ def evaluate_after_agent( runtime_id: str, trace_id: str, **kwargs: Any, - ) -> Any: + ) -> AuditRecord: """Evaluate AFTER_AGENT rules.""" ... @@ -58,7 +61,7 @@ def evaluate_before_model( messages: list[dict[str, Any]] | None = None, model_name: str = "", **kwargs: Any, - ) -> Any: + ) -> AuditRecord: """Evaluate BEFORE_MODEL rules.""" ... @@ -69,7 +72,7 @@ def evaluate_after_model( runtime_id: str, trace_id: str, **kwargs: Any, - ) -> Any: + ) -> AuditRecord: """Evaluate AFTER_MODEL rules.""" ... @@ -82,7 +85,7 @@ def evaluate_tool_call( trace_id: str, session_state: dict[str, Any] | None = None, **kwargs: Any, - ) -> Any: + ) -> AuditRecord: """Evaluate TOOL_CALL rules.""" ... @@ -94,6 +97,6 @@ def evaluate_after_tool( 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/providers.py b/packages/uipath-core/src/uipath/core/governance/providers.py index 29f435edb..918474865 100644 --- a/packages/uipath-core/src/uipath/core/governance/providers.py +++ b/packages/uipath-core/src/uipath/core/governance/providers.py @@ -106,6 +106,13 @@ 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 also optional — when the caller passes an empty + string (or omits the field), the concrete provider is expected to + resolve the canonical trace id itself at HTTP-call time (typically + via :func:`uipath.platform.common._base_service.resolve_trace_id`). + Hosts that already hold a resolved value pass it in; hosts that + don't can let the provider do the work. """ model_config = ConfigDict(populate_by_name=True) @@ -114,7 +121,7 @@ class GovernRequest(BaseModel): rules: list[FiredRule] data: dict[str, Any] hook: str - trace_id: str = Field(alias="traceId") + trace_id: str = Field(default="", 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 +142,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_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_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 cfb903454..67d937f3a 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.22" +version = "0.5.23" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index cfe85a61e..21c693e7b 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.76" +version = "0.1.78" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/common/__init__.py b/packages/uipath-platform/src/uipath/platform/common/__init__.py index 555d6901d..2407263ee 100644 --- a/packages/uipath-platform/src/uipath/platform/common/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/common/__init__.py @@ -16,7 +16,7 @@ ) from ._config import UiPathApiConfig, UiPathConfig from ._endpoints_manager import EndpointManager -from ._execution_context import UiPathExecutionContext +from ._execution_context import ExecutionSourceContext, UiPathExecutionContext from ._external_application_service import ExternalApplicationService from ._folder_context import FolderContext, header_folder from ._http_config import get_ca_bundle_path, get_httpx_client_kwargs @@ -61,6 +61,7 @@ "BaseService", "UiPathApiConfig", "UiPathExecutionContext", + "ExecutionSourceContext", "ExternalApplicationService", "FolderContext", "TokenData", diff --git a/packages/uipath-platform/src/uipath/platform/common/_execution_context.py b/packages/uipath-platform/src/uipath/platform/common/_execution_context.py index de54c0c99..184a626db 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_execution_context.py +++ b/packages/uipath-platform/src/uipath/platform/common/_execution_context.py @@ -1,7 +1,34 @@ +from contextvars import ContextVar, Token from os import environ as env from uipath.platform.common.constants import ENV_JOB_ID, ENV_JOB_KEY, ENV_ROBOT_KEY +_execution_source: ContextVar[str | None] = ContextVar("execution_source", default=None) + + +class ExecutionSourceContext: + """Scope the execution source for the duration of a run. + + Carries the source (e.g. ``runtime``/``playground``/``eval``) via a context + variable and releases it on exit so it stays correctly scoped in concurrent + runs. The CLI enters this with ``UiPathRuntimeContext.execution_source`` so + platform clients can read it via + :attr:`UiPathExecutionContext.execution_source`. + """ + + def __init__(self, execution_source: str | None) -> None: + self._execution_source = execution_source + self._token: Token[str | None] | None = None + + def __enter__(self) -> "ExecutionSourceContext": + self._token = _execution_source.set(self._execution_source) + return self + + def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None: + if self._token is not None: + _execution_source.reset(self._token) + self._token = None + class UiPathExecutionContext: """Manages the execution context for UiPath automation processes. @@ -76,3 +103,13 @@ def robot_key(self) -> str | None: raise ValueError(f"Robot key is not set ({ENV_ROBOT_KEY})") return self._robot_key + + @property + def execution_source(self) -> str | None: + """Get the execution source for the current run. + + Identifies the run context (e.g. ``runtime``/``playground``/``eval``), + derived from the CLI command and carried via + :class:`ExecutionSourceContext`. Returns ``None`` when not set. + """ + return _execution_source.get() diff --git a/packages/uipath-platform/src/uipath/platform/common/constants.py b/packages/uipath-platform/src/uipath/platform/common/constants.py index 304ef64a6..fa710993c 100644 --- a/packages/uipath-platform/src/uipath/platform/common/constants.py +++ b/packages/uipath-platform/src/uipath/platform/common/constants.py @@ -39,6 +39,7 @@ HEADER_PROCESS_KEY = "x-uipath-processkey" HEADER_TRACE_ID = "x-uipath-traceid" HEADER_AGENTHUB_CONFIG = "x-uipath-agenthub-config" +HEADER_GUARDRAILS_SOURCE = "x-uipath-guardrails-source" HEADER_LLMGATEWAY_BYO_CONNECTION_ID = "x-uipath-llmgateway-byoisconnectionid" HEADER_SW_LOCK_KEY = "x-uipath-sw-lockkey" HEADER_LICENSING_CONTEXT = "x-uipath-licensing-context" 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..29f484f76 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, @@ -262,18 +262,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 empty 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 empty 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 empty. + + Caller-supplied values win — the runtime captures on the hook + thread (via ``contextvars.copy_context`` for the background + pool) and the resolver here only fires when nothing was + captured. + """ + if request.trace_id: + 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/src/uipath/platform/guardrails/_guardrails_service.py b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py index 86856a6b4..5082843ab 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py @@ -12,7 +12,9 @@ from ..common._base_service import BaseService from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext +from ..common._job_context import header_job_key from ..common._models import Endpoint, RequestSpec +from ..common.constants import HEADER_GUARDRAILS_SOURCE from ..errors import EnrichedException from .guardrails import BuiltInValidatorGuardrail @@ -123,9 +125,21 @@ def evaluate_guardrail( endpoint=Endpoint("/agentsruntime_/api/execution/guardrails/validate"), json=payload, ) - # Include trace context headers for server-side span correlation + # Include trace context headers for server-side span correlation, plus + # the execution source (x-uipath-guardrails-source) and job key headers + # for licensing/metering correlation. The execution source is read from + # the execution context, propagated from the runtime context. trace_headers = build_trace_context_headers() - request_headers = {**(spec.headers or {}), **trace_headers} + source_headers: dict[str, str] = {} + execution_source = self._execution_context.execution_source + if execution_source: + source_headers[HEADER_GUARDRAILS_SOURCE] = execution_source + request_headers = { + **(spec.headers or {}), + **trace_headers, + **source_headers, + **header_job_key(), + } span_id = None try: response = self.request( diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/mcp.py b/packages/uipath-platform/src/uipath/platform/orchestrator/mcp.py index dd96353a0..ca2050da3 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/mcp.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/mcp.py @@ -18,6 +18,7 @@ class McpServerType(IntEnum): Remote = 4 # HTTP connection to remote MCP server ProcessAssistant = 5 # Dynamic user process assistant Platform = 6 # Platform MCP server (e.g: Orchestrator, TestManager) + Swagger = 7 # User-provided Swagger/OpenAPI spec exposed as MCP server class McpServerStatus(IntEnum): diff --git a/packages/uipath-platform/tests/common/test_execution_context.py b/packages/uipath-platform/tests/common/test_execution_context.py new file mode 100644 index 000000000..25b43c9b2 --- /dev/null +++ b/packages/uipath-platform/tests/common/test_execution_context.py @@ -0,0 +1,26 @@ +from uipath.platform.common import ExecutionSourceContext, UiPathExecutionContext + + +def test_execution_source_none_by_default() -> None: + assert UiPathExecutionContext().execution_source is None + + +def test_execution_source_set_within_context() -> None: + ctx = UiPathExecutionContext() + + with ExecutionSourceContext("runtime"): + assert ctx.execution_source == "runtime" + + assert ctx.execution_source is None + + +def test_execution_source_context_restores_previous_value() -> None: + ctx = UiPathExecutionContext() + + with ExecutionSourceContext("eval"): + assert ctx.execution_source == "eval" + with ExecutionSourceContext("playground"): + assert ctx.execution_source == "playground" + assert ctx.execution_source == "eval" + + assert ctx.execution_source is None diff --git a/packages/uipath-platform/tests/services/test_guardrails_service.py b/packages/uipath-platform/tests/services/test_guardrails_service.py index e9d73a06f..87fb7bb8c 100644 --- a/packages/uipath-platform/tests/services/test_guardrails_service.py +++ b/packages/uipath-platform/tests/services/test_guardrails_service.py @@ -10,6 +10,7 @@ ) from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.common import ExecutionSourceContext from uipath.platform.guardrails import ( BuiltInValidatorGuardrail, EnumListParameterValue, @@ -356,6 +357,103 @@ def capture_request(request): # header merging works even when no active span exists) assert "content-type" in headers + def test_evaluate_guardrail_sends_source_and_job_key_headers( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Outgoing request includes execution source and job key headers.""" + monkeypatch.setenv("UIPATH_JOB_KEY", "job-123") + + captured_request = None + + def capture_request(request): + nonlocal captured_request + captured_request = request + return httpx.Response( + status_code=200, + json={"result": "PASSED", "details": "OK"}, + ) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", + callback=capture_request, + ) + + pii_guardrail = BuiltInValidatorGuardrail( + id="test-id", + name="PII guardrail", + description="Test", + enabled_for_evals=True, + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["tool1"] + ), + guardrail_type="builtInValidator", + validator_type="pii_detection", + validator_parameters=[], + ) + + with ExecutionSourceContext("runtime"): + service.evaluate_guardrail("test input", pii_guardrail) + + assert captured_request is not None + headers = dict(captured_request.headers) + assert headers.get("x-uipath-guardrails-source") == "runtime" + assert headers.get("x-uipath-jobkey") == "job-123" + + def test_evaluate_guardrail_omits_source_and_job_key_when_unset( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Source/job key headers are absent when unset.""" + monkeypatch.delenv("UIPATH_JOB_KEY", raising=False) + + captured_request = None + + def capture_request(request): + nonlocal captured_request + captured_request = request + return httpx.Response( + status_code=200, + json={"result": "PASSED", "details": "OK"}, + ) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", + callback=capture_request, + ) + + pii_guardrail = BuiltInValidatorGuardrail( + id="test-id", + name="PII guardrail", + description="Test", + enabled_for_evals=True, + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["tool1"] + ), + guardrail_type="builtInValidator", + validator_type="pii_detection", + validator_parameters=[], + ) + + service.evaluate_guardrail("test input", pii_guardrail) + + assert captured_request is not None + headers = dict(captured_request.headers) + assert "x-uipath-guardrails-source" not in headers + assert "x-uipath-jobkey" not in headers + def test_evaluate_guardrail_extracts_span_id_from_traceparent( self, httpx_mock: HTTPXMock, diff --git a/packages/uipath-platform/tests/services/test_mcp_service.py b/packages/uipath-platform/tests/services/test_mcp_service.py index fdc5d8ee1..d40082610 100644 --- a/packages/uipath-platform/tests/services/test_mcp_service.py +++ b/packages/uipath-platform/tests/services/test_mcp_service.py @@ -551,3 +551,21 @@ async def test_retrieve_async_passes_all_kwargs( call_kwargs.kwargs["headers"][HEADER_FOLDER_KEY] == "test-folder-key" ) + + +class TestMcpServerType: + """Tests for the McpServerType enum and McpServer validation.""" + + def test_swagger_type_value(self) -> None: + from uipath.platform.orchestrator.mcp import McpServerType + + assert McpServerType.Swagger == 7 + + def test_validate_swagger_server(self) -> None: + """A Swagger (type=7) server must validate — regression for backend + server types newer than the SDK's enum.""" + server = McpServer.model_validate( + {"slug": "contoso-directory", "name": "Employee Directory", "type": 7} + ) + assert server.type == 7 + assert server.slug == "contoso-directory" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 2c3e5d025..c0552fa41 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-06-22T13:55:56.0776194Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1063,7 +1063,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.22" +version = "0.5.23" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.76" +version = "0.1.78" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 7acd8465d..61dab8d61 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.11.12" +version = "2.11.14" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.21, <0.6.0", - "uipath-runtime>=0.11.0, <0.12.0", - "uipath-platform>=0.1.76, <0.2.0", + "uipath-runtime>=0.11.4, <0.12.0", + "uipath-platform>=0.1.78, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/src/uipath/_cli/cli_debug.py b/packages/uipath/src/uipath/_cli/cli_debug.py index 92b8ea454..1e2df4770 100644 --- a/packages/uipath/src/uipath/_cli/cli_debug.py +++ b/packages/uipath/src/uipath/_cli/cli_debug.py @@ -11,7 +11,11 @@ from uipath.core.tracing import UiPathTraceManager from uipath.eval.mocks import UiPathMockRuntime from uipath.eval.mocks._mock_runtime import load_simulation_config -from uipath.platform.common import ResourceOverwritesContext, UiPathConfig +from uipath.platform.common import ( + ExecutionSourceContext, + ResourceOverwritesContext, + UiPathConfig, +) from uipath.runtime import ( UiPathExecuteOptions, UiPathRuntimeContext, @@ -122,14 +126,15 @@ def debug( async def execute_debug_runtime(): trace_manager = UiPathTraceManager() - with UiPathRuntimeContext.with_defaults( + ctx = UiPathRuntimeContext.with_defaults( input=input, input_file=input_file, output_file=output_file, resume=resume, trace_manager=trace_manager, command="debug", - ) as ctx: + ) + with ExecutionSourceContext(ctx.execution_source), ctx: factory: UiPathRuntimeFactoryProtocol | None = None try: diff --git a/packages/uipath/src/uipath/_cli/cli_dev.py b/packages/uipath/src/uipath/_cli/cli_dev.py index 62740dc4b..f6d7021bb 100644 --- a/packages/uipath/src/uipath/_cli/cli_dev.py +++ b/packages/uipath/src/uipath/_cli/cli_dev.py @@ -7,6 +7,7 @@ from uipath._cli._utils._debug import setup_debugging from uipath._cli.middlewares import Middlewares from uipath.core.tracing import UiPathTraceManager +from uipath.platform.common import ExecutionSourceContext from uipath.runtime import UiPathRuntimeContext, UiPathRuntimeFactoryRegistry from ._telemetry import track_command @@ -14,6 +15,12 @@ console = ConsoleLogger() +def _create_dev_context_and_factory(trace_manager: UiPathTraceManager): + """Build the dev runtime context and its factory.""" + context = UiPathRuntimeContext(trace_manager=trace_manager, command="dev") + return context, UiPathRuntimeFactoryRegistry.get(context=context) + + def _check_dev_dependency(interface: str) -> None: """Check if uipath-dev is installed and raise helpful error if not.""" import importlib.util @@ -80,17 +87,14 @@ async def run_terminal() -> None: factory = None try: trace_manager = UiPathTraceManager() - factory = UiPathRuntimeFactoryRegistry.get( - context=UiPathRuntimeContext( - trace_manager=trace_manager, command="dev" - ) - ) + context, factory = _create_dev_context_and_factory(trace_manager) app = UiPathDeveloperConsole( runtime_factory=factory, trace_manager=trace_manager ) - await app.run_async() + with ExecutionSourceContext(context.execution_source): + await app.run_async() except KeyboardInterrupt: console.info("Debug session interrupted by user") @@ -124,11 +128,7 @@ def signal_handler(sig, frame): try: trace_manager = UiPathTraceManager() - factory = UiPathRuntimeFactoryRegistry.get( - context=UiPathRuntimeContext( - trace_manager=trace_manager, command="dev" - ) - ) + context, factory = _create_dev_context_and_factory(trace_manager) app = UiPathDeveloperServer( runtime_factory=factory, @@ -140,13 +140,17 @@ def signal_handler(sig, frame): ), ) - server_task = asyncio.create_task(app.run_async()) - shutdown_task = asyncio.create_task(shutdown_event.wait()) + # Enter the execution source context before creating the server + # task so request tasks spawned during the run inherit it. + with ExecutionSourceContext(context.execution_source): + server_task = asyncio.create_task(app.run_async()) + shutdown_task = asyncio.create_task(shutdown_event.wait()) - # Wait for either server to complete or shutdown signal - done, pending = await asyncio.wait( - {server_task, shutdown_task}, return_when=asyncio.FIRST_COMPLETED - ) + # Wait for either server to complete or shutdown signal + done, pending = await asyncio.wait( + {server_task, shutdown_task}, + return_when=asyncio.FIRST_COMPLETED, + ) for task in pending: task.cancel() diff --git a/packages/uipath/src/uipath/_cli/cli_eval.py b/packages/uipath/src/uipath/_cli/cli_eval.py index e101717d6..86e028c67 100644 --- a/packages/uipath/src/uipath/_cli/cli_eval.py +++ b/packages/uipath/src/uipath/_cli/cli_eval.py @@ -21,7 +21,11 @@ from uipath.eval.models.evaluation_set import EvaluationSet from uipath.eval.runtime import UiPathEvalContext, evaluate from uipath.platform.chat import set_llm_concurrency -from uipath.platform.common import ResourceOverwritesContext, UiPathConfig +from uipath.platform.common import ( + ExecutionSourceContext, + ResourceOverwritesContext, + UiPathConfig, +) from uipath.runtime import ( UiPathRuntimeContext, UiPathRuntimeFactoryRegistry, @@ -309,12 +313,13 @@ async def execute_eval(): trace_manager = UiPathTraceManager() - with UiPathRuntimeContext.with_defaults( + ctx = UiPathRuntimeContext.with_defaults( output_file=output_file, trace_manager=trace_manager, command="eval", resume=resume, - ) as ctx: + ) + with ExecutionSourceContext(ctx.execution_source), ctx: # Set job_id in eval context for single runtime runs eval_context.job_id = ctx.job_id diff --git a/packages/uipath/src/uipath/_cli/cli_run.py b/packages/uipath/src/uipath/_cli/cli_run.py index 48f42018b..9a8571d03 100644 --- a/packages/uipath/src/uipath/_cli/cli_run.py +++ b/packages/uipath/src/uipath/_cli/cli_run.py @@ -9,7 +9,11 @@ from uipath._cli._utils._debug import setup_debugging from uipath.core.tracing import UiPathTraceManager from uipath.eval.mocks import SimulationConfig, UiPathMockRuntime, build_mocking_context -from uipath.platform.common import ResourceOverwritesContext, UiPathConfig +from uipath.platform.common import ( + ExecutionSourceContext, + ResourceOverwritesContext, + UiPathConfig, +) from uipath.runtime import ( UiPathExecuteOptions, UiPathRuntimeFactoryProtocol, @@ -209,7 +213,7 @@ async def execute() -> None: async with ResourceOverwritesContext( lambda: read_resource_overwrites_from_file(ctx.runtime_dir) ): - with ctx: + with ExecutionSourceContext(ctx.execution_source), ctx: base_runtime: UiPathRuntimeProtocol | None = None runtime: UiPathRuntimeProtocol | None = None chat_runtime: UiPathRuntimeProtocol | None = None diff --git a/packages/uipath/tests/cli/test_dev.py b/packages/uipath/tests/cli/test_dev.py new file mode 100644 index 000000000..8b2a93c04 --- /dev/null +++ b/packages/uipath/tests/cli/test_dev.py @@ -0,0 +1,70 @@ +import sys +import types +from unittest.mock import AsyncMock, MagicMock + +import pytest +from click.testing import CliRunner + +from uipath._cli import cli, cli_dev +from uipath._cli.middlewares import MiddlewareResult +from uipath.platform.common import UiPathExecutionContext + + +def test_create_dev_context_and_factory_uses_dev_command( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The helper builds a 'dev' context (source 'playground') and its factory.""" + sentinel_factory = MagicMock(name="factory") + captured: dict[str, object] = {} + + def fake_get(context: object) -> object: + captured["command"] = context.command # type: ignore[attr-defined] + return sentinel_factory + + monkeypatch.setattr( + "uipath._cli.cli_dev.UiPathRuntimeFactoryRegistry.get", fake_get + ) + + context, factory = cli_dev._create_dev_context_and_factory(None) # type: ignore[arg-type] + + assert factory is sentinel_factory + assert captured["command"] == "dev" + assert context.execution_source == "playground" + + +def test_dev_terminal_sets_execution_source_during_run( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Running `dev terminal` scopes the execution source to 'playground'.""" + seen: dict[str, object] = {} + + async def fake_run_async() -> None: + seen["source"] = UiPathExecutionContext().execution_source + + fake_console = MagicMock() + fake_console.run_async = fake_run_async + + fake_module = types.ModuleType("uipath.dev") + fake_module.UiPathDeveloperConsole = MagicMock(return_value=fake_console) # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "uipath.dev", fake_module) + + mock_factory = MagicMock() + mock_factory.dispose = AsyncMock() + + monkeypatch.setattr(cli_dev, "_check_dev_dependency", lambda interface: None) + monkeypatch.setattr(cli_dev, "setup_debugging", lambda debug, port: True) + monkeypatch.setattr( + "uipath._cli.cli_dev.Middlewares.next", + lambda *a, **k: MiddlewareResult(should_continue=True), + ) + monkeypatch.setattr( + "uipath._cli.cli_dev.UiPathRuntimeFactoryRegistry.get", + lambda context: mock_factory, + ) + + result = CliRunner().invoke(cli, ["dev", "terminal"]) + + assert result.exit_code == 0, result.output + assert seen["source"] == "playground" + # token released once the run completes + assert UiPathExecutionContext().execution_source is None diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 989e4b5ea..c5946b4ae 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-06-22T13:56:19.8527915Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.12" +version = "2.11.14" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2625,7 +2625,7 @@ requires-dist = [ { name = "truststore", specifier = ">=0.10.1" }, { name = "uipath-core", editable = "../uipath-core" }, { name = "uipath-platform", editable = "../uipath-platform" }, - { name = "uipath-runtime", specifier = ">=0.11.0,<0.12.0" }, + { name = "uipath-runtime", specifier = ">=0.11.4,<0.12.0" }, ] [package.metadata.requires-dev] @@ -2659,7 +2659,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.22" +version = "0.5.23" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.76" +version = "0.1.78" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, @@ -2729,14 +2729,14 @@ dev = [ [[package]] name = "uipath-runtime" -version = "0.11.2" +version = "0.11.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/87/fed3a5bd3479b9e7dc6cba769b054f5f1c00e93762356a70010e32f1f03c/uipath_runtime-0.11.2.tar.gz", hash = "sha256:8b3cc986644d6c9f2365345c231577f97d3bad8fb105fe8a6c7e16508d00d9ef", size = 145770, upload-time = "2026-06-22T16:31:40.786Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/a4/944a7d6ef63aedea3592cdd73f2477b156562f3fb094ecf613117decbec5/uipath_runtime-0.11.4.tar.gz", hash = "sha256:7094b63f259249c763774d971ce0f3e611ca5abc160f2e4157e888b1690d9aa8", size = 152254, upload-time = "2026-06-24T14:26:07.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/62/c649c18ac39f53e5603abbcfa6917f6e880ac08047b1ce69d4c3ee937de8/uipath_runtime-0.11.2-py3-none-any.whl", hash = "sha256:a3a14dc2378bc934437bbd7523cf884d0cee0eeef26c736a9d6ce504d8a9fea0", size = 43874, upload-time = "2026-06-22T16:31:39.328Z" }, + { url = "https://files.pythonhosted.org/packages/c4/73/730469eb53fcb1bc6b05251323830bc6012993f8cc12b2bb302deeea5679/uipath_runtime-0.11.4-py3-none-any.whl", hash = "sha256:072a620a45584d745da7c7b0ab43d590768acfed846c1b860b15aaa8fb93071a", size = 49826, upload-time = "2026-06-24T14:26:05.831Z" }, ] [[package]]