Skip to content

Commit 2c87963

Browse files
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. A failing write degrades to inline and never changes job status, and status/error/resume/resumeTriggers always stay inline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 29b297c commit 2c87963

4 files changed

Lines changed: 287 additions & 3 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-runtime"
3-
version = "0.13.0"
3+
version = "0.13.1"
44
description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath/runtime/context.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323

2424
logger = logging.getLogger(__name__)
2525

26+
OUTPUT_ARGUMENTS_FILE_NAME = "output.args.json"
27+
2628
_EXECUTION_SOURCE_BY_COMMAND: dict[str, str] = {
2729
"run": "runtime",
2830
"debug": "playground",
@@ -87,6 +89,14 @@ class UiPathRuntimeContext(BaseModel):
8789
"If not specified, path is constructed from runtime_dir and result_file."
8890
),
8991
)
92+
split_output_arguments: bool = Field(
93+
False,
94+
description=(
95+
"Write the output arguments to their own file alongside the result file, "
96+
"and carry an 'outputArgumentsFilePath' pointer in the result file "
97+
"instead of the inline 'output' value."
98+
),
99+
)
90100
state_file: str = Field("state.db", description="Filename for the state database")
91101
state_file_path: str | None = Field(
92102
None,
@@ -275,6 +285,27 @@ def __exit__(self, exc_type, exc_val, exc_tb):
275285

276286
content = self.result.to_dict()
277287

288+
# Captured before the pop below, so output_file still gets the real args
289+
output_payload = content.get("output", {})
290+
291+
# Only an optimization, so a failing write degrades to the inline value
292+
# rather than changing the outcome of an otherwise successful run
293+
if self.split_output_arguments:
294+
try:
295+
output_arguments_path = self.resolved_output_arguments_file_path
296+
os.makedirs(os.path.dirname(output_arguments_path), exist_ok=True)
297+
with open(output_arguments_path, "w") as f:
298+
json.dump(output_payload, f, default=str)
299+
except Exception as e:
300+
logger.warning(
301+
"Failed to write the output arguments file, keeping the output arguments inline: %s",
302+
e,
303+
exc_info=True,
304+
)
305+
else:
306+
content.pop("output", None)
307+
content["outputArgumentsFilePath"] = output_arguments_path
308+
278309
# Always write output file at runtime, except for inner runtimes
279310
# Inner runtimes have execution_id
280311
if self.job_id:
@@ -283,7 +314,6 @@ def __exit__(self, exc_type, exc_val, exc_tb):
283314

284315
# Write the execution output to file if requested
285316
if self.output_file:
286-
output_payload = content.get("output", {})
287317
with open(self.output_file, "w") as f:
288318
json.dump(output_payload, f, default=str)
289319

@@ -343,6 +373,19 @@ def resolved_result_file_path(self) -> str:
343373
return os.path.join(self.runtime_dir, self.result_file)
344374
return os.path.join("__uipath", "output.json")
345375

376+
@cached_property
377+
def resolved_output_arguments_file_path(self) -> str:
378+
"""Get the full path to the output arguments file.
379+
380+
Derived, not configured: the name is fixed and the directory is the result
381+
file's, so the host cannot put the two files in different places and the
382+
knob has exactly one encoding.
383+
"""
384+
return os.path.join(
385+
os.path.dirname(os.path.abspath(self.resolved_result_file_path)),
386+
OUTPUT_ARGUMENTS_FILE_NAME,
387+
)
388+
346389
@cached_property
347390
def resolved_state_file_path(self) -> str:
348391
"""Get the full path to the state file."""
@@ -406,6 +449,7 @@ def from_config(
406449
mapping = {
407450
"dir": "runtime_dir",
408451
"outputFile": "result_file", # we need this to maintain back-compat with serverless runtime
452+
"splitOutputArguments": "split_output_arguments",
409453
"stateFile": "state_file",
410454
"logsFile": "logs_file",
411455
}

tests/test_context.py

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import json
2+
import logging
3+
import os
24
from pathlib import Path
35
from typing import Any
46

57
import pytest
68
from uipath.core.errors import ErrorCategory, UiPathFaultedTriggerError
9+
from uipath.core.triggers import UiPathResumeTrigger
710

811
from uipath.runtime.context import UiPathRuntimeContext
912
from uipath.runtime.errors import (
@@ -427,3 +430,240 @@ def test_from_config_accepts_maestro_flow_voice_mode(tmp_path: Path) -> None:
427430
ctx = UiPathRuntimeContext.from_config(str(config_path))
428431

429432
assert ctx.voice_mode == "maestro_flow"
433+
434+
435+
def test_from_config_maps_split_output_arguments(tmp_path: Path) -> None:
436+
"""runtime.splitOutputArguments should map onto the knob."""
437+
cfg = {"runtime": {"splitOutputArguments": True}}
438+
config_path = tmp_path / "uipath.json"
439+
config_path.write_text(json.dumps(cfg))
440+
441+
ctx = UiPathRuntimeContext.from_config(config_path=str(config_path))
442+
443+
assert ctx.split_output_arguments is True
444+
445+
446+
def test_split_output_arguments_defaults_off_when_config_key_absent(
447+
tmp_path: Path,
448+
) -> None:
449+
"""The split stays off when the config omits the key."""
450+
cfg = {"runtime": {"outputFile": "my_output.json"}}
451+
config_path = tmp_path / "uipath.json"
452+
config_path.write_text(json.dumps(cfg))
453+
454+
ctx = UiPathRuntimeContext.from_config(config_path=str(config_path))
455+
456+
assert ctx.split_output_arguments is False
457+
458+
459+
def test_output_arguments_file_is_a_sibling_of_the_result_file(
460+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
461+
) -> None:
462+
"""The arguments file lands next to the result file, never in the process CWD.
463+
464+
The host names the directory once, through runtime_dir, and both files follow
465+
it. The filename is not configurable, so the knob has exactly one encoding and
466+
the two files cannot be pointed at different directories.
467+
"""
468+
cwd = tmp_path / "cwd"
469+
cwd.mkdir()
470+
monkeypatch.chdir(cwd)
471+
runtime_dir = tmp_path / "runtime"
472+
ctx = UiPathRuntimeContext(
473+
job_id="job-sibling",
474+
runtime_dir=str(runtime_dir),
475+
result_file="result.json",
476+
split_output_arguments=True,
477+
)
478+
479+
arguments_path = Path(ctx.resolved_output_arguments_file_path)
480+
assert arguments_path.parent == Path(ctx.resolved_result_file_path).parent
481+
assert arguments_path.parent == runtime_dir
482+
assert arguments_path.name == "output.args.json"
483+
assert arguments_path.is_absolute()
484+
assert cwd not in arguments_path.parents
485+
486+
487+
def test_result_file_keeps_output_inline_when_split_disabled(
488+
tmp_path: Path,
489+
) -> None:
490+
"""Without the knob, the result file is byte-identical to the legacy envelope."""
491+
runtime_dir = tmp_path / "runtime"
492+
ctx = UiPathRuntimeContext(
493+
job_id="job-inline",
494+
runtime_dir=str(runtime_dir),
495+
result_file="result.json",
496+
)
497+
498+
with ctx:
499+
ctx.result = UiPathRuntimeResult(
500+
status=UiPathRuntimeStatus.SUCCESSFUL,
501+
output={"foo": "bar"},
502+
)
503+
504+
result_path = Path(ctx.resolved_result_file_path)
505+
# The envelope is written in text mode, so json's newline reaches disk as os.linesep
506+
expected = json.dumps(
507+
{"output": {"foo": "bar"}, "status": "successful"}, indent=2
508+
).replace("\n", os.linesep)
509+
assert result_path.read_bytes() == expected.encode()
510+
511+
content = json.loads(result_path.read_bytes())
512+
assert "outputArgumentsFilePath" not in content
513+
assert not Path(ctx.resolved_output_arguments_file_path).exists()
514+
515+
516+
def test_output_arguments_written_to_separate_file(tmp_path: Path) -> None:
517+
"""With the knob, the arguments move out and the envelope carries the path."""
518+
runtime_dir = tmp_path / "nested" / "runtime"
519+
ctx = UiPathRuntimeContext(
520+
job_id="job-split",
521+
runtime_dir=str(runtime_dir),
522+
result_file="result.json",
523+
split_output_arguments=True,
524+
)
525+
526+
with ctx:
527+
ctx.result = UiPathRuntimeResult(
528+
status=UiPathRuntimeStatus.SUCCESSFUL,
529+
output={"foo": "bar"},
530+
)
531+
532+
arguments_path = Path(ctx.resolved_output_arguments_file_path)
533+
# Parent directory is created on demand
534+
assert json.loads(arguments_path.read_text()) == {"foo": "bar"}
535+
536+
content = json.loads(Path(ctx.resolved_result_file_path).read_text())
537+
assert "output" not in content
538+
assert content["status"] == UiPathRuntimeStatus.SUCCESSFUL.value
539+
assert content["outputArgumentsFilePath"] == str(arguments_path)
540+
assert Path(content["outputArgumentsFilePath"]).is_absolute()
541+
542+
543+
def test_output_file_receives_bare_arguments_when_split_enabled(
544+
tmp_path: Path,
545+
) -> None:
546+
"""--output-file keeps receiving the bare arguments when both are set."""
547+
runtime_dir = tmp_path / "runtime"
548+
output_path = tmp_path / "output.json"
549+
ctx = UiPathRuntimeContext(
550+
job_id="job-both",
551+
runtime_dir=str(runtime_dir),
552+
result_file="result.json",
553+
output_file=str(output_path),
554+
split_output_arguments=True,
555+
)
556+
557+
with ctx:
558+
ctx.result = UiPathRuntimeResult(
559+
status=UiPathRuntimeStatus.SUCCESSFUL,
560+
output={"foo": "bar"},
561+
)
562+
563+
assert json.loads(output_path.read_text()) == {"foo": "bar"}
564+
arguments_path = Path(ctx.resolved_output_arguments_file_path)
565+
assert json.loads(arguments_path.read_text()) == {"foo": "bar"}
566+
567+
568+
def test_faulted_run_keeps_status_and_error_inline_when_split_enabled(
569+
tmp_path: Path,
570+
) -> None:
571+
"""status and error stay in the envelope when the arguments are split out."""
572+
runtime_dir = tmp_path / "runtime"
573+
ctx = UiPathRuntimeContext(
574+
job_id="job-faulted-split",
575+
runtime_dir=str(runtime_dir),
576+
result_file="result.json",
577+
split_output_arguments=True,
578+
)
579+
580+
with pytest.raises(RuntimeError, match="Stream blew up"):
581+
with ctx:
582+
raise RuntimeError("Stream blew up")
583+
584+
content = json.loads(Path(ctx.resolved_result_file_path).read_text())
585+
assert content["status"] == UiPathRuntimeStatus.FAULTED.value
586+
assert content["error"]["code"] == "ERROR_RuntimeError"
587+
assert "Stream blew up" in content["error"]["detail"]
588+
assert "output" not in content
589+
590+
# The pointer must never advertise a file that was not actually written
591+
arguments_path = Path(content["outputArgumentsFilePath"])
592+
assert arguments_path.exists()
593+
assert json.loads(arguments_path.read_text()) == {}
594+
595+
596+
def test_resume_triggers_stay_inline_when_split_enabled(tmp_path: Path) -> None:
597+
"""resume and resumeTriggers must never be moved out of the envelope.
598+
599+
They are what makes a suspended job resumable, so a split that swept them
600+
into the arguments file would strand the job.
601+
"""
602+
runtime_dir = tmp_path / "runtime"
603+
ctx = UiPathRuntimeContext(
604+
job_id="job-suspended-split",
605+
runtime_dir=str(runtime_dir),
606+
result_file="result.json",
607+
split_output_arguments=True,
608+
)
609+
610+
trigger = UiPathResumeTrigger(item_key="k")
611+
with ctx:
612+
ctx.result = UiPathRuntimeResult(
613+
status=UiPathRuntimeStatus.SUSPENDED,
614+
output={"foo": "bar"},
615+
trigger=trigger,
616+
triggers=[trigger],
617+
)
618+
619+
content = json.loads(Path(ctx.resolved_result_file_path).read_text())
620+
assert content["status"] == UiPathRuntimeStatus.SUSPENDED.value
621+
assert content["resume"]["itemKey"] == "k"
622+
assert len(content["resumeTriggers"]) == 1
623+
assert content["resumeTriggers"][0]["itemKey"] == "k"
624+
# Only the output moved out
625+
assert "output" not in content
626+
arguments_path = Path(content["outputArgumentsFilePath"])
627+
assert json.loads(arguments_path.read_text()) == {"foo": "bar"}
628+
629+
630+
def test_failed_arguments_write_degrades_to_inline_output(
631+
tmp_path: Path, caplog: pytest.LogCaptureFixture
632+
) -> None:
633+
"""A failing arguments write keeps the output inline and the run successful.
634+
635+
Contract: splitting the arguments out is only an optimization, so a failed
636+
write must never turn an otherwise successful run into a faulted one.
637+
"""
638+
runtime_dir = tmp_path / "runtime"
639+
runtime_dir.mkdir()
640+
# A directory cannot be opened for writing, so the split write fails
641+
(runtime_dir / "output.args.json").mkdir()
642+
ctx = UiPathRuntimeContext(
643+
job_id="job-degraded-write",
644+
runtime_dir=str(runtime_dir),
645+
result_file="result.json",
646+
split_output_arguments=True,
647+
)
648+
649+
with caplog.at_level(logging.WARNING, logger="uipath.runtime.context"):
650+
with ctx:
651+
ctx.result = UiPathRuntimeResult(
652+
status=UiPathRuntimeStatus.SUCCESSFUL,
653+
output={"foo": "bar"},
654+
)
655+
656+
content = json.loads(Path(ctx.resolved_result_file_path).read_text())
657+
assert content["status"] == UiPathRuntimeStatus.SUCCESSFUL.value
658+
assert content["output"] == {"foo": "bar"}
659+
assert "outputArgumentsFilePath" not in content
660+
assert "error" not in content
661+
662+
record = next(
663+
r
664+
for r in caplog.records
665+
if "Failed to write the output arguments file" in r.message
666+
)
667+
# The cause is interpolated and the traceback preserved, not just the prefix
668+
assert "%s" not in record.message
669+
assert record.exc_info is not None

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)