diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index b8effe0f0..868a66130 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.73" +version = "0.1.74" 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/_config.py b/packages/uipath-platform/src/uipath/platform/common/_config.py index 40db82214..759308f2b 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_config.py +++ b/packages/uipath-platform/src/uipath/platform/common/_config.py @@ -155,6 +155,16 @@ def job_key(self) -> str | None: return os.getenv(ENV_JOB_KEY, None) + @property + def execution_source(self) -> str | None: + """Execution source identifying the run context. + + Set by the ``uipath`` CLI from the executing command (run/debug/eval). + """ + from uipath.platform.common.constants import ENV_EXECUTION_SOURCE + + return os.getenv(ENV_EXECUTION_SOURCE, None) + @property def has_legacy_eval_folder(self) -> bool: from uipath.platform.common.constants import LEGACY_EVAL_FOLDER diff --git a/packages/uipath-platform/src/uipath/platform/common/_job_context.py b/packages/uipath-platform/src/uipath/platform/common/_job_context.py index ccbd99f22..29b5fbc50 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_job_context.py +++ b/packages/uipath-platform/src/uipath/platform/common/_job_context.py @@ -1,5 +1,5 @@ from ._config import UiPathConfig -from .constants import HEADER_JOB_KEY +from .constants import HEADER_GUARDRAILS_SOURCE, HEADER_JOB_KEY def header_job_key() -> dict[str, str]: @@ -11,3 +11,15 @@ def header_job_key() -> dict[str, str]: if not job_key: return {} return {HEADER_JOB_KEY: job_key} + + +def header_execution_source() -> dict[str, str]: + """Return the x-uipath-guardrails-source header for the execution source. + + Returns an empty dict when ``UiPathConfig.execution_source`` is unset or + empty. + """ + source = UiPathConfig.execution_source + if not source: + return {} + return {HEADER_GUARDRAILS_SOURCE: source} diff --git a/packages/uipath-platform/src/uipath/platform/common/constants.py b/packages/uipath-platform/src/uipath/platform/common/constants.py index 304ef64a6..a4e069a1c 100644 --- a/packages/uipath-platform/src/uipath/platform/common/constants.py +++ b/packages/uipath-platform/src/uipath/platform/common/constants.py @@ -26,6 +26,7 @@ ENV_UIPATH_TRACE_ID = "UIPATH_TRACE_ID" ENV_UIPATH_PROCESS_VERSION = "UIPATH_PROCESS_VERSION" ENV_UIPATH_CONFIG_PATH = "UIPATH_CONFIG_PATH" +ENV_EXECUTION_SOURCE = "UIPATH_EXECUTION_SOURCE" # Headers HEADER_FOLDER_KEY = "x-uipath-folderkey" @@ -39,6 +40,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 424b35dad..028102595 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py @@ -12,6 +12,7 @@ from ..common._base_service import BaseService from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext +from ..common._job_context import header_execution_source, header_job_key from ..common._models import Endpoint, RequestSpec from ..errors import EnrichedException from .guardrails import BuiltInValidatorGuardrail @@ -122,9 +123,16 @@ 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. trace_headers = build_trace_context_headers() - request_headers = {**(spec.headers or {}), **trace_headers} + request_headers = { + **(spec.headers or {}), + **trace_headers, + **header_execution_source(), + **header_job_key(), + } span_id = None try: response = self.request( diff --git a/packages/uipath-platform/tests/common/test_job_context.py b/packages/uipath-platform/tests/common/test_job_context.py index 6a4e1a97d..afcc49784 100644 --- a/packages/uipath-platform/tests/common/test_job_context.py +++ b/packages/uipath-platform/tests/common/test_job_context.py @@ -1,7 +1,15 @@ import pytest -from uipath.platform.common._job_context import header_job_key -from uipath.platform.common.constants import ENV_JOB_KEY, HEADER_JOB_KEY +from uipath.platform.common._job_context import ( + header_execution_source, + header_job_key, +) +from uipath.platform.common.constants import ( + ENV_EXECUTION_SOURCE, + ENV_JOB_KEY, + HEADER_GUARDRAILS_SOURCE, + HEADER_JOB_KEY, +) def test_returns_header_when_env_var_set(monkeypatch: pytest.MonkeyPatch) -> None: @@ -20,3 +28,21 @@ def test_returns_empty_when_env_var_blank(monkeypatch: pytest.MonkeyPatch) -> No monkeypatch.setenv(ENV_JOB_KEY, "") assert header_job_key() == {} + + +def test_execution_source_header_when_set(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(ENV_EXECUTION_SOURCE, "runtime") + + assert header_execution_source() == {HEADER_GUARDRAILS_SOURCE: "runtime"} + + +def test_execution_source_empty_when_unset(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(ENV_EXECUTION_SOURCE, raising=False) + + assert header_execution_source() == {} + + +def test_execution_source_empty_when_blank(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(ENV_EXECUTION_SOURCE, "") + + assert header_execution_source() == {} diff --git a/packages/uipath-platform/tests/services/test_guardrails_service.py b/packages/uipath-platform/tests/services/test_guardrails_service.py index dd20d5646..3fa347813 100644 --- a/packages/uipath-platform/tests/services/test_guardrails_service.py +++ b/packages/uipath-platform/tests/services/test_guardrails_service.py @@ -353,6 +353,104 @@ 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_EXECUTION_SOURCE", "runtime") + 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=[], + ) + + 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 their env vars are unset.""" + monkeypatch.delenv("UIPATH_EXECUTION_SOURCE", raising=False) + 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 9f696bfe4..144cd43a1 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.73" +version = "0.1.74" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 1bf92e0e1..84ad749a0 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.11.9" +version = "2.11.10" 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.73, <0.2.0", + "uipath-platform>=0.1.74, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/src/uipath/_cli/_utils/_execution_source.py b/packages/uipath/src/uipath/_cli/_utils/_execution_source.py new file mode 100644 index 000000000..74380fb26 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_utils/_execution_source.py @@ -0,0 +1,40 @@ +"""Execution source resolution for the CLI. + +Maps the executing CLI command to an AgentHub config ("source") and exposes it +to the SDK via the ``UIPATH_EXECUTION_SOURCE`` environment variable. Because +this runs in the same process that later executes the agent, downstream +services (e.g. guardrails) can read it from config and send it as the +``x-uipath-agenthub-config`` header. This covers both coded and low-code +agents, since both are launched through these CLI commands. +""" + +import os + +from uipath.platform.common.constants import ENV_EXECUTION_SOURCE + +# Execution source values, matching the low-code runtime's AgentExecutionType +# (run -> runtime, debug/dev -> playground, eval -> eval). +_RUNTIME_SOURCE = "runtime" +_PLAYGROUND_SOURCE = "playground" +_EVAL_SOURCE = "eval" + +_COMMAND_TO_SOURCE: dict[str, str] = { + "run": _RUNTIME_SOURCE, + "debug": _PLAYGROUND_SOURCE, + "dev": _PLAYGROUND_SOURCE, + "eval": _EVAL_SOURCE, +} + + +def set_execution_source(command: str) -> None: + """Set ``UIPATH_EXECUTION_SOURCE`` from the executing CLI command. + + No-op for commands that do not execute an agent. + + Args: + command: The CLI command name (e.g. "run", "debug", "eval"). + """ + source = _COMMAND_TO_SOURCE.get(command) + if source is None: + return + os.environ[ENV_EXECUTION_SOURCE] = source diff --git a/packages/uipath/src/uipath/_cli/cli_debug.py b/packages/uipath/src/uipath/_cli/cli_debug.py index 92b8ea454..2e7f117e4 100644 --- a/packages/uipath/src/uipath/_cli/cli_debug.py +++ b/packages/uipath/src/uipath/_cli/cli_debug.py @@ -87,6 +87,10 @@ def debug( attach: str | None, ) -> None: """Debug the project.""" + from ._utils._execution_source import set_execution_source + + set_execution_source("debug") + input_file = file or input_file # Setup debugging if requested if not setup_debugging(debug, debug_port): diff --git a/packages/uipath/src/uipath/_cli/cli_dev.py b/packages/uipath/src/uipath/_cli/cli_dev.py index 62740dc4b..998b8bbc4 100644 --- a/packages/uipath/src/uipath/_cli/cli_dev.py +++ b/packages/uipath/src/uipath/_cli/cli_dev.py @@ -52,6 +52,10 @@ def dev(interface: str, debug: bool, debug_port: int) -> None: INTERFACE: Choose 'terminal' for console interface (default) or 'web' for browser-based interface. """ + from ._utils._execution_source import set_execution_source + + set_execution_source("dev") + try: _check_dev_dependency(interface) except ImportError as e: diff --git a/packages/uipath/src/uipath/_cli/cli_eval.py b/packages/uipath/src/uipath/_cli/cli_eval.py index e101717d6..fcfd031cd 100644 --- a/packages/uipath/src/uipath/_cli/cli_eval.py +++ b/packages/uipath/src/uipath/_cli/cli_eval.py @@ -262,6 +262,10 @@ def eval( input_overrides: Input field overrides mapping (direct field override with deep merge) resume: Resume execution from a previous suspended state """ + from ._utils._execution_source import set_execution_source + + set_execution_source("eval") + set_llm_concurrency(max_llm_concurrency) should_register_progress_reporter = setup_reporting_prereq(no_report) diff --git a/packages/uipath/src/uipath/_cli/cli_run.py b/packages/uipath/src/uipath/_cli/cli_run.py index 48f42018b..c71937f74 100644 --- a/packages/uipath/src/uipath/_cli/cli_run.py +++ b/packages/uipath/src/uipath/_cli/cli_run.py @@ -125,6 +125,10 @@ def run( simulation: str | None, ) -> None: """Execute the project.""" + from ._utils._execution_source import set_execution_source + + set_execution_source("run") + input_file = file or input_file # Setup debugging if requested diff --git a/packages/uipath/tests/cli/unit/test_execution_source.py b/packages/uipath/tests/cli/unit/test_execution_source.py new file mode 100644 index 000000000..529655a90 --- /dev/null +++ b/packages/uipath/tests/cli/unit/test_execution_source.py @@ -0,0 +1,34 @@ +import os + +import pytest + +from uipath._cli._utils._execution_source import set_execution_source + + +@pytest.mark.parametrize( + "command,expected", + [ + ("run", "runtime"), + ("debug", "playground"), + ("dev", "playground"), + ("eval", "eval"), + ], +) +def test_set_execution_source_maps_command( + command: str, expected: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("UIPATH_EXECUTION_SOURCE", raising=False) + + set_execution_source(command) + + assert os.environ["UIPATH_EXECUTION_SOURCE"] == expected + + +def test_set_execution_source_noop_for_unmapped_command( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("UIPATH_EXECUTION_SOURCE", raising=False) + + set_execution_source("pack") + + assert "UIPATH_EXECUTION_SOURCE" not in os.environ diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 94b6b38fb..2fc09e8bf 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.9" +version = "2.11.10" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.73" +version = "0.1.74" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" },