diff --git a/pyproject.toml b/pyproject.toml index 01b693284..df02815bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.18.15" +version = "0.18.16" 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" diff --git a/src/uipath_langchain/runtime/runtime.py b/src/uipath_langchain/runtime/runtime.py index 25a438415..1a1944748 100644 --- a/src/uipath_langchain/runtime/runtime.py +++ b/src/uipath_langchain/runtime/runtime.py @@ -67,8 +67,10 @@ def reset(token: Any) -> None: ReferenceContext: Any = None ReferenceContextAccessor: Any = _NoopReferenceContextAccessor +resolve_project_id: Any = None try: + from uipath.platform.common._span_utils import resolve_project_id from uipath.tracing import ( ReferenceContext, ReferenceContextAccessor, @@ -117,19 +119,33 @@ def _push_reference_context(self) -> "contextvars.Token[Any]": ``langgraph`` entry for this runtime. Returns the ContextVar token so the caller can reset in a ``finally`` block. + The entry must carry the id the span's ReferenceId resolves to, so the + hierarchy leaf and ReferenceId name the same entity. ``_SpanUtils`` + derives ReferenceId as ``agentId or referenceId`` and overwrites + ``agentId`` with ``resolve_project_id()`` — so that is the id to push, + with ``UIPATH_AGENT_ID`` as the fallback for when it resolves to nothing. + 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") + env_agent_id = os.environ.get("UIPATH_AGENT_ID") + hierarchy_id = ( + resolve_project_id() if resolve_project_id else None + ) or env_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 - ) + try: + ref_ctx = ( + parent_ctx.add("langgraph", hierarchy_id, agent_version) + if hierarchy_id + else parent_ctx + ) + except ValueError: + # Not a UUID (env-var sources are unvalidated). Skip this entry + # rather than failing the run. + ref_ctx = parent_ctx return ReferenceContextAccessor.set(ref_ctx) async def execute( diff --git a/tests/runtime/test_reference_context_wiring.py b/tests/runtime/test_reference_context_wiring.py index 37e82aa29..e7f265861 100644 --- a/tests/runtime/test_reference_context_wiring.py +++ b/tests/runtime/test_reference_context_wiring.py @@ -240,3 +240,74 @@ def _boom(s: _S) -> _S: pass assert ReferenceContextAccessor.get() is None + + +# --------------------------------------------------------------------------- +# The hierarchy leaf must name the same entity as the span's ReferenceId +# --------------------------------------------------------------------------- + + +class TestHierarchyLeafMatchesReferenceId: + """The pushed entry carries the id `ReferenceId` resolves to. + + `_SpanUtils.otel_span_to_uipath_span` derives `ReferenceId` as + ``agentId or referenceId`` and sets the `agentId` attribute from + ``resolve_project_id()`` — which prefers ``uipath.json#id`` over + ``UIPATH_AGENT_ID``. Pushing the raw env var left the hierarchy leaf naming + a different entity than `ReferenceId` whenever the two disagreed. + """ + + ENV_AGENT_ID = "550e8400-e29b-41d4-a716-446655440020" + PROJECT_ID = "550e8400-e29b-41d4-a716-4466554400ff" + + def setup_method(self) -> None: + _clear_accessor() + + def teardown_method(self) -> None: + _clear_accessor() + + def _leaf_id(self, tmp_path: Any, monkeypatch: pytest.MonkeyPatch) -> Any: + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() + runtime = UiPathLangGraphRuntime(graph=_build_graph().compile()) + token = runtime._push_reference_context() + try: + ctx = ReferenceContextAccessor.get() + return ctx.entries[-1].reference_id if ctx and len(ctx) else None + finally: + ReferenceContextAccessor.reset(token) + _read_config_id.cache_clear() + + def test_uipath_json_id_wins_over_env_agent_id( + self, tmp_path: Any, monkeypatch: pytest.MonkeyPatch + ) -> None: + """resolve_project_id() prefers uipath.json#id, so the entry must too.""" + import json + + monkeypatch.delenv("UIPATH_PROCESS_VERSION", raising=False) + monkeypatch.setenv("UIPATH_AGENT_ID", self.ENV_AGENT_ID) + (tmp_path / "uipath.json").write_text(json.dumps({"id": self.PROJECT_ID})) + monkeypatch.chdir(tmp_path) + + assert self._leaf_id(tmp_path, monkeypatch) == self.PROJECT_ID + + def test_falls_back_to_env_agent_id( + self, tmp_path: Any, monkeypatch: pytest.MonkeyPatch + ) -> None: + """With no uipath.json, UIPATH_AGENT_ID is what ReferenceId resolves to.""" + monkeypatch.delenv("UIPATH_PROCESS_VERSION", raising=False) + monkeypatch.setenv("UIPATH_AGENT_ID", self.ENV_AGENT_ID) + monkeypatch.chdir(tmp_path) + + assert self._leaf_id(tmp_path, monkeypatch) == self.ENV_AGENT_ID + + def test_non_uuid_id_is_skipped_not_raised( + self, tmp_path: Any, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Env-var sources are unvalidated; a bad id must not fail the run.""" + monkeypatch.delenv("UIPATH_PROCESS_VERSION", raising=False) + monkeypatch.setenv("UIPATH_AGENT_ID", "not-a-uuid") + monkeypatch.chdir(tmp_path) + + assert self._leaf_id(tmp_path, monkeypatch) is None diff --git a/uv.lock b/uv.lock index fdc6bc03c..c113569fc 100644 --- a/uv.lock +++ b/uv.lock @@ -4828,7 +4828,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.18.15" +version = "0.18.16" source = { editable = "." } dependencies = [ { name = "a2a-sdk" },