diff --git a/pyproject.toml b/pyproject.toml index a1661d065..482f23b21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath-langchain" -version = "0.14.1" +version = "0.14.2" description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath>=2.13.3, <2.14.0", + "uipath>=2.13.5, <2.14.0", "uipath-core>=0.5.29, <0.6.0", - "uipath-platform>=0.2.2, <0.3.0", + "uipath-platform>=0.2.4, <0.3.0", "uipath-runtime>=0.12.1, <0.13.0", "uipath-llm-client>=1.16.2, <1.17.0", "langgraph>=1.1.8, <2.0.0", diff --git a/src/uipath_langchain/runtime/runtime.py b/src/uipath_langchain/runtime/runtime.py index a51de93d6..04657b1e7 100644 --- a/src/uipath_langchain/runtime/runtime.py +++ b/src/uipath_langchain/runtime/runtime.py @@ -1,3 +1,4 @@ +import contextvars import logging import os from collections.abc import Iterator @@ -42,6 +43,39 @@ from ._serialize import serialize_output +# Guarded import: ReferenceContext was added to uipath.tracing in a later release. +# Older installed packages lack it; the noop shim keeps the runtime loadable while +# silently disabling span propagation until the package is updated. +_noop_ref_ctx: contextvars.ContextVar[None] = contextvars.ContextVar( + "_uipath_langchain_noop_ref_ctx", default=None +) + + +class _NoopReferenceContextAccessor: + @staticmethod + def get() -> None: + return None + + @staticmethod + def set(value: Any) -> "contextvars.Token[None]": + return _noop_ref_ctx.set(None) + + @staticmethod + def reset(token: Any) -> None: + _noop_ref_ctx.reset(token) + + +ReferenceContext: Any = None +ReferenceContextAccessor: Any = _NoopReferenceContextAccessor + +try: + from uipath.tracing import ( + ReferenceContext, + ReferenceContextAccessor, + ) +except ImportError: + pass + logger = logging.getLogger(__name__) @@ -75,12 +109,36 @@ def __init__( self.chat.client_side_tools = self._get_client_side_tools() self._middleware_node_names: set[str] = self._detect_middleware_nodes() + def _push_reference_context(self) -> "contextvars.Token[Any]": + """Append this runtime's own entry to the ambient ReferenceContext. + + Reads any parent context already in the accessor (e.g. set by an + upstream middleware or the agents-python runtime), then appends a + ``langgraph`` entry for this runtime. Returns the ContextVar token + so the caller can reset in a ``finally`` block. + + Returns a no-op token when the installed uipath package predates + reference-context support. + """ + if ReferenceContext is None: + return ReferenceContextAccessor.set(None) + agent_id = os.environ.get("UIPATH_AGENT_ID") + agent_version = os.environ.get("UIPATH_PROCESS_VERSION") or None + parent_ctx = ReferenceContextAccessor.get() or ReferenceContext.Empty + ref_ctx = ( + parent_ctx.add("langgraph", agent_id, agent_version) + if agent_id + else parent_ctx + ) + return ReferenceContextAccessor.set(ref_ctx) + async def execute( self, input: dict[str, Any] | None = None, options: UiPathExecuteOptions | None = None, ) -> UiPathRuntimeResult: """Execute the graph with the provided input and configuration.""" + ref_ctx_token = self._push_reference_context() try: graph_input = await self._get_graph_input(input, options) graph_config = self._get_graph_config() @@ -99,6 +157,8 @@ async def execute( except Exception as e: raise self.create_runtime_error(e) from e + finally: + ReferenceContextAccessor.reset(ref_ctx_token) async def stream( self, @@ -133,6 +193,7 @@ async def stream( Raises: LangGraphRuntimeError: If execution fails """ + ref_ctx_token = self._push_reference_context() try: graph_input = await self._get_graph_input(input, options) graph_config = self._get_graph_config() @@ -230,6 +291,8 @@ async def stream( except Exception as e: raise self.create_runtime_error(e) from e + finally: + ReferenceContextAccessor.reset(ref_ctx_token) async def get_schema(self) -> UiPathRuntimeSchema: """Get schema for this LangGraph runtime.""" diff --git a/tests/runtime/test_reference_context_wiring.py b/tests/runtime/test_reference_context_wiring.py new file mode 100644 index 000000000..37e82aa29 --- /dev/null +++ b/tests/runtime/test_reference_context_wiring.py @@ -0,0 +1,242 @@ +"""Tests for ReferenceContext wiring in UiPathLangGraphRuntime.""" + +from typing import Any, TypedDict + +import pytest +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver +from langgraph.graph import END, START, StateGraph + +from uipath_langchain.runtime.errors import LangGraphRuntimeError +from uipath_langchain.runtime.runtime import UiPathLangGraphRuntime + +ReferenceContext: Any = None +ReferenceContextAccessor: Any = None +_reference_context_available = False + +try: + from uipath.tracing import ( + ReferenceContext, + ReferenceContextAccessor, + ) + + _reference_context_available = True +except ImportError: + pass + +pytestmark = pytest.mark.skipif( + not _reference_context_available, + reason="installed uipath does not export ReferenceContext", +) + + +# --------------------------------------------------------------------------- +# Minimal graph fixture +# --------------------------------------------------------------------------- + + +class _State(TypedDict): + value: str + + +def _build_graph() -> Any: + graph = StateGraph(_State) + graph.add_node("step", lambda s: {"value": s.get("value", "") + "_done"}) + graph.add_edge(START, "step") + graph.add_edge("step", END) + return graph + + +def _clear_accessor() -> None: + ReferenceContextAccessor.set(None) + + +# --------------------------------------------------------------------------- +# _push_reference_context — unit tests (no graph needed) +# --------------------------------------------------------------------------- + + +class TestPushReferenceContext: + def setup_method(self) -> None: + _clear_accessor() + + def teardown_method(self) -> None: + _clear_accessor() + + def test_sets_langgraph_entry_when_agent_id_present( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + monkeypatch.setenv("UIPATH_AGENT_ID", "550e8400-e29b-41d4-a716-446655440020") + monkeypatch.delenv("UIPATH_PROCESS_VERSION", raising=False) + + graph = _build_graph().compile() + runtime = UiPathLangGraphRuntime(graph=graph, runtime_id="t") + + token = runtime._push_reference_context() + try: + ctx = ReferenceContextAccessor.get() + assert ctx is not None + assert len(ctx) == 1 + assert ctx.entries[0].service_type == "langgraph" + assert ctx.entries[0].reference_id == "550e8400-e29b-41d4-a716-446655440020" + assert ctx.entries[0].version is None + finally: + ReferenceContextAccessor.reset(token) + + def test_includes_version_when_env_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_AGENT_ID", "550e8400-e29b-41d4-a716-446655440020") + monkeypatch.setenv("UIPATH_PROCESS_VERSION", "3.1.0") + + graph = _build_graph().compile() + runtime = UiPathLangGraphRuntime(graph=graph, runtime_id="t") + + token = runtime._push_reference_context() + try: + ctx = ReferenceContextAccessor.get() + assert ctx is not None + assert ctx.entries[0].version == "3.1.0" + finally: + ReferenceContextAccessor.reset(token) + + def test_no_entry_when_agent_id_absent( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("UIPATH_AGENT_ID", raising=False) + monkeypatch.delenv("UIPATH_PROCESS_VERSION", raising=False) + + graph = _build_graph().compile() + runtime = UiPathLangGraphRuntime(graph=graph, runtime_id="t") + + token = runtime._push_reference_context() + try: + ctx = ReferenceContextAccessor.get() + assert ctx is not None + assert len(ctx) == 0 + finally: + ReferenceContextAccessor.reset(token) + + def test_stacks_on_top_of_parent_context( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_AGENT_ID", "550e8400-e29b-41d4-a716-446655440020") + monkeypatch.delenv("UIPATH_PROCESS_VERSION", raising=False) + + parent = ReferenceContext.Empty.add( + "agent", "550e8400-e29b-41d4-a716-446655440001", "1.0" + ) + parent_token = ReferenceContextAccessor.set(parent) + + graph = _build_graph().compile() + runtime = UiPathLangGraphRuntime(graph=graph, runtime_id="t") + + token = runtime._push_reference_context() + try: + ctx = ReferenceContextAccessor.get() + assert ctx is not None + assert len(ctx) == 2 + assert ctx.entries[0].service_type == "agent" + assert ctx.entries[1].service_type == "langgraph" + finally: + ReferenceContextAccessor.reset(token) + ReferenceContextAccessor.reset(parent_token) + + +# --------------------------------------------------------------------------- +# execute() — context cleared after run +# --------------------------------------------------------------------------- + + +async def test_context_cleared_after_execute( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + _clear_accessor() + monkeypatch.setenv("UIPATH_AGENT_ID", "550e8400-e29b-41d4-a716-446655440020") + monkeypatch.delenv("UIPATH_PROCESS_VERSION", raising=False) + + async with AsyncSqliteSaver.from_conn_string(str(tmp_path / "mem.db")) as memory: + await memory.setup() + graph = _build_graph().compile(checkpointer=memory) + runtime = UiPathLangGraphRuntime(graph=graph, runtime_id="exec-run") + await runtime.execute(input={"value": "hello"}) + + assert ReferenceContextAccessor.get() is None + + +async def test_context_cleared_after_execute_on_error( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + _clear_accessor() + monkeypatch.setenv("UIPATH_AGENT_ID", "550e8400-e29b-41d4-a716-446655440020") + + class _S(TypedDict): + v: str + + def _boom(s: _S) -> _S: + raise ValueError("explode") + + g = StateGraph(_S) + g.add_node("boom", _boom) # type: ignore[arg-type] + g.add_edge(START, "boom") + g.add_edge("boom", END) + + async with AsyncSqliteSaver.from_conn_string(str(tmp_path / "mem.db")) as memory: + await memory.setup() + compiled = g.compile(checkpointer=memory) + runtime = UiPathLangGraphRuntime(graph=compiled, runtime_id="err-run") + with pytest.raises(LangGraphRuntimeError): + await runtime.execute(input={"v": "x"}) + + assert ReferenceContextAccessor.get() is None + + +# --------------------------------------------------------------------------- +# stream() — context cleared after run +# --------------------------------------------------------------------------- + + +async def test_context_cleared_after_stream( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + _clear_accessor() + monkeypatch.setenv("UIPATH_AGENT_ID", "550e8400-e29b-41d4-a716-446655440020") + monkeypatch.delenv("UIPATH_PROCESS_VERSION", raising=False) + + async with AsyncSqliteSaver.from_conn_string(str(tmp_path / "mem.db")) as memory: + await memory.setup() + graph = _build_graph().compile(checkpointer=memory) + runtime = UiPathLangGraphRuntime(graph=graph, runtime_id="stream-run") + async for _ in runtime.stream(input={"value": "hi"}): + pass + + assert ReferenceContextAccessor.get() is None + + +async def test_context_cleared_after_stream_on_error( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + _clear_accessor() + monkeypatch.setenv("UIPATH_AGENT_ID", "550e8400-e29b-41d4-a716-446655440020") + + class _S(TypedDict): + v: str + + def _boom(s: _S) -> _S: + raise ValueError("explode") + + g = StateGraph(_S) + g.add_node("boom", _boom) # type: ignore[arg-type] + g.add_edge(START, "boom") + g.add_edge("boom", END) + + async with AsyncSqliteSaver.from_conn_string( + str(tmp_path / "stream-err.db") + ) as memory: + await memory.setup() + compiled = g.compile(checkpointer=memory) + runtime = UiPathLangGraphRuntime(graph=compiled, runtime_id="stream-err-run") + with pytest.raises(LangGraphRuntimeError): + async for _ in runtime.stream(input={"v": "x"}): + pass + + assert ReferenceContextAccessor.get() is None diff --git a/tests/runtime/test_runtime_unit.py b/tests/runtime/test_runtime_unit.py new file mode 100644 index 000000000..e6c14a130 --- /dev/null +++ b/tests/runtime/test_runtime_unit.py @@ -0,0 +1,368 @@ +"""Unit tests for UiPathLangGraphRuntime helper methods (no graph execution).""" + +from typing import Any +from unittest.mock import MagicMock + +import pytest +from langgraph.errors import EmptyInputError, GraphRecursionError, InvalidUpdateError +from langgraph.graph.state import CompiledStateGraph +from langgraph.types import StateSnapshot +from uipath.runtime import UiPathRuntimeStatus +from uipath.runtime.errors import UiPathErrorCategory + +from uipath_langchain.runtime.errors import LangGraphErrorCode, LangGraphRuntimeError +from uipath_langchain.runtime.runtime import UiPathLangGraphRuntime + + +def _make_runtime(**kwargs: Any) -> UiPathLangGraphRuntime: + graph = MagicMock(spec=CompiledStateGraph) + graph.nodes = {} + graph.output_channels = [] + return UiPathLangGraphRuntime(graph=graph, **kwargs) + + +def _snapshot( + next_nodes: tuple[Any, ...] = (), + interrupts: tuple[Any, ...] = (), + tasks: list[Any] | None = None, + values: dict[str, Any] | None = None, +) -> MagicMock: + snap = MagicMock(spec=StateSnapshot) + snap.next = next_nodes + snap.interrupts = interrupts + snap.tasks = tasks or [] + snap.values = values or {} + return snap + + +# --------------------------------------------------------------------------- +# _build_node_name +# --------------------------------------------------------------------------- + + +class TestBuildNodeName: + def setup_method(self) -> None: + self.rt = _make_runtime() + + def test_root_graph_returns_node_name(self) -> None: + assert self.rt._build_node_name((), "agent") == "agent" + + def test_single_subgraph_prepends_subgraph_name(self) -> None: + assert ( + self.rt._build_node_name(("coder:abc123",), "generate") == "coder:generate" + ) + + def test_nested_subgraphs_build_full_path(self) -> None: + assert ( + self.rt._build_node_name(("coder:a", "debugger:b"), "analyze") + == "coder:debugger:analyze" + ) + + def test_namespace_without_colon_uses_full_segment(self) -> None: + assert self.rt._build_node_name(("subgraph",), "node") == "subgraph:node" + + def test_empty_string_segments_are_skipped(self) -> None: + assert self.rt._build_node_name(("",), "node") == "node" + + def test_non_tuple_namespace_falls_back_to_node_name(self) -> None: + assert self.rt._build_node_name("unexpected", "node") == "node" + + def test_none_namespace_falls_back_to_node_name(self) -> None: + assert self.rt._build_node_name(None, "node") == "node" + + +# --------------------------------------------------------------------------- +# _extract_graph_result +# --------------------------------------------------------------------------- + + +class TestExtractGraphResult: + def setup_method(self) -> None: + self.rt = _make_runtime() + + def test_non_dict_returned_unchanged(self) -> None: + self.rt.graph.output_channels = ["out"] + assert self.rt._extract_graph_result("string") == "string" + + def test_single_string_channel_extracts_matching_key(self) -> None: + self.rt.graph.output_channels = "result" + assert self.rt._extract_graph_result({"result": 42, "other": 1}) == 42 + + def test_single_string_channel_missing_returns_full_chunk(self) -> None: + self.rt.graph.output_channels = "result" + chunk = {"other": 1} + assert self.rt._extract_graph_result(chunk) == chunk + + def test_multi_channel_returns_only_present_keys(self) -> None: + self.rt.graph.output_channels = ["a", "b", "c"] + assert self.rt._extract_graph_result({"a": 1, "b": 2}) == {"a": 1, "b": 2} + + def test_multi_channel_unwraps_single_key_wrapping_dict(self) -> None: + self.rt.graph.output_channels = ["x", "y"] + assert self.rt._extract_graph_result({"node": {"x": 10, "y": 20}}) == { + "x": 10, + "y": 20, + } + + def test_tuple_format_unwraps_second_element(self) -> None: + self.rt.graph.output_channels = "val" + assert self.rt._extract_graph_result(("ignored", {"val": 99})) == 99 + + def test_empty_sequence_channels_returns_chunk_unchanged(self) -> None: + self.rt.graph.output_channels = [] + chunk = {"a": 1} + assert self.rt._extract_graph_result(chunk) == chunk + + +# --------------------------------------------------------------------------- +# create_runtime_error +# --------------------------------------------------------------------------- + + +class TestCreateRuntimeError: + def setup_method(self) -> None: + self.rt = _make_runtime() + + def test_langgraph_runtime_error_returned_unchanged(self) -> None: + err = LangGraphRuntimeError( + LangGraphErrorCode.GRAPH_LOAD_ERROR, "t", "d", UiPathErrorCategory.USER + ) + assert self.rt.create_runtime_error(err) is err + + def test_graph_recursion_error_maps_to_graph_load_error(self) -> None: + result = self.rt.create_runtime_error(GraphRecursionError("too deep")) + assert result.error_info.code == "LANGGRAPH.GRAPH_LOAD_ERROR" + + def test_invalid_update_error_maps_to_graph_invalid_update(self) -> None: + result = self.rt.create_runtime_error(InvalidUpdateError("bad update")) + assert result.error_info.code == "LANGGRAPH.GRAPH_INVALID_UPDATE" + + def test_empty_input_error_maps_to_graph_empty_input(self) -> None: + result = self.rt.create_runtime_error(EmptyInputError()) + assert result.error_info.code == "LANGGRAPH.GRAPH_EMPTY_INPUT" + + def test_generic_exception_maps_to_execution_error(self) -> None: + result = self.rt.create_runtime_error(RuntimeError("boom")) + assert result.error_info.code == "LANGGRAPH.EXECUTION_ERROR" + assert "boom" in result.error_info.detail + + +# --------------------------------------------------------------------------- +# _get_graph_config +# --------------------------------------------------------------------------- + + +class TestGetGraphConfig: + def test_thread_id_set_from_runtime_id(self) -> None: + rt = _make_runtime(runtime_id="my-run") + assert rt._get_graph_config()["configurable"]["thread_id"] == "my-run" + + def test_no_recursion_limit_by_default( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("LANGCHAIN_RECURSION_LIMIT", raising=False) + assert "recursion_limit" not in _make_runtime()._get_graph_config() + + def test_recursion_limit_read_from_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("LANGCHAIN_RECURSION_LIMIT", "50") + assert _make_runtime()._get_graph_config()["recursion_limit"] == 50 + + def test_no_max_concurrency_by_default( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("LANGCHAIN_MAX_CONCURRENCY", raising=False) + assert "max_concurrency" not in _make_runtime()._get_graph_config() + + def test_max_concurrency_read_from_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("LANGCHAIN_MAX_CONCURRENCY", "4") + assert _make_runtime()._get_graph_config()["max_concurrency"] == 4 + + +# --------------------------------------------------------------------------- +# _detect_middleware_nodes / _is_middleware_node +# --------------------------------------------------------------------------- + + +class TestMiddlewareDetection: + def _make_with_nodes(self, node_names: list[str]) -> UiPathLangGraphRuntime: + graph = MagicMock(spec=CompiledStateGraph) + graph.nodes = {n: MagicMock() for n in node_names} + graph.output_channels = [] + return UiPathLangGraphRuntime(graph=graph) + + def test_node_with_dot_and_middleware_keyword_detected(self) -> None: + rt = self._make_with_nodes(["GuardrailsMiddleware.pre", "agent"]) + assert rt._is_middleware_node("GuardrailsMiddleware.pre") + + def test_regular_node_not_detected(self) -> None: + rt = self._make_with_nodes(["GuardrailsMiddleware.pre", "agent"]) + assert not rt._is_middleware_node("agent") + + def test_node_with_dot_but_no_middleware_keyword_not_detected(self) -> None: + rt = self._make_with_nodes(["some.node"]) + assert not rt._is_middleware_node("some.node") + + def test_middleware_keyword_without_dot_not_detected(self) -> None: + rt = self._make_with_nodes(["MiddlewareNode"]) + assert not rt._is_middleware_node("MiddlewareNode") + + def test_multiple_middleware_hooks_all_detected(self) -> None: + rt = self._make_with_nodes(["FooMiddleware.pre", "FooMiddleware.post", "tools"]) + assert rt._is_middleware_node("FooMiddleware.pre") + assert rt._is_middleware_node("FooMiddleware.post") + assert not rt._is_middleware_node("tools") + + +# --------------------------------------------------------------------------- +# _is_interrupted +# --------------------------------------------------------------------------- + + +class TestIsInterrupted: + def setup_method(self) -> None: + self.rt = _make_runtime() + + def test_true_when_next_nodes_present(self) -> None: + assert self.rt._is_interrupted(_snapshot(next_nodes=("step",))) + + def test_true_when_dynamic_interrupts_present(self) -> None: + assert self.rt._is_interrupted(_snapshot(interrupts=(MagicMock(),))) + + def test_false_when_both_empty(self) -> None: + assert not self.rt._is_interrupted(_snapshot()) + + +# --------------------------------------------------------------------------- +# _create_success_result +# --------------------------------------------------------------------------- + + +class TestCreateSuccessResult: + def setup_method(self) -> None: + self.rt = _make_runtime() + + def test_status_is_successful(self) -> None: + assert ( + self.rt._create_success_result({"k": "v"}).status + == UiPathRuntimeStatus.SUCCESSFUL + ) + + def test_output_passed_through(self) -> None: + assert self.rt._create_success_result({"key": "val"}).output == {"key": "val"} + + def test_none_output_becomes_empty_dict(self) -> None: + assert self.rt._create_success_result(None).output == {} + + +# --------------------------------------------------------------------------- +# _create_breakpoint_result +# --------------------------------------------------------------------------- + + +class TestCreateBreakpointResult: + def setup_method(self) -> None: + self.rt = _make_runtime() + + def test_before_breakpoint_uses_next_node_name(self) -> None: + snap = _snapshot(next_nodes=("step_b",), values={"k": "v"}) + result = self.rt._create_breakpoint_result(snap) + assert result.breakpoint_type == "before" + assert "step_b" in result.breakpoint_node + assert result.next_nodes == ["step_b"] + + def test_after_breakpoint_uses_last_task_name(self) -> None: + task = MagicMock() + task.name = "last_step" + snap = _snapshot(next_nodes=(), tasks=[task], values={}) + result = self.rt._create_breakpoint_result(snap) + assert result.breakpoint_type == "after" + assert result.breakpoint_node == "last_step" + + def test_after_breakpoint_with_no_tasks_is_unknown(self) -> None: + snap = _snapshot(next_nodes=(), tasks=[], values={}) + result = self.rt._create_breakpoint_result(snap) + assert result.breakpoint_type == "after" + assert result.breakpoint_node == "unknown" + + def test_current_state_reflects_snapshot_values(self) -> None: + snap = _snapshot(next_nodes=("n",), values={"out": "done"}) + result = self.rt._create_breakpoint_result(snap) + assert result.current_state == {"out": "done"} + + +# --------------------------------------------------------------------------- +# ImportError fallback — _NoopReferenceContextAccessor +# --------------------------------------------------------------------------- + + +class TestImportErrorFallback: + """Cover the _NoopReferenceContextAccessor shim and the ReferenceContext=None + guard inside _push_reference_context.""" + + def test_noop_accessor_get_returns_none(self) -> None: + from uipath_langchain.runtime.runtime import _NoopReferenceContextAccessor + + assert _NoopReferenceContextAccessor.get() is None + + def test_noop_accessor_set_returns_token(self) -> None: + import contextvars + + from uipath_langchain.runtime.runtime import _NoopReferenceContextAccessor + + token = _NoopReferenceContextAccessor.set("anything") + assert isinstance(token, contextvars.Token) + + def test_noop_accessor_reset_does_not_raise(self) -> None: + from uipath_langchain.runtime.runtime import _NoopReferenceContextAccessor + + token = _NoopReferenceContextAccessor.set(None) + _NoopReferenceContextAccessor.reset(token) # must not raise + + def test_push_returns_token_when_reference_context_unavailable( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + import contextvars + + import uipath_langchain.runtime.runtime as rt_mod + from uipath_langchain.runtime.runtime import _NoopReferenceContextAccessor + + monkeypatch.setattr(rt_mod, "ReferenceContext", None) + monkeypatch.setattr( + rt_mod, "ReferenceContextAccessor", _NoopReferenceContextAccessor + ) + + rt = _make_runtime() + token = rt._push_reference_context() + assert isinstance(token, contextvars.Token) + # cleanup must not raise + _NoopReferenceContextAccessor.reset(token) + + def test_push_does_not_set_real_accessor_when_unavailable( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + try: + from uipath.tracing import ( + ReferenceContextAccessor, + ) + except ImportError: + pytest.skip("installed uipath does not export ReferenceContext") + + import uipath_langchain.runtime.runtime as rt_mod + from uipath_langchain.runtime.runtime import _NoopReferenceContextAccessor + + monkeypatch.setattr(rt_mod, "ReferenceContext", None) + monkeypatch.setattr( + rt_mod, "ReferenceContextAccessor", _NoopReferenceContextAccessor + ) + + rt = _make_runtime() + before = ReferenceContextAccessor.get() + token = rt._push_reference_context() + after = ReferenceContextAccessor.get() + _NoopReferenceContextAccessor.reset(token) + + assert before == after diff --git a/uv.lock b/uv.lock index 22c3ab02f..6c477a85b 100644 --- a/uv.lock +++ b/uv.lock @@ -4432,7 +4432,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.3" +version = "2.13.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "applicationinsights" }, @@ -4456,9 +4456,9 @@ dependencies = [ { name = "uipath-platform" }, { name = "uipath-runtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/2b/42848522df8a0d9e674aef9a0267fe15c3c4df69d490e080a3166f2400d2/uipath-2.13.3.tar.gz", hash = "sha256:5f35669d9f96f4e797a22278006f551be2ee24e067d0ad32b61d983933d18418", size = 4495433, upload-time = "2026-07-07T10:29:23.936Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/e4/3fb853b93cdb79ede574566de4222630542e8da82aaefc51a2932a584ef0/uipath-2.13.5.tar.gz", hash = "sha256:fa7b5c37b4beb10b162778b59b449ef7158bd05420f75a2a5bfb084832d29035", size = 4497874, upload-time = "2026-07-07T17:14:45.421295Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/ee/f3991b7b5f321b5a5ae195d4bb0315ea99dc184d2670126269f294ff1378/uipath-2.13.3-py3-none-any.whl", hash = "sha256:95159a1d1b237152125327d52b33941b0aae808c77825240c2953629fe502649", size = 417701, upload-time = "2026-07-07T10:29:21.363Z" }, + { url = "https://files.pythonhosted.org/packages/3e/7c/51400a789db89ee040b07c5a877297e1b9faca6add00988ce31f81ce1266/uipath-2.13.5-py3-none-any.whl", hash = "sha256:6705b6f9fcd50e0c3d8228312d47969ef382f7895b927ca2f3c6fd16c57267a1", size = 418209, upload-time = "2026-07-07T17:14:43.292295Z" }, ] [[package]] @@ -4477,7 +4477,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.14.1" +version = "0.14.2" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, @@ -4555,7 +4555,7 @@ requires-dist = [ { name = "pillow", specifier = ">=12.1.1" }, { name = "pydantic-settings", specifier = ">=2.6.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, - { name = "uipath", specifier = ">=2.13.3,<2.14.0" }, + { name = "uipath", specifier = ">=2.13.5,<2.14.0" }, { name = "uipath-core", specifier = ">=0.5.29,<0.6.0" }, { name = "uipath-langchain-client", extras = ["all"], marker = "extra == 'all'", specifier = ">=1.16.1,<1.17.0" }, { name = "uipath-langchain-client", extras = ["anthropic"], marker = "extra == 'anthropic'", specifier = ">=1.16.1,<1.17.0" }, @@ -4565,7 +4565,7 @@ requires-dist = [ { name = "uipath-langchain-client", extras = ["openai"], specifier = ">=1.16.1,<1.17.0" }, { name = "uipath-langchain-client", extras = ["vertexai"], marker = "extra == 'vertex'", specifier = ">=1.16.1,<1.17.0" }, { name = "uipath-llm-client", specifier = ">=1.16.2,<1.17.0" }, - { name = "uipath-platform", specifier = ">=0.2.2,<0.3.0" }, + { name = "uipath-platform", specifier = ">=0.2.4,<0.3.0" }, { name = "uipath-runtime", specifier = ">=0.12.1,<0.13.0" }, ] provides-extras = ["anthropic", "vertex", "bedrock", "fireworks", "all"] @@ -4649,7 +4649,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.2" +version = "0.2.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -4659,9 +4659,9 @@ dependencies = [ { name = "truststore" }, { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/b6/a776ef1ba59189e69f6d04f3728dde1fbd02219f78f03bd80d9b888a834f/uipath_platform-0.2.2.tar.gz", hash = "sha256:c131b7c943efdde83f08e4161c8d1b4170b8fffd9d961dbea2486d6f8b85daf4", size = 414645, upload-time = "2026-07-07T10:28:10.412Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/6b/feaeeaa028ee94fedcd6ebd40d2068097c3bfcacf683c798e40b35293ee5/uipath_platform-0.2.4.tar.gz", hash = "sha256:d79c7c99d74c484006a4684efc62dbd2572ba7053447e9882c624f3b272e2367", size = 421692, upload-time = "2026-07-07T17:13:56.832163Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/c6/cab60e437565e9ca600d793e1ab60f6dc5aeac785de1ee5dde498fee642c/uipath_platform-0.2.2-py3-none-any.whl", hash = "sha256:9882f6d25473b6b4df3d26f537e46339d6f52081c1113f8b7e5543f8bafa9494", size = 274146, upload-time = "2026-07-07T10:28:08.971Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fc/fd8a88e50f99579483b8ead68912884d50505ecac8046491c8c1c1318e09/uipath_platform-0.2.4-py3-none-any.whl", hash = "sha256:a0736a60723e7c48a6efdbc42a64a933197205ca7c555a4f89988a50eb88a5c9", size = 277697, upload-time = "2026-07-07T17:13:55.444420Z" }, ] [[package]]