feat: optionally split output arguments into their own file [ROBO-5900] - #159
feat: optionally split output arguments into their own file [ROBO-5900]#159eduard-dumitru wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds an opt-in optimization to UiPathRuntimeContext to write large output arguments into a separate JSON file and emit only an outputArgumentsFilePath pointer in the result envelope, enabling downstream consumers to stream the payload without materializing it in memory.
Changes:
- Introduces
output_arguments_fileinUiPathRuntimeContextand mapsruntime.outputArgumentsFilefromuipath.json. - Updates
__exit__to (optionally) write the output payload to the separate file and replace inlineoutputwithoutputArgumentsFilePathon successful write (degrading to inline on failure). - Adds unit tests covering default-off byte identity, split-output behavior, absolute-path pointer behavior, and degradation-on-write-failure.
Reviewed changes
Copilot reviewed 3 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
src/uipath/runtime/context.py |
Adds the output_arguments_file knob, config mapping, and producer-side split-output write/pointer logic. |
tests/test_context.py |
Adds coverage for split-output contract (default-off identity, pointer semantics, degradation behavior). |
pyproject.toml |
Bumps package version to 0.13.1. |
uv.lock |
Updates lockfile version entry for uipath-runtime to 0.13.1. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
12310d4 to
2f29340
Compare
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 <noreply@anthropic.com>
e3c076d to
2c87963
Compare
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 <noreply@anthropic.com>
2c87963 to
d3fa634
Compare
…n 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 <noreply@anthropic.com>
| 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) |
There was a problem hiding this comment.
| content.pop("output", None) | |
| output_payload = content.pop("output",{}) |
no need for this line above
output_payload = content.get("output", {})
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 <noreply@anthropic.com>
|



Why
A consumer that only forwards the output arguments still has to materialize them. In Serverless the handler reads
output.json, parsesoutputinto aJToken, stringifies it, converts tobyte[], then uploads — roughly 4x the payload in the .NET heap for a byte-for-byte passthrough. At ~80 MB that is the difference between a job completing and an OOM.This lets the runtime write the arguments to their own file and report only a path, so the consumer can stream it. Producer side only; the consumer change is in hdens.
What
One key,
runtime.splitOutputArguments(bool, defaultfalse), inuipath.json. When set,__exit__writes the payload next to the result file and replaces the envelope'soutputwith an absoluteoutputArgumentsFilePath.The host chooses neither the path nor the name. Both are derived from the result file, which it already named once via
runtime.dir: the directory is the same, and the name inserts.argsbefore the extension (output.json→output.args.json). So the knob has exactly one encoding, the two files cannot land in different directories, and they cannot collide — a result file namedoutput.args.jsonyieldsoutput.args.args.json.Contract
outputArgumentsFilePath(absolute)runtime.splitOutputArgumentsjob_idstatus/error/resume/resumeTriggersalways stay inline, so faults and suspends still work. Consumers must treat the pointer as optional — absent by default and from older runtimes.Worth reviewing closely:
contentis untouched; the only change on that path is hoistingoutput_payloadabove the write.job_idlike the envelope write below it.--output-fileis written job or no job because the caller named a path; this is a modifier on the envelope, not a request for a file, so it follows the envelope.--output-filewrites either side of it, and the existing handler turns it into a structuredRUNTIME_SHUTDOWN_ERROR. An earlier revision degraded to inline instead — dropped, because it writes the same bytes to the same volume (so it cannot rescue a full disk, only defer it) and it would hand the consumer the payload this feature exists to keep out of its heap.Testing
425 passed; ruff, ruff-format and mypy clean. Covers:outputkeyoutputFileis calledjob_id--output-filestill receives the real arguments when both are setresume/resumeTriggersstay inlinestatus/errorinline and the advertised file existsRUNTIME_SHUTDOWN_ERRORFive mutants that previously passed now fail: popping
resumealongsideoutput; skipping the write for an empty payload while still advertising the pointer; resolving the path against the CWD; reverting to a fixed filename; and dropping thejob_idgate.Also here: one unrelated CI fix
29b297cis the oldest commit and touches only.github/scripts/force-runtime-override.py, so it reads and reverts independently.The script joined the existing
override-dependenciesitems with the injected wheel entry but stripped only whitespace, so a multi-line array's trailing comma produced[.., , ..]anduvrejected the file. That is what failedlangchain-crosshere. The bug is latent onmain—test-uipath.ymlwasskippedon every other recent branch, so this PR is just the first to run it.Follow-ups (separate PRs, in order)
uipath-pythonraises itsuipath-runtimefloor to this version.uipath-agents-pythonraises three ceilings that currently exclude it.hdensconsumes the pointer and streams the file into the blob upload (streaming from the POD filesystem through the HTTP Request all the way to Orchestrator)