From 29b297c71997ab07ccb831cb004e56e5e6669adf Mon Sep 17 00:00:00 2001 From: Eduard Dumitru Date: Tue, 11 Aug 2026 09:25:34 +0200 Subject: [PATCH 1/4] fix(ci): tolerate a trailing comma when injecting the runtime override force-runtime-override.py joined the existing override-dependencies items with the new wheel entry, but only stripped whitespace from them. A multi-line array normally ends in a trailing comma, so the join produced `[.., , ..]` and uv refused the file with "extra comma in array, expected value". This is why langchain-cross failed: the testcase it rewrites in uipath-python declares override-dependencies across several lines with a trailing comma. Co-Authored-By: Claude Opus 5 --- .github/scripts/force-runtime-override.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/scripts/force-runtime-override.py b/.github/scripts/force-runtime-override.py index 5abbacdc..49f88205 100644 --- a/.github/scripts/force-runtime-override.py +++ b/.github/scripts/force-runtime-override.py @@ -37,7 +37,8 @@ def _add_override(pyproject_path: Path, override: str) -> None: body, ) if override_match: - items = override_match.group("items").strip() + # A trailing comma must not survive the join below, or it yields `[..,, ..]` + items = override_match.group("items").strip().rstrip(",").strip() if quoted_override in items: return From d3fa6347f3ff47d9432ce8a81fa4265cc876def3 Mon Sep 17 00:00:00 2001 From: Eduard Dumitru Date: Tue, 11 Aug 2026 17:52:19 +0200 Subject: [PATCH 2/4] feat: optionally split output arguments into their own file Adds runtime.splitOutputArguments to uipath.json. When set, the output arguments are written next to the result file and the envelope carries an absolute outputArgumentsFilePath pointer instead of the inline output value, so a consumer can stream that file rather than materializing it. Opt-in and default-off: with the knob unset the emitted output is byte-identical to before. status/error/resume/resumeTriggers always stay inline. A failed write faults the run, like the two writes either side of it. Co-Authored-By: Claude Opus 5 --- pyproject.toml | 2 +- src/uipath/runtime/context.py | 36 +++++- tests/test_context.py | 229 ++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 4 files changed, 266 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8ade0630..84af1782 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.13.0" +version = "0.13.1" 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 05219b45..ae6ff8ae 100644 --- a/src/uipath/runtime/context.py +++ b/src/uipath/runtime/context.py @@ -23,6 +23,8 @@ logger = logging.getLogger(__name__) +OUTPUT_ARGUMENTS_FILE_NAME = "output.args.json" + _EXECUTION_SOURCE_BY_COMMAND: dict[str, str] = { "run": "runtime", "debug": "playground", @@ -87,6 +89,14 @@ class UiPathRuntimeContext(BaseModel): "If not specified, path is constructed from runtime_dir and result_file." ), ) + split_output_arguments: bool = Field( + False, + description=( + "Write the output arguments to their own file alongside the result file, " + "and carry an 'outputArgumentsFilePath' pointer in the result file " + "instead of the inline 'output' value." + ), + ) state_file: str = Field("state.db", description="Filename for the state database") state_file_path: str | None = Field( None, @@ -275,6 +285,17 @@ def __exit__(self, exc_type, exc_val, exc_tb): content = self.result.to_dict() + # Captured before the pop below, so output_file still gets the real args + output_payload = content.get("output", {}) + + if self.split_output_arguments: + output_arguments_path = self.resolved_output_arguments_file_path + os.makedirs(os.path.dirname(output_arguments_path), exist_ok=True) + with open(output_arguments_path, "w") as f: + json.dump(output_payload, f, default=str) + content.pop("output", None) + content["outputArgumentsFilePath"] = output_arguments_path + # Always write output file at runtime, except for inner runtimes # Inner runtimes have execution_id if self.job_id: @@ -283,7 +304,6 @@ def __exit__(self, exc_type, exc_val, exc_tb): # Write the execution output to file if requested if self.output_file: - output_payload = content.get("output", {}) with open(self.output_file, "w") as f: json.dump(output_payload, f, default=str) @@ -343,6 +363,19 @@ def resolved_result_file_path(self) -> str: return os.path.join(self.runtime_dir, self.result_file) return os.path.join("__uipath", "output.json") + @cached_property + def resolved_output_arguments_file_path(self) -> str: + """Get the full path to the output arguments file. + + Derived, not configured: the name is fixed and the directory is the result + file's, so the host cannot put the two files in different places and the + knob has exactly one encoding. + """ + return os.path.join( + os.path.dirname(os.path.abspath(self.resolved_result_file_path)), + OUTPUT_ARGUMENTS_FILE_NAME, + ) + @cached_property def resolved_state_file_path(self) -> str: """Get the full path to the state file.""" @@ -406,6 +439,7 @@ def from_config( mapping = { "dir": "runtime_dir", "outputFile": "result_file", # we need this to maintain back-compat with serverless runtime + "splitOutputArguments": "split_output_arguments", "stateFile": "state_file", "logsFile": "logs_file", } diff --git a/tests/test_context.py b/tests/test_context.py index 39bc4289..7f30add3 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,9 +1,11 @@ import json +import os from pathlib import Path from typing import Any import pytest from uipath.core.errors import ErrorCategory, UiPathFaultedTriggerError +from uipath.core.triggers import UiPathResumeTrigger from uipath.runtime.context import UiPathRuntimeContext from uipath.runtime.errors import ( @@ -427,3 +429,230 @@ def test_from_config_accepts_maestro_flow_voice_mode(tmp_path: Path) -> None: ctx = UiPathRuntimeContext.from_config(str(config_path)) assert ctx.voice_mode == "maestro_flow" + + +def test_from_config_maps_split_output_arguments(tmp_path: Path) -> None: + """runtime.splitOutputArguments should map onto the knob.""" + cfg = {"runtime": {"splitOutputArguments": True}} + config_path = tmp_path / "uipath.json" + config_path.write_text(json.dumps(cfg)) + + ctx = UiPathRuntimeContext.from_config(config_path=str(config_path)) + + assert ctx.split_output_arguments is True + + +def test_split_output_arguments_defaults_off_when_config_key_absent( + tmp_path: Path, +) -> None: + """The split stays off when the config omits the key.""" + cfg = {"runtime": {"outputFile": "my_output.json"}} + config_path = tmp_path / "uipath.json" + config_path.write_text(json.dumps(cfg)) + + ctx = UiPathRuntimeContext.from_config(config_path=str(config_path)) + + assert ctx.split_output_arguments is False + + +def test_output_arguments_file_is_a_sibling_of_the_result_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The arguments file lands next to the result file, never in the process CWD. + + The host names the directory once, through runtime_dir, and both files follow + it. The filename is not configurable, so the knob has exactly one encoding and + the two files cannot be pointed at different directories. + """ + cwd = tmp_path / "cwd" + cwd.mkdir() + monkeypatch.chdir(cwd) + runtime_dir = tmp_path / "runtime" + ctx = UiPathRuntimeContext( + job_id="job-sibling", + runtime_dir=str(runtime_dir), + result_file="result.json", + split_output_arguments=True, + ) + + arguments_path = Path(ctx.resolved_output_arguments_file_path) + assert arguments_path.parent == Path(ctx.resolved_result_file_path).parent + assert arguments_path.parent == runtime_dir + assert arguments_path.name == "output.args.json" + assert arguments_path.is_absolute() + assert cwd not in arguments_path.parents + + +def test_result_file_keeps_output_inline_when_split_disabled( + tmp_path: Path, +) -> None: + """Without the knob, the result file is byte-identical to the legacy envelope.""" + runtime_dir = tmp_path / "runtime" + ctx = UiPathRuntimeContext( + job_id="job-inline", + runtime_dir=str(runtime_dir), + result_file="result.json", + ) + + with ctx: + ctx.result = UiPathRuntimeResult( + status=UiPathRuntimeStatus.SUCCESSFUL, + output={"foo": "bar"}, + ) + + result_path = Path(ctx.resolved_result_file_path) + # The envelope is written in text mode, so json's newline reaches disk as os.linesep + expected = json.dumps( + {"output": {"foo": "bar"}, "status": "successful"}, indent=2 + ).replace("\n", os.linesep) + assert result_path.read_bytes() == expected.encode() + + content = json.loads(result_path.read_bytes()) + assert "outputArgumentsFilePath" not in content + assert not Path(ctx.resolved_output_arguments_file_path).exists() + + +def test_output_arguments_written_to_separate_file(tmp_path: Path) -> None: + """With the knob, the arguments move out and the envelope carries the path.""" + runtime_dir = tmp_path / "nested" / "runtime" + ctx = UiPathRuntimeContext( + job_id="job-split", + runtime_dir=str(runtime_dir), + result_file="result.json", + split_output_arguments=True, + ) + + with ctx: + ctx.result = UiPathRuntimeResult( + status=UiPathRuntimeStatus.SUCCESSFUL, + output={"foo": "bar"}, + ) + + arguments_path = Path(ctx.resolved_output_arguments_file_path) + # Parent directory is created on demand + assert json.loads(arguments_path.read_text()) == {"foo": "bar"} + + content = json.loads(Path(ctx.resolved_result_file_path).read_text()) + assert "output" not in content + assert content["status"] == UiPathRuntimeStatus.SUCCESSFUL.value + assert content["outputArgumentsFilePath"] == str(arguments_path) + assert Path(content["outputArgumentsFilePath"]).is_absolute() + + +def test_output_file_receives_bare_arguments_when_split_enabled( + tmp_path: Path, +) -> None: + """--output-file keeps receiving the bare arguments when both are set.""" + runtime_dir = tmp_path / "runtime" + output_path = tmp_path / "output.json" + ctx = UiPathRuntimeContext( + job_id="job-both", + runtime_dir=str(runtime_dir), + result_file="result.json", + output_file=str(output_path), + split_output_arguments=True, + ) + + with ctx: + ctx.result = UiPathRuntimeResult( + status=UiPathRuntimeStatus.SUCCESSFUL, + output={"foo": "bar"}, + ) + + assert json.loads(output_path.read_text()) == {"foo": "bar"} + arguments_path = Path(ctx.resolved_output_arguments_file_path) + assert json.loads(arguments_path.read_text()) == {"foo": "bar"} + + +def test_faulted_run_keeps_status_and_error_inline_when_split_enabled( + tmp_path: Path, +) -> None: + """status and error stay in the envelope when the arguments are split out.""" + runtime_dir = tmp_path / "runtime" + ctx = UiPathRuntimeContext( + job_id="job-faulted-split", + runtime_dir=str(runtime_dir), + result_file="result.json", + split_output_arguments=True, + ) + + with pytest.raises(RuntimeError, match="Stream blew up"): + with ctx: + raise RuntimeError("Stream blew up") + + content = json.loads(Path(ctx.resolved_result_file_path).read_text()) + assert content["status"] == UiPathRuntimeStatus.FAULTED.value + assert content["error"]["code"] == "ERROR_RuntimeError" + assert "Stream blew up" in content["error"]["detail"] + assert "output" not in content + + # The pointer must never advertise a file that was not actually written + arguments_path = Path(content["outputArgumentsFilePath"]) + assert arguments_path.exists() + assert json.loads(arguments_path.read_text()) == {} + + +def test_resume_triggers_stay_inline_when_split_enabled(tmp_path: Path) -> None: + """resume and resumeTriggers must never be moved out of the envelope. + + They are what makes a suspended job resumable, so a split that swept them + into the arguments file would strand the job. + """ + runtime_dir = tmp_path / "runtime" + ctx = UiPathRuntimeContext( + job_id="job-suspended-split", + runtime_dir=str(runtime_dir), + result_file="result.json", + split_output_arguments=True, + ) + + trigger = UiPathResumeTrigger(item_key="k") + with ctx: + ctx.result = UiPathRuntimeResult( + status=UiPathRuntimeStatus.SUSPENDED, + output={"foo": "bar"}, + trigger=trigger, + triggers=[trigger], + ) + + content = json.loads(Path(ctx.resolved_result_file_path).read_text()) + assert content["status"] == UiPathRuntimeStatus.SUSPENDED.value + assert content["resume"]["itemKey"] == "k" + assert len(content["resumeTriggers"]) == 1 + assert content["resumeTriggers"][0]["itemKey"] == "k" + # Only the output moved out + assert "output" not in content + arguments_path = Path(content["outputArgumentsFilePath"]) + assert json.loads(arguments_path.read_text()) == {"foo": "bar"} + + +def test_failed_arguments_write_faults_the_run(tmp_path: Path) -> None: + """A failing arguments write faults the run, like every other write in __exit__. + + Falling back to the inline value would write the same bytes to the same volume, + so it cannot rescue the failure that actually matters, and it would hand the + consumer the payload the split exists to keep out of its heap. + """ + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir() + # A directory cannot be opened for writing, so the split write fails + (runtime_dir / "output.args.json").mkdir() + ctx = UiPathRuntimeContext( + job_id="job-failed-write", + runtime_dir=str(runtime_dir), + result_file="result.json", + split_output_arguments=True, + ) + + with pytest.raises(RuntimeError) as excinfo: + with ctx: + ctx.result = UiPathRuntimeResult( + status=UiPathRuntimeStatus.SUCCESSFUL, + output={"foo": "bar"}, + ) + + assert "RUNTIME_SHUTDOWN_ERROR" in str(excinfo.value) + + content = json.loads(Path(ctx.resolved_result_file_path).read_text()) + assert content["status"] == UiPathRuntimeStatus.FAULTED.value + assert content["error"]["code"] == "RUNTIME_SHUTDOWN_ERROR" diff --git a/uv.lock b/uv.lock index 43a3b2eb..1350d745 100644 --- a/uv.lock +++ b/uv.lock @@ -1153,7 +1153,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.13.0" +version = "0.13.1" source = { editable = "." } dependencies = [ { name = "chardet" }, From 3fecb4cdbe62e1d1956fd3dfa5f5ef10e3b5aced Mon Sep 17 00:00:00 2001 From: Eduard Dumitru Date: Wed, 12 Aug 2026 09:43:23 +0200 Subject: [PATCH 3/4] fix: derive the arguments filename from the result file and gate it on job_id Two collision and lifetime problems, both in the same guard: The filename was a constant, so naming the result file output.args.json pointed both writes at one path. The envelope landed last, overwriting the arguments and carrying a pointer to itself - every write succeeded, so nothing raised and the consumer read the envelope as the job's own arguments. Inserting the suffix before the extension instead (output.json -> output.args.json) makes that structurally impossible: a result file named output.args.json now yields output.args.args.json. The write was also not gated on job_id, unlike the envelope write below it. A local run or an inner runtime therefore wrote the full payload to disk and then wrote no envelope pointing at it. The rule is explicit vs implicit: --output-file is written job or no job because the caller named a path, whereas the envelope is written only because a job implies one. This is a modifier on the envelope, not a request for a file, so it follows the envelope. Both are pinned: a fixed filename and a missing job_id gate each fail a test. Co-Authored-By: Claude Opus 5 --- src/uipath/runtime/context.py | 20 +++++++++------ tests/test_context.py | 48 +++++++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/src/uipath/runtime/context.py b/src/uipath/runtime/context.py index ae6ff8ae..859d42b6 100644 --- a/src/uipath/runtime/context.py +++ b/src/uipath/runtime/context.py @@ -23,7 +23,7 @@ logger = logging.getLogger(__name__) -OUTPUT_ARGUMENTS_FILE_NAME = "output.args.json" +OUTPUT_ARGUMENTS_SUFFIX = ".args" _EXECUTION_SOURCE_BY_COMMAND: dict[str, str] = { "run": "runtime", @@ -288,7 +288,9 @@ def __exit__(self, exc_type, exc_val, exc_tb): # Captured before the pop below, so output_file still gets the real args output_payload = content.get("output", {}) - if self.split_output_arguments: + # Gated on job_id like the envelope write below: the pointer only has a + # reader when there is a job, so without one there is nothing to point at it + if self.split_output_arguments and self.job_id: output_arguments_path = self.resolved_output_arguments_file_path os.makedirs(os.path.dirname(output_arguments_path), exist_ok=True) with open(output_arguments_path, "w") as f: @@ -367,13 +369,15 @@ def resolved_result_file_path(self) -> str: def resolved_output_arguments_file_path(self) -> str: """Get the full path to the output arguments file. - Derived, not configured: the name is fixed and the directory is the result - file's, so the host cannot put the two files in different places and the - knob has exactly one encoding. + Derived from the result file, not configured: the host cannot put the two + in different places, and inserting the suffix before the extension keeps + them distinct whatever the result file is called. """ - return os.path.join( - os.path.dirname(os.path.abspath(self.resolved_result_file_path)), - OUTPUT_ARGUMENTS_FILE_NAME, + result_path = Path(os.path.abspath(self.resolved_result_file_path)) + return str( + result_path.with_name( + f"{result_path.stem}{OUTPUT_ARGUMENTS_SUFFIX}{result_path.suffix}" + ) ) @cached_property diff --git a/tests/test_context.py b/tests/test_context.py index 7f30add3..d04de55e 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -478,11 +478,55 @@ def test_output_arguments_file_is_a_sibling_of_the_result_file( arguments_path = Path(ctx.resolved_output_arguments_file_path) assert arguments_path.parent == Path(ctx.resolved_result_file_path).parent assert arguments_path.parent == runtime_dir - assert arguments_path.name == "output.args.json" + assert arguments_path.name == "result.args.json" assert arguments_path.is_absolute() assert cwd not in arguments_path.parents +def test_output_arguments_file_cannot_collide_with_the_result_file( + tmp_path: Path, +) -> None: + """The suffix goes before the extension, so the two names can never converge. + + Naming the result file after the arguments file used to produce one path for + both: the envelope overwrote the arguments and then pointed at itself. + """ + ctx = UiPathRuntimeContext( + job_id="job-collide", + runtime_dir=str(tmp_path / "runtime"), + result_file="output.args.json", + split_output_arguments=True, + ) + + assert Path(ctx.resolved_output_arguments_file_path).name == "output.args.args.json" + assert ctx.resolved_output_arguments_file_path != os.path.abspath( + ctx.resolved_result_file_path + ) + + +def test_output_arguments_file_not_written_without_a_job(tmp_path: Path) -> None: + """No job means no envelope, so the pointer would have no reader and no file. + + A local `uipath run` has no UIPATH_JOB_KEY; writing the payload there would + leave a full copy on disk that nothing references. + """ + runtime_dir = tmp_path / "runtime" + ctx = UiPathRuntimeContext( + runtime_dir=str(runtime_dir), + result_file="result.json", + split_output_arguments=True, + ) + + with ctx: + ctx.result = UiPathRuntimeResult( + status=UiPathRuntimeStatus.SUCCESSFUL, + output={"foo": "bar"}, + ) + + assert not Path(ctx.resolved_output_arguments_file_path).exists() + assert not Path(ctx.resolved_result_file_path).exists() + + def test_result_file_keeps_output_inline_when_split_disabled( tmp_path: Path, ) -> None: @@ -636,7 +680,7 @@ def test_failed_arguments_write_faults_the_run(tmp_path: Path) -> None: runtime_dir = tmp_path / "runtime" runtime_dir.mkdir() # A directory cannot be opened for writing, so the split write fails - (runtime_dir / "output.args.json").mkdir() + (runtime_dir / "result.args.json").mkdir() ctx = UiPathRuntimeContext( job_id="job-failed-write", runtime_dir=str(runtime_dir), From 0ebd2eba47e3031530f830433b92fa35991f1cba Mon Sep 17 00:00:00 2001 From: Eduard Dumitru Date: Wed, 12 Aug 2026 10:18:59 +0200 Subject: [PATCH 4/4] docs: say why the payload is read rather than popped Review asked whether the read above the split could be folded into the pop inside it. It cannot, and the comment now says so: --output-file is written whether or not the split runs, so it needs the arguments in a local either way, and popping to re-insert them would move "output" after "status" in the envelope and break the byte-identity the default-off path is pinned on. Co-Authored-By: Claude Opus 5 --- src/uipath/runtime/context.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/uipath/runtime/context.py b/src/uipath/runtime/context.py index 859d42b6..575db03a 100644 --- a/src/uipath/runtime/context.py +++ b/src/uipath/runtime/context.py @@ -285,7 +285,9 @@ def __exit__(self, exc_type, exc_val, exc_tb): content = self.result.to_dict() - # Captured before the pop below, so output_file still gets the real args + # Read, not popped: output_file needs the arguments even when the split + # does not run, and popping to re-insert would move "output" after + # "status" in the envelope output_payload = content.get("output", {}) # Gated on job_id like the envelope write below: the pointer only has a