From da282a992bbf277b6f159e54bc8f90ece3b0c655 Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Thu, 25 Jun 2026 08:39:22 +0530 Subject: [PATCH 1/8] refactor(core): drop AdapterRegistry + BaseAdapter; keep EvaluatorProtocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapter-registry plugin-discovery system in uipath-core was a parallel keyed-by-agent-type dispatcher for a fact already known statically: the runtime-factory registry has picked the framework by the time anything needs an "adapter". The two registries existed only to support an open-PR pattern (#125/#126) where the runtime layer sniffed the framework off an opaque agent. With that approach abandoned in favor of framework plugins wiring governance at their native seam (callback handlers, hook lists), the registry, the BaseAdapter abstraction, and their per-package entry-point group are all dead weight. Deletions - src/uipath/core/adapters/base.py — BaseAdapter + GovernedAgentBase. No internal consumers; the only external one (uipath-langchain PR #899) is unmerged and is being reshaped to consume EvaluatorProtocol directly via its factory. - src/uipath/core/adapters/registry.py — AdapterRegistry, get_adapter_registry, reset_adapter_registry, the `uipath.governance.adapters` entry-point group, and the side-effect discovery on first call. - tests/adapters/test_base.py and tests/adapters/test_registry.py. Kept - src/uipath/core/adapters/evaluator.py — EvaluatorProtocol is the one contract framework plugins still consume. Plugin factories accept an evaluator at create_runtime() time and wire it into their own callback seam. - tests/adapters/test_evaluator.py — protocol-conformance unchanged. Net diff: ~960 LOC removed. uipath-core 0.5.22 → 0.5.23. uv.lock files regenerated in uipath-core, uipath-platform, and uipath (workspace editable-path deps, so they pick up 0.5.23 immediately). Verified: ruff clean, mypy clean (45 source files), 230 passed + 1 skipped in uipath-core's test suite. Monorepo grep for AdapterRegistry / BaseAdapter / GovernedAgentBase / get_adapter_registry / reset_adapter_registry returns zero hits outside the deleted files across .py/.toml/.md/.rst/.json. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/adapters/__init__.py | 27 +- .../src/uipath/core/adapters/base.py | 116 ----- .../src/uipath/core/adapters/registry.py | 176 ------- .../uipath-core/tests/adapters/test_base.py | 163 ------ .../tests/adapters/test_registry.py | 492 ------------------ packages/uipath-core/uv.lock | 2 +- packages/uipath-platform/uv.lock | 4 +- packages/uipath/uv.lock | 4 +- 9 files changed, 12 insertions(+), 974 deletions(-) delete mode 100644 packages/uipath-core/src/uipath/core/adapters/base.py delete mode 100644 packages/uipath-core/src/uipath/core/adapters/registry.py delete mode 100644 packages/uipath-core/tests/adapters/test_base.py delete mode 100644 packages/uipath-core/tests/adapters/test_registry.py diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 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/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/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/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/uv.lock b/packages/uipath-platform/uv.lock index 2c3e5d025..89d4f8e8b 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" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 989e4b5ea..723b58ffa 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] @@ -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" }, From 651a7dd81c1469bdf26b734fac3acbe228e3b0c4 Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Thu, 25 Jun 2026 11:14:19 +0530 Subject: [PATCH 2/8] refactor(core): type EvaluatorProtocol returns as AuditRecord MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes doc item 1.2 — every ``evaluate_*`` method on ``EvaluatorProtocol`` returned ``-> Any``, forcing callers to downcast to the type they already knew the concrete evaluator was returning. The concrete ``GovernanceEvaluator`` in uipath-runtime-python already declares ``-> AuditRecord`` on each per-hook method, so narrowing the protocol contract is structurally compatible — no behavior change, no downstream code change required. Narrows the six evaluate_* return types (before_agent / after_agent / before_model / after_model / tool_call / after_tool) from Any to AuditRecord, imports the type from uipath.core.governance.models, and refreshes the class docstring (was claiming the protocol is intentionally Any because the audit record "lives in the plugin package" — but AuditRecord lives right here in uipath-core). Verified - ruff clean, mypy clean (45 source files), 230 passed + 1 skipped in uipath-core. - uipath-runtime-python's test suite (357 passed + 1 skipped) keeps green when this version of uipath-core is installed — the protocol- conformance tests in test_evaluator.py still pass because the concrete GovernanceEvaluator was already returning AuditRecord from every evaluate_* method. Rides on the same 0.5.23 version bump as the previous commit — both changes ship together as one public-surface change on uipath-core. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/uipath/core/adapters/evaluator.py | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) 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.""" ... From afcf7a5a1b9a00bb30144792250e23d9ee8d1b9c Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Thu, 25 Jun 2026 14:44:10 +0530 Subject: [PATCH 3/8] feat(core): add get_policy_async to GovernancePolicyProvider protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unblocks architecture-review §2.4 on the uipath-runtime side. The prescription there is to hoist the policy fetch from the runtime-layer ``PolicyLoader`` (which today spins a daemon thread inside an async runtime and blocks on ``threading.Event.wait(timeout=10s)``) up to the async host: the CLI calls ``await provider.get_policy_async(ctx)`` itself, builds the ``PolicyIndex``, and passes the resolved index + mode into ``GovernanceRuntime``. The runtime collapses to a pure, synchronous-to-construct decorator — no thread, no Event, no ``is_conversational`` in the ctor. For that to type-check on the runtime side, the structural ``GovernancePolicyProvider`` Protocol in uipath-core needs to declare ``get_policy_async``. The concrete platform provider (``UiPathPlatformGovernanceProvider``) already implements it; the contract was just lying about what providers expose. Changes - ``GovernancePolicyProvider`` now declares both ``get_policy`` and ``async get_policy_async``. Both required (the platform impl ships both today, and the doc's recommended caller path is the async variant — sync stays for non-event-loop callers like integration tests and CLI tools). - ``_FakePolicyProvider`` in the conformance tests grew the async method and a separate ``async_calls`` recorder. - New ``test_policy_round_trip_async`` exercises the async path via ``@pytest.mark.asyncio`` and pins that the two entry points are independent (calling one doesn't touch the other's recorder). Verified - uipath-core: ruff clean, mypy clean (45 source files), 32 governance tests passed. - uipath-platform: protocol-conformance tests still pass (9 passed) — ``UiPathPlatformGovernanceProvider`` already exposed ``get_policy_async``, so the now-stricter protocol still accepts it structurally. No version bump — rides on the unreleased 0.5.23 that already carries PR #1761's §1.1 (adapter-registry deletion) and §1.2 (typed EvaluatorProtocol returns). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/uipath/core/governance/providers.py | 24 +++++++++++++++++-- .../tests/governance/test_providers.py | 23 ++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/uipath-core/src/uipath/core/governance/providers.py b/packages/uipath-core/src/uipath/core/governance/providers.py index 29f435edb..44664d693 100644 --- a/packages/uipath-core/src/uipath/core/governance/providers.py +++ b/packages/uipath-core/src/uipath/core/governance/providers.py @@ -135,14 +135,34 @@ 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/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() From d763f4f90d3ead260ac04a3282cbded8a5bfea3e Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Fri, 26 Jun 2026 16:24:49 +0530 Subject: [PATCH 4/8] feat(governance): platform self-resolves trace_id when caller leaves it empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets the runtime layer (uipath-runtime-python) stop carrying ``trace_id`` through ``UiPathGovernedRuntime`` / ``GuardrailCompensator``. The runtime emits compensation requests with ``trace_id=""`` and the platform fills in the canonical agent trace id at HTTP-call time via the existing ``resolve_trace_id()`` helper — same fallback ``track_event`` (PR #1745) already uses. uipath-core - ``GovernRequest.trace_id`` relaxes from required ``str`` to ``str = ""`` default. Docstring documents the platform-side self-resolve contract so wire callers know an empty value is legitimate. uipath-platform - ``GovernanceService._compensate`` / ``_compensate_async`` now call a new ``_resolve_request_trace_id()`` helper before the POST. When ``request.trace_id`` is empty the helper resolves via ``resolve_trace_id()`` (env → LLMOps external span → OTel current span). Caller-supplied values win — the runtime captures live OTel context across its background-pool hop via ``contextvars.copy_context()``, so when the worker calls ``provider.compensate(...)`` the platform-side resolver sees the agent's live span and returns the same canonical id. Caller-supplied non-empty trace ids continue to pass through unchanged. Tests - uipath-core governance suite: 32 passed. - uipath-platform governance service suite: 27 passed. - ruff + mypy clean on ``src/uipath/core/governance`` and ``src/uipath/platform/governance``. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/uipath/core/governance/providers.py | 9 +++++- .../governance/_governance_service.py | 32 +++++++++++++++++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/uipath-core/src/uipath/core/governance/providers.py b/packages/uipath-core/src/uipath/core/governance/providers.py index 44664d693..e89aebf3d 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") 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]]: From 92393546cc2ef56922de8608887d64f2eba71843 Mon Sep 17 00:00:00 2001 From: Popescu Tudor-Cristian <94108303+PopescuTudor@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:31:57 +0300 Subject: [PATCH 5/8] fix(platform): add Swagger (type 7) to McpServerType (#1762) Co-authored-by: Claude Opus 4.8 --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/orchestrator/mcp.py | 1 + .../tests/services/test_mcp_service.py | 18 ++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 5 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index cfe85a61e..e4da0a0ce 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.77" 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/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/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 89d4f8e8b..bc369ea84 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.76" +version = "0.1.77" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 723b58ffa..f6d8b483d 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.76" +version = "0.1.77" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From 125f0f513970c9456de6bba8462c074cd71f7262 Mon Sep 17 00:00:00 2001 From: Valentina Bojan Date: Thu, 25 Jun 2026 17:38:23 +0300 Subject: [PATCH 6/8] feat(guardrails): send execution source and job key headers (#1739) Co-authored-by: Valentina Bojan Co-authored-by: Claude Opus 4.8 --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/common/__init__.py | 3 +- .../platform/common/_execution_context.py | 37 +++++++ .../src/uipath/platform/common/constants.py | 1 + .../guardrails/_guardrails_service.py | 18 +++- .../tests/common/test_execution_context.py | 26 +++++ .../tests/services/test_guardrails_service.py | 98 +++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 6 +- packages/uipath/src/uipath/_cli/cli_debug.py | 11 ++- packages/uipath/src/uipath/_cli/cli_dev.py | 38 +++---- packages/uipath/src/uipath/_cli/cli_eval.py | 11 ++- packages/uipath/src/uipath/_cli/cli_run.py | 8 +- packages/uipath/tests/cli/test_dev.py | 70 +++++++++++++ packages/uipath/uv.lock | 12 +-- 15 files changed, 304 insertions(+), 39 deletions(-) create mode 100644 packages/uipath-platform/tests/common/test_execution_context.py create mode 100644 packages/uipath/tests/cli/test_dev.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index e4da0a0ce..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.77" +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/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/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/uv.lock b/packages/uipath-platform/uv.lock index bc369ea84..c0552fa41 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.77" +version = "0.1.78" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 7acd8465d..62bb9a5e1 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.13" 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 f6d8b483d..f99dd06a0 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.12" +version = "2.11.13" 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] @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.77" +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]] From 1a979ded8ddb0682b1e87fd2f2f718dfce003074 Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Fri, 26 Jun 2026 16:36:59 +0530 Subject: [PATCH 7/8] style(core): reformat get_policy_async signature to one line Ruff format collapses the three-line def to a single line; CI lint runs ruff with --check and failed on the prior commit. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/uipath-core/src/uipath/core/governance/providers.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/uipath-core/src/uipath/core/governance/providers.py b/packages/uipath-core/src/uipath/core/governance/providers.py index e89aebf3d..918474865 100644 --- a/packages/uipath-core/src/uipath/core/governance/providers.py +++ b/packages/uipath-core/src/uipath/core/governance/providers.py @@ -159,9 +159,7 @@ 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 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 From d5937f95a4256dc466db521155ac233452a5dda8 Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Fri, 26 Jun 2026 16:42:28 +0530 Subject: [PATCH 8/8] =?UTF-8?q?chore(uipath):=20bump=20version=202.11.13?= =?UTF-8?q?=20=E2=86=92=202.11.14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2.11.13 is already taken by another PR/publish; bumping to keep the release on this branch publishable. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/uipath/pyproject.toml | 2 +- packages/uipath/uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 62bb9a5e1..61dab8d61 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.11.13" +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" diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index f99dd06a0..c5946b4ae 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.13" +version = "2.11.14" source = { editable = "." } dependencies = [ { name = "applicationinsights" },