From 9b2abc64fdb0e02f2bac16dd183e3c308010a108 Mon Sep 17 00:00:00 2001 From: Valentina Bojan Date: Wed, 24 Jun 2026 16:37:22 +0300 Subject: [PATCH 1/2] feat(context): add execution_source derived from command Add UiPathRuntimeContext.execution_source, derived from the executing command (run -> runtime, debug/dev -> playground, eval -> eval) in with_defaults. This lets execution metadata flow through the existing context boundary instead of an out-of-band env var, so downstream platform calls (e.g. guardrails) can identify the run context and it stays correctly scoped in concurrent runs. Co-Authored-By: Claude Opus 4.8 --- pyproject.toml | 2 +- src/uipath/runtime/context.py | 21 ++++++++++++++++++ tests/test_context.py | 42 +++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c1427ea..ec055aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.11.3" +version = "0.11.4" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/runtime/context.py b/src/uipath/runtime/context.py index c655a76..524d7e5 100644 --- a/src/uipath/runtime/context.py +++ b/src/uipath/runtime/context.py @@ -23,6 +23,15 @@ logger = logging.getLogger(__name__) +# Maps the executing command to an execution source. Commands that do not run +# an agent are absent, leaving execution_source unset. +_EXECUTION_SOURCE_BY_COMMAND: dict[str, str] = { + "run": "runtime", + "debug": "playground", + "dev": "playground", + "eval": "eval", +} + class UiPathRuntimeContext(BaseModel): """Context information passed throughout the runtime execution.""" @@ -31,6 +40,14 @@ class UiPathRuntimeContext(BaseModel): input: str | None = None resume: bool = False command: str | None = None + execution_source: str | None = Field( + None, + description=( + "Execution source derived from the command " + "(runtime/playground/eval). Propagated to platform clients so " + "downstream calls (e.g. guardrails) can identify the run context." + ), + ) job_id: str | None = None conversation_id: str | None = Field( None, description="Conversation identifier for CAS" @@ -345,6 +362,10 @@ def with_defaults( for k, v in kwargs.items(): setattr(base, k, v) + # Derive the execution source from the command unless explicitly set. + if base.execution_source is None and base.command is not None: + base.execution_source = _EXECUTION_SOURCE_BY_COMMAND.get(base.command) + return base @classmethod diff --git a/tests/test_context.py b/tests/test_context.py index d4868c7..92482a7 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -318,3 +318,45 @@ def test_string_output_wrapped_in_dict() -> None: assert result_dict["output"] == {"output": "primitive str"} assert result_dict["status"] == UiPathRuntimeStatus.SUCCESSFUL + + +@pytest.mark.parametrize( + "command,expected", + [ + ("run", "runtime"), + ("debug", "playground"), + ("dev", "playground"), + ("eval", "eval"), + ], +) +def test_with_defaults_derives_execution_source( + command: str, expected: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """execution_source is derived from the command when not provided.""" + monkeypatch.chdir(tmp_path) + + ctx = UiPathRuntimeContext.with_defaults(command=command) + + assert ctx.execution_source == expected + + +def test_with_defaults_execution_source_none_for_unmapped_command( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Commands that do not run an agent leave execution_source unset.""" + monkeypatch.chdir(tmp_path) + + ctx = UiPathRuntimeContext.with_defaults(command="pack") + + assert ctx.execution_source is None + + +def test_with_defaults_explicit_execution_source_not_overwritten( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An explicitly provided execution_source takes precedence over the command.""" + monkeypatch.chdir(tmp_path) + + ctx = UiPathRuntimeContext.with_defaults(command="run", execution_source="custom") + + assert ctx.execution_source == "custom" diff --git a/uv.lock b/uv.lock index 6b5443c..0a07a22 100644 --- a/uv.lock +++ b/uv.lock @@ -1012,7 +1012,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.11.3" +version = "0.11.4" source = { editable = "." } dependencies = [ { name = "uipath-core" }, From 711246aa9e9cfc7c469cd419fd7b0e153093a076 Mon Sep 17 00:00:00 2001 From: Valentina Bojan Date: Wed, 24 Jun 2026 16:44:57 +0300 Subject: [PATCH 2/2] refactor(context): derive execution_source via model_validator Move derivation out of with_defaults into a model_validator so it fires on the plain-constructor path too (e.g. dev/init), and never assign None for unmapped commands so the field stays absent under model_dump(exclude_unset=True). with_defaults re-applies it since it mutates command via setattr after construction. Addresses Copilot review feedback on PR #132. Co-Authored-By: Claude Opus 4.8 --- src/uipath/runtime/context.py | 38 ++++++++++++++++++++++++----------- tests/test_context.py | 37 ++++++++++++++++++++++------------ 2 files changed, 50 insertions(+), 25 deletions(-) diff --git a/src/uipath/runtime/context.py b/src/uipath/runtime/context.py index 524d7e5..924548c 100644 --- a/src/uipath/runtime/context.py +++ b/src/uipath/runtime/context.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from uipath.core.errors import UiPathFaultedTriggerError from uipath.core.tracing import UiPathTraceManager @@ -23,8 +23,6 @@ logger = logging.getLogger(__name__) -# Maps the executing command to an execution source. Commands that do not run -# an agent are absent, leaving execution_source unset. _EXECUTION_SOURCE_BY_COMMAND: dict[str, str] = { "run": "runtime", "debug": "playground", @@ -41,12 +39,7 @@ class UiPathRuntimeContext(BaseModel): resume: bool = False command: str | None = None execution_source: str | None = Field( - None, - description=( - "Execution source derived from the command " - "(runtime/playground/eval). Propagated to platform clients so " - "downstream calls (e.g. guardrails) can identify the run context." - ), + None, description="Execution source derived from the command." ) job_id: str | None = None conversation_id: str | None = Field( @@ -113,6 +106,28 @@ class UiPathRuntimeContext(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow") + def _apply_execution_source(self) -> None: + """Derive execution_source from the command, if not already set. + + Only assigns a mapped value, so the field stays unset (absent under + ``model_dump(exclude_unset=True)``) for unmapped commands, and an + explicitly-provided value is never overwritten. + """ + if self.execution_source is None and self.command is not None: + source = _EXECUTION_SOURCE_BY_COMMAND.get(self.command) + if source is not None: + self.execution_source = source + + @model_validator(mode="after") + def _derive_execution_source(self) -> "UiPathRuntimeContext": + """Derive execution_source on the constructor path (e.g. dev/init). + + ``with_defaults`` mutates ``command`` via ``setattr`` after construction, + so it re-applies the derivation itself. + """ + self._apply_execution_source() + return self + def get_input(self) -> dict[str, Any] | None: """Get parsed input data. @@ -362,9 +377,8 @@ def with_defaults( for k, v in kwargs.items(): setattr(base, k, v) - # Derive the execution source from the command unless explicitly set. - if base.execution_source is None and base.command is not None: - base.execution_source = _EXECUTION_SOURCE_BY_COMMAND.get(base.command) + # setattr does not re-run the validator, so derive explicitly. + base._apply_execution_source() return base diff --git a/tests/test_context.py b/tests/test_context.py index 92482a7..d3ed4ae 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -329,10 +329,24 @@ def test_string_output_wrapped_in_dict() -> None: ("eval", "eval"), ], ) +def test_constructor_derives_execution_source(command: str, expected: str) -> None: + """execution_source is derived from the command on the plain constructor path.""" + ctx = UiPathRuntimeContext(command=command) + + assert ctx.execution_source == expected + + +@pytest.mark.parametrize( + "command,expected", + [ + ("run", "runtime"), + ("eval", "eval"), + ], +) def test_with_defaults_derives_execution_source( command: str, expected: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """execution_source is derived from the command when not provided.""" + """execution_source is also derived via with_defaults.""" monkeypatch.chdir(tmp_path) ctx = UiPathRuntimeContext.with_defaults(command=command) @@ -340,23 +354,20 @@ def test_with_defaults_derives_execution_source( assert ctx.execution_source == expected -def test_with_defaults_execution_source_none_for_unmapped_command( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Commands that do not run an agent leave execution_source unset.""" - monkeypatch.chdir(tmp_path) +def test_execution_source_unset_for_unmapped_command() -> None: + """Commands that do not run an agent leave execution_source unset. - ctx = UiPathRuntimeContext.with_defaults(command="pack") + The field must remain unset (not explicitly None) so it is absent from + model_dump(exclude_unset=True). + """ + ctx = UiPathRuntimeContext(command="pack") assert ctx.execution_source is None + assert "execution_source" not in ctx.model_dump(exclude_unset=True) -def test_with_defaults_explicit_execution_source_not_overwritten( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_explicit_execution_source_not_overwritten() -> None: """An explicitly provided execution_source takes precedence over the command.""" - monkeypatch.chdir(tmp_path) - - ctx = UiPathRuntimeContext.with_defaults(command="run", execution_source="custom") + ctx = UiPathRuntimeContext(command="run", execution_source="custom") assert ctx.execution_source == "custom"