From 2097995f63b3e4334c6e6edb7d1c915f5447a9c9 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:47:18 +0000 Subject: [PATCH 1/5] feat(axon): add working directory / directory to launch agent (#8689) --- .stats.yml | 4 ++-- src/runloop_api_client/types/shared/broker_mount.py | 6 ++++++ src/runloop_api_client/types/shared_params/broker_mount.py | 6 ++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index e948713b6..5e793c0f5 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 115 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/runloop-ai%2Frunloop-5b536a11a713dd4e47b270c130368dbfdf1f30282f262c160cd0411fcd291806.yml -openapi_spec_hash: f94d993a7f34461ebde7d0186e8e3c3a +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/runloop-ai%2Frunloop-406ebae7a9b8525a70a6400f4119209fc0230f73c93f56b63ee27b43cf7e5085.yml +openapi_spec_hash: 7ad158db2a73b2a9c31fce2f7f8f935a config_hash: 12de9459ff629b6a3072a75b236b7b70 diff --git a/src/runloop_api_client/types/shared/broker_mount.py b/src/runloop_api_client/types/shared/broker_mount.py index 6614a69ba..ed518c632 100644 --- a/src/runloop_api_client/types/shared/broker_mount.py +++ b/src/runloop_api_client/types/shared/broker_mount.py @@ -28,3 +28,9 @@ class BrokerMount(BaseModel): protocol: Optional[Literal["acp", "claude_json"]] = None """The protocol used by the broker to deliver events to the agent.""" + + working_directory: Optional[str] = None + """Working directory in which to launch the agent binary. + + Defaults to the home directory if not specified. + """ diff --git a/src/runloop_api_client/types/shared_params/broker_mount.py b/src/runloop_api_client/types/shared_params/broker_mount.py index c0225bb7c..233ae0095 100644 --- a/src/runloop_api_client/types/shared_params/broker_mount.py +++ b/src/runloop_api_client/types/shared_params/broker_mount.py @@ -30,3 +30,9 @@ class BrokerMount(TypedDict, total=False): protocol: Optional[Literal["acp", "claude_json"]] """The protocol used by the broker to deliver events to the agent.""" + + working_directory: Optional[str] + """Working directory in which to launch the agent binary. + + Defaults to the home directory if not specified. + """ From 64f27adfc44ef5242b19637377c50073c8240538 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 02:56:59 +0000 Subject: [PATCH 2/5] perf(client): optimize file structure copying in multipart requests --- src/runloop_api_client/_files.py | 56 ++++++++++- src/runloop_api_client/_utils/__init__.py | 1 - src/runloop_api_client/_utils/_utils.py | 15 --- .../resources/devboxes/devboxes.py | 13 ++- tests/test_deepcopy.py | 58 ----------- tests/test_files.py | 99 ++++++++++++++++++- 6 files changed, 159 insertions(+), 83 deletions(-) delete mode 100644 tests/test_deepcopy.py diff --git a/src/runloop_api_client/_files.py b/src/runloop_api_client/_files.py index 10f7978c8..7e9eeeb10 100644 --- a/src/runloop_api_client/_files.py +++ b/src/runloop_api_client/_files.py @@ -3,8 +3,8 @@ import io import os import pathlib -from typing import overload -from typing_extensions import TypeGuard +from typing import Sequence, cast, overload +from typing_extensions import TypeVar, TypeGuard import anyio @@ -17,7 +17,9 @@ HttpxFileContent, HttpxRequestFiles, ) -from ._utils import is_tuple_t, is_mapping_t, is_sequence_t +from ._utils import is_list, is_mapping, is_tuple_t, is_mapping_t, is_sequence_t + +_T = TypeVar("_T") def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]: @@ -121,3 +123,51 @@ async def async_read_file_content(file: FileContent) -> HttpxFileContent: return await anyio.Path(file).read_bytes() return file + + +def deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]]) -> _T: + """Copy only the containers along the given paths. + + Used to guard against mutation by extract_files without copying the entire structure. + Only dicts and lists that lie on a path are copied; everything else + is returned by reference. + + For example, given paths=[["foo", "files", "file"]] and the structure: + { + "foo": { + "bar": {"baz": {}}, + "files": {"file": } + } + } + The root dict, "foo", and "files" are copied (they lie on the path). + "bar" and "baz" are returned by reference (off the path). + """ + return _deepcopy_with_paths(item, paths, 0) + + +def _deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]], index: int) -> _T: + if not paths: + return item + if is_mapping(item): + key_to_paths: dict[str, list[Sequence[str]]] = {} + for path in paths: + if index < len(path): + key_to_paths.setdefault(path[index], []).append(path) + + # if no path continues through this mapping, it won't be mutated and copying it is redundant + if not key_to_paths: + return item + + result = dict(item) + for key, subpaths in key_to_paths.items(): + if key in result: + result[key] = _deepcopy_with_paths(result[key], subpaths, index + 1) + return cast(_T, result) + if is_list(item): + array_paths = [path for path in paths if index < len(path) and path[index] == ""] + + # if no path expects a list here, nothing will be mutated inside it - return by reference + if not array_paths: + return cast(_T, item) + return cast(_T, [_deepcopy_with_paths(entry, array_paths, index + 1) for entry in item]) + return item diff --git a/src/runloop_api_client/_utils/__init__.py b/src/runloop_api_client/_utils/__init__.py index 64f8a6d9c..7c831defa 100644 --- a/src/runloop_api_client/_utils/__init__.py +++ b/src/runloop_api_client/_utils/__init__.py @@ -25,7 +25,6 @@ coerce_integer as coerce_integer, file_from_path as file_from_path, strip_not_given as strip_not_given, - deepcopy_minimal as deepcopy_minimal, get_async_library as get_async_library, maybe_coerce_float as maybe_coerce_float, get_required_header as get_required_header, diff --git a/src/runloop_api_client/_utils/_utils.py b/src/runloop_api_client/_utils/_utils.py index 63b8cd602..771859f5e 100644 --- a/src/runloop_api_client/_utils/_utils.py +++ b/src/runloop_api_client/_utils/_utils.py @@ -177,21 +177,6 @@ def is_iterable(obj: object) -> TypeGuard[Iterable[object]]: return isinstance(obj, Iterable) -def deepcopy_minimal(item: _T) -> _T: - """Minimal reimplementation of copy.deepcopy() that will only copy certain object types: - - - mappings, e.g. `dict` - - list - - This is done for performance reasons. - """ - if is_mapping(item): - return cast(_T, {k: deepcopy_minimal(v) for k, v in item.items()}) - if is_list(item): - return cast(_T, [deepcopy_minimal(entry) for entry in item]) - return item - - # copied from https://github.com/Rapptz/RoboDanny def human_join(seq: Sequence[str], *, delim: str = ", ", final: str = "or") -> str: size = len(seq) diff --git a/src/runloop_api_client/resources/devboxes/devboxes.py b/src/runloop_api_client/resources/devboxes/devboxes.py index a80acc108..704d05648 100644 --- a/src/runloop_api_client/resources/devboxes/devboxes.py +++ b/src/runloop_api_client/resources/devboxes/devboxes.py @@ -37,8 +37,9 @@ devbox_snapshot_disk_async_params, devbox_write_file_contents_params, ) +from ..._files import deepcopy_with_paths from ..._types import Body, Omit, Query, Headers, NotGiven, FileTypes, omit, not_given -from ..._utils import is_given, extract_files, path_template, maybe_transform, deepcopy_minimal, async_maybe_transform +from ..._utils import is_given, extract_files, path_template, maybe_transform, async_maybe_transform from ..._compat import cached_property from .executions import ( ExecutionsResource, @@ -1607,11 +1608,12 @@ def upload_file( raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") if not is_given(timeout) and self._client.timeout == DEFAULT_TIMEOUT: timeout = 600 - body = deepcopy_minimal( + body = deepcopy_with_paths( { "path": path, "file": file, - } + }, + [["file"]], ) files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) # It should be noted that the actual Content-Type header that will be @@ -3241,11 +3243,12 @@ async def upload_file( raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") if not is_given(timeout) and self._client.timeout == DEFAULT_TIMEOUT: timeout = 600 - body = deepcopy_minimal( + body = deepcopy_with_paths( { "path": path, "file": file, - } + }, + [["file"]], ) files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) # It should be noted that the actual Content-Type header that will be diff --git a/tests/test_deepcopy.py b/tests/test_deepcopy.py deleted file mode 100644 index 89eb51864..000000000 --- a/tests/test_deepcopy.py +++ /dev/null @@ -1,58 +0,0 @@ -from runloop_api_client._utils import deepcopy_minimal - - -def assert_different_identities(obj1: object, obj2: object) -> None: - assert obj1 == obj2 - assert id(obj1) != id(obj2) - - -def test_simple_dict() -> None: - obj1 = {"foo": "bar"} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - - -def test_nested_dict() -> None: - obj1 = {"foo": {"bar": True}} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1["foo"], obj2["foo"]) - - -def test_complex_nested_dict() -> None: - obj1 = {"foo": {"bar": [{"hello": "world"}]}} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1["foo"], obj2["foo"]) - assert_different_identities(obj1["foo"]["bar"], obj2["foo"]["bar"]) - assert_different_identities(obj1["foo"]["bar"][0], obj2["foo"]["bar"][0]) - - -def test_simple_list() -> None: - obj1 = ["a", "b", "c"] - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - - -def test_nested_list() -> None: - obj1 = ["a", [1, 2, 3]] - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1[1], obj2[1]) - - -class MyObject: ... - - -def test_ignores_other_types() -> None: - # custom classes - my_obj = MyObject() - obj1 = {"foo": my_obj} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert obj1["foo"] is my_obj - - # tuples - obj3 = ("a", "b") - obj4 = deepcopy_minimal(obj3) - assert obj3 is obj4 diff --git a/tests/test_files.py b/tests/test_files.py index 6b19b3721..f78bb843b 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -4,7 +4,8 @@ import pytest from dirty_equals import IsDict, IsList, IsBytes, IsTuple -from runloop_api_client._files import to_httpx_files, async_to_httpx_files +from runloop_api_client._files import to_httpx_files, deepcopy_with_paths, async_to_httpx_files +from runloop_api_client._utils import extract_files readme_path = Path(__file__).parent.parent.joinpath("README.md") @@ -49,3 +50,99 @@ def test_string_not_allowed() -> None: "file": "foo", # type: ignore } ) + + +def assert_different_identities(obj1: object, obj2: object) -> None: + assert obj1 == obj2 + assert obj1 is not obj2 + + +class TestDeepcopyWithPaths: + def test_copies_top_level_dict(self) -> None: + original = {"file": b"data", "other": "value"} + result = deepcopy_with_paths(original, [["file"]]) + assert_different_identities(result, original) + + def test_file_value_is_same_reference(self) -> None: + file_bytes = b"contents" + original = {"file": file_bytes} + result = deepcopy_with_paths(original, [["file"]]) + assert_different_identities(result, original) + assert result["file"] is file_bytes + + def test_list_popped_wholesale(self) -> None: + files = [b"f1", b"f2"] + original = {"files": files, "title": "t"} + result = deepcopy_with_paths(original, [["files", ""]]) + assert_different_identities(result, original) + result_files = result["files"] + assert isinstance(result_files, list) + assert_different_identities(result_files, files) + + def test_nested_array_path_copies_list_and_elements(self) -> None: + elem1 = {"file": b"f1", "extra": 1} + elem2 = {"file": b"f2", "extra": 2} + original = {"items": [elem1, elem2]} + result = deepcopy_with_paths(original, [["items", "", "file"]]) + assert_different_identities(result, original) + result_items = result["items"] + assert isinstance(result_items, list) + assert_different_identities(result_items, original["items"]) + assert_different_identities(result_items[0], elem1) + assert_different_identities(result_items[1], elem2) + + def test_empty_paths_returns_same_object(self) -> None: + original = {"foo": "bar"} + result = deepcopy_with_paths(original, []) + assert result is original + + def test_multiple_paths(self) -> None: + f1 = b"file1" + f2 = b"file2" + original = {"a": f1, "b": f2, "c": "unchanged"} + result = deepcopy_with_paths(original, [["a"], ["b"]]) + assert_different_identities(result, original) + assert result["a"] is f1 + assert result["b"] is f2 + assert result["c"] is original["c"] + + def test_extract_files_does_not_mutate_original_top_level(self) -> None: + file_bytes = b"contents" + original = {"file": file_bytes, "other": "value"} + + copied = deepcopy_with_paths(original, [["file"]]) + extracted = extract_files(copied, paths=[["file"]]) + + assert extracted == [("file", file_bytes)] + assert original == {"file": file_bytes, "other": "value"} + assert copied == {"other": "value"} + + def test_extract_files_does_not_mutate_original_nested_array_path(self) -> None: + file1 = b"f1" + file2 = b"f2" + original = { + "items": [ + {"file": file1, "extra": 1}, + {"file": file2, "extra": 2}, + ], + "title": "example", + } + + copied = deepcopy_with_paths(original, [["items", "", "file"]]) + extracted = extract_files(copied, paths=[["items", "", "file"]]) + + assert extracted == [("items[][file]", file1), ("items[][file]", file2)] + assert original == { + "items": [ + {"file": file1, "extra": 1}, + {"file": file2, "extra": 2}, + ], + "title": "example", + } + assert copied == { + "items": [ + {"extra": 1}, + {"extra": 2}, + ], + "title": "example", + } From c72f34dca3653010eb76a762a68519c996e74860 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 02:58:44 +0000 Subject: [PATCH 3/5] chore(tests): bump steady to v0.22.1 --- scripts/mock | 6 +++--- scripts/test | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 5cd7c157b..feebe5ed7 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.20.2 -- steady --version + npm exec --package=@stdy/cli@0.22.1 -- steady --version - npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index 5f30115cb..a066eccc3 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.20.2 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.22.1 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 From 7467d2f20bd8a8d336a493b386226d232c355685 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 04:05:27 +0000 Subject: [PATCH 4/5] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 5e793c0f5..056301a0a 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 115 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/runloop-ai%2Frunloop-406ebae7a9b8525a70a6400f4119209fc0230f73c93f56b63ee27b43cf7e5085.yml -openapi_spec_hash: 7ad158db2a73b2a9c31fce2f7f8f935a +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/runloop-ai%2Frunloop-e8ff6f465a843ae55c394e11e243d9d8b31a2b5542d899aff2e167bcfde2858f.yml +openapi_spec_hash: 41ee9d50105022e0e253137efdb41d2a config_hash: 12de9459ff629b6a3072a75b236b7b70 From c137d18b3430333d0171ba206d788c82150a7299 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 04:05:54 +0000 Subject: [PATCH 5/5] release: 1.20.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 19 +++++++++++++++++++ pyproject.toml | 2 +- src/runloop_api_client/_version.py | 2 +- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index de44c40d8..69eb19a7b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.19.0" + ".": "1.20.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index afa8cdb3c..5efea77a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 1.20.0 (2026-04-21) + +Full Changelog: [v1.19.0...v1.20.0](https://github.com/runloopai/api-client-python/compare/v1.19.0...v1.20.0) + +### Features + +* add OO SDK support for new Agent API calls ([#786](https://github.com/runloopai/api-client-python/issues/786)) ([6197567](https://github.com/runloopai/api-client-python/commit/6197567bee84193beca7699b8616b46479607510)) +* **axon:** add working directory / directory to launch agent ([#8689](https://github.com/runloopai/api-client-python/issues/8689)) ([2097995](https://github.com/runloopai/api-client-python/commit/2097995f63b3e4334c6e6edb7d1c915f5447a9c9)) + + +### Performance Improvements + +* **client:** optimize file structure copying in multipart requests ([64f27ad](https://github.com/runloopai/api-client-python/commit/64f27adfc44ef5242b19637377c50073c8240538)) + + +### Chores + +* **tests:** bump steady to v0.22.1 ([c72f34d](https://github.com/runloopai/api-client-python/commit/c72f34dca3653010eb76a762a68519c996e74860)) + ## 1.19.0 (2026-04-13) Full Changelog: [v1.18.1...v1.19.0](https://github.com/runloopai/api-client-python/compare/v1.18.1...v1.19.0) diff --git a/pyproject.toml b/pyproject.toml index 64ca9ee03..f437210e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "runloop_api_client" -version = "1.19.0" +version = "1.20.0" description = "The official Python library for the runloop API" dynamic = ["readme"] license = "MIT" diff --git a/src/runloop_api_client/_version.py b/src/runloop_api_client/_version.py index 63342c137..a0cf0beb2 100644 --- a/src/runloop_api_client/_version.py +++ b/src/runloop_api_client/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "runloop_api_client" -__version__ = "1.19.0" # x-release-please-version +__version__ = "1.20.0" # x-release-please-version