From c01c752799b828377206dd09eb77a3524c22df45 Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Mon, 30 Mar 2026 10:32:22 -0400 Subject: [PATCH 1/9] x --- .../deepagents/deepagents/backends/sandbox.py | 31 +++++++------------ 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/libs/deepagents/deepagents/backends/sandbox.py b/libs/deepagents/deepagents/backends/sandbox.py index 11ad3eb1c3..839f2ae27b 100644 --- a/libs/deepagents/deepagents/backends/sandbox.py +++ b/libs/deepagents/deepagents/backends/sandbox.py @@ -380,9 +380,6 @@ def write( ) -> WriteResult: """Create a new file, failing if it already exists. - Runs a small preflight command to check existence and create parent - directories, then transfers content via `upload_files()`. - Args: file_path: Absolute path for the new file. content: UTF-8 text content to write. @@ -391,31 +388,22 @@ def write( `WriteResult` with `path` on success or `error` on failure. """ # Existence check + mkdir. There is a TOCTOU window between this check - # and the upload below — a concurrent process could create the file in + # and the upload below - a concurrent process could create the file in # between. This is an inherent limitation of splitting the operation; - # the risk is minimal in single-agent sandbox environments. path_b64 = base64.b64encode(file_path.encode("utf-8")).decode("ascii") check_cmd = _WRITE_CHECK_TEMPLATE.format(path_b64=path_b64) - try: - result = self.execute(check_cmd) - except Exception as exc: # noqa: BLE001 # defense-in-depth for buggy subclass execute() - msg = f"Failed to write file '{file_path}': {exc}" - return WriteResult(error=msg) - + result = self.execute(check_cmd) if result.exit_code != 0 or "Error:" in result.output: error_msg = result.output.strip() or f"Failed to write file '{file_path}'" return WriteResult(error=error_msg) - # Transfer content via upload_files() - try: - responses = self.upload_files([(file_path, content.encode("utf-8"))]) - except Exception as exc: # noqa: BLE001 # defense-in-depth for buggy subclass upload_files() - msg = f"Failed to write file '{file_path}': {exc}" - return WriteResult(error=msg) + responses = self.upload_files([(file_path, content.encode("utf-8"))]) if not responses: - return WriteResult(error=f"Failed to write file '{file_path}': upload returned no response") - if responses[0].error: - return WriteResult(error=f"Failed to write file '{file_path}': {responses[0].error}") + # An unreachable condition was reached + raise AssertionError(f"Responses was expected to return 1 result, but it returned {len(responses)} with type {type(responses)}") + response = responses[0] + if response.error: + return WriteResult(error=f"Failed to write file '{file_path}': {response.error}") return WriteResult(path=file_path, files_update=None) @@ -676,6 +664,9 @@ def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadRespons Implementations must support partial success - catch exceptions per-file and return errors in `FileUploadResponse` objects rather than raising. + + Upload files is responsible for ensuring that the parent path exists + (if user permissions allow the user to write to the given directory) """ @abstractmethod From 96ddb1441ce67901dce2e303f695956fd865bfcf Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Mon, 30 Mar 2026 10:36:17 -0400 Subject: [PATCH 2/9] x --- libs/deepagents/deepagents/backends/sandbox.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/deepagents/deepagents/backends/sandbox.py b/libs/deepagents/deepagents/backends/sandbox.py index 839f2ae27b..bd60807510 100644 --- a/libs/deepagents/deepagents/backends/sandbox.py +++ b/libs/deepagents/deepagents/backends/sandbox.py @@ -400,7 +400,8 @@ def write( responses = self.upload_files([(file_path, content.encode("utf-8"))]) if not responses: # An unreachable condition was reached - raise AssertionError(f"Responses was expected to return 1 result, but it returned {len(responses)} with type {type(responses)}") + msg = f"Responses was expected to return 1 result, but it returned {len(responses)} with type {type(responses)}" + raise AssertionError(msg) response = responses[0] if response.error: return WriteResult(error=f"Failed to write file '{file_path}': {response.error}") From e0e8291cfe03bab6bd43ef6086501de9cd46fc41 Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Mon, 30 Mar 2026 15:09:05 -0400 Subject: [PATCH 3/9] x --- .../tests/unit_tests/backends/test_sandbox_backend.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/libs/deepagents/tests/unit_tests/backends/test_sandbox_backend.py b/libs/deepagents/tests/unit_tests/backends/test_sandbox_backend.py index 13ab1514de..5ae56d8105 100644 --- a/libs/deepagents/tests/unit_tests/backends/test_sandbox_backend.py +++ b/libs/deepagents/tests/unit_tests/backends/test_sandbox_backend.py @@ -686,17 +686,6 @@ def test_sandbox_write_returns_correct_result_on_success() -> None: assert result.files_update is None -def test_sandbox_write_returns_error_on_empty_upload_response() -> None: - """Test that write() handles upload_files returning an empty list.""" - sandbox = MockSandbox() - sandbox.upload_files = lambda _files: [] # type: ignore[assignment] - - result = sandbox.write("/test/file.txt", "content") - - assert result.error is not None - assert "no response" in result.error - - def test_sandbox_edit_upload_returns_error_on_empty_upload_response() -> None: """Test that upload-path edit handles upload_files returning empty list.""" sandbox = MockSandbox() From 3651d9af682fcbbdad18afa9586b1e9fc50d123c Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Mon, 30 Mar 2026 15:24:39 -0400 Subject: [PATCH 4/9] x --- libs/deepagents/deepagents/backends/sandbox.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/deepagents/deepagents/backends/sandbox.py b/libs/deepagents/deepagents/backends/sandbox.py index bd60807510..979a7740aa 100644 --- a/libs/deepagents/deepagents/backends/sandbox.py +++ b/libs/deepagents/deepagents/backends/sandbox.py @@ -116,7 +116,8 @@ print(json.dumps({{'count': count}})) " 2>&1 <<'__DEEPAGENTS_EDIT_EOF__' {payload_b64} -__DEEPAGENTS_EDIT_EOF__""" +__DEEPAGENTS_EDIT_EOF__ +""" """Server-side file edit via `execute()`. Reads the file, performs string replacement, and writes back — all on the From a949a3daf2d5576721742e74a78a5ea21ca58580 Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Mon, 30 Mar 2026 15:49:35 -0400 Subject: [PATCH 5/9] x --- libs/deepagents/deepagents/backends/sandbox.py | 3 +++ .../tests/unit_tests/backends/test_sandbox_backend.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/libs/deepagents/deepagents/backends/sandbox.py b/libs/deepagents/deepagents/backends/sandbox.py index 979a7740aa..e5ca6275b5 100644 --- a/libs/deepagents/deepagents/backends/sandbox.py +++ b/libs/deepagents/deepagents/backends/sandbox.py @@ -118,6 +118,9 @@ {payload_b64} __DEEPAGENTS_EDIT_EOF__ """ +# Make sure to maintain a new line at the end of DEEPAGENTS_EDIT_EOF to denote end of +# feed. This may not matter for some integrations. + """Server-side file edit via `execute()`. Reads the file, performs string replacement, and writes back — all on the diff --git a/libs/deepagents/tests/unit_tests/backends/test_sandbox_backend.py b/libs/deepagents/tests/unit_tests/backends/test_sandbox_backend.py index 5ae56d8105..a62973abfc 100644 --- a/libs/deepagents/tests/unit_tests/backends/test_sandbox_backend.py +++ b/libs/deepagents/tests/unit_tests/backends/test_sandbox_backend.py @@ -557,6 +557,11 @@ def test_edit_command_template_format() -> None: assert "__DEEPAGENTS_EDIT_EOF__" in cmd +def test_edit_command_template_ends_with_newline() -> None: + """Test that _EDIT_COMMAND_TEMPLATE preserves the trailing newline after EOF.""" + assert _EDIT_COMMAND_TEMPLATE.endswith("\n\"\"\"") + + def test_edit_tmpfile_template_format() -> None: """Test that _EDIT_TMPFILE_TEMPLATE can be formatted without KeyError.""" old_b64 = base64.b64encode(b"/tmp/old").decode("ascii") From 8204d219c64a7fe48a8b9e5a0464423ba085f4a7 Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Thu, 2 Apr 2026 15:52:00 -0400 Subject: [PATCH 6/9] x --- .../deepagents/backends/protocol.py | 73 ++++++++++++------- 1 file changed, 48 insertions(+), 25 deletions(-) diff --git a/libs/deepagents/deepagents/backends/protocol.py b/libs/deepagents/deepagents/backends/protocol.py index 389a286719..6024a397a4 100644 --- a/libs/deepagents/deepagents/backends/protocol.py +++ b/libs/deepagents/deepagents/backends/protocol.py @@ -11,7 +11,7 @@ import logging import warnings from collections.abc import Callable -from dataclasses import dataclass, field +from dataclasses import dataclass from functools import lru_cache from typing import Any, Literal, NotRequired, TypeAlias @@ -178,10 +178,25 @@ class _Unset: """Sentinel type for detecting explicit parameter usage.""" -_FILES_UPDATE_UNSET = _Unset() +Unset = _Unset() -@dataclass +def _normalize_files_update( + files_update: dict[str, Any] | None | _Unset, +) -> dict[str, Any] | None: + """Normalize file updates.""" + if isinstance(files_update, _Unset): + return None + + warnings.warn( + "`files_update` is deprecated and will be removed in v0.7. State updates are now handled internally by the backend.", + DeprecationWarning, + stacklevel=3, + ) + return files_update + + +@dataclass(init=False) class WriteResult: """Result from backend write operations. @@ -194,20 +209,23 @@ class WriteResult: >>> WriteResult(error="File exists") """ - error: str | None = None - path: str | None = None - files_update: dict[str, Any] | None | _Unset = field(default=_FILES_UPDATE_UNSET, repr=False) + error: str | None + path: str | None + files_update: dict[str, Any] | None - def __post_init__(self) -> None: # noqa: D105 - if not isinstance(self.files_update, _Unset): - warnings.warn( - "`files_update` is deprecated and will be removed in v0.7. State updates are now handled internally by the backend.", - DeprecationWarning, - stacklevel=2, - ) + def __init__( + self, + error: str | None = None, + path: str | None = None, + files_update: dict[str, Any] | None | _Unset = Unset, + ) -> None: + """Initialize WriteResult.""" + self.error = error + self.path = path + self.files_update = _normalize_files_update(files_update) -@dataclass +@dataclass(init=False) class EditResult: """Result from backend edit operations. @@ -221,18 +239,23 @@ class EditResult: >>> EditResult(error="File not found") """ - error: str | None = None - path: str | None = None - files_update: dict[str, Any] | None | _Unset = field(default=_FILES_UPDATE_UNSET, repr=False) - occurrences: int | None = None + error: str | None + path: str | None + files_update: dict[str, Any] | None + occurrences: int | None - def __post_init__(self) -> None: # noqa: D105 - if not isinstance(self.files_update, _Unset): - warnings.warn( - "`files_update` is deprecated and will be removed in v0.7. State updates are now handled internally by the backend.", - DeprecationWarning, - stacklevel=2, - ) + def __init__( + self, + error: str | None = None, + path: str | None = None, + files_update: dict[str, Any] | None | _Unset = Unset, + occurrences: int | None = None, + ) -> None: + """Initialize edit result.""" + self.error = error + self.path = path + self.files_update = _normalize_files_update(files_update) + self.occurrences = occurrences @dataclass From a2041b5aa82663dafd616fb0bfc55286fa23ab13 Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Thu, 2 Apr 2026 17:29:03 -0400 Subject: [PATCH 7/9] x --- .../deepagents/deepagents/backends/sandbox.py | 33 +++++++++++++++++-- .../backends/test_sandbox_backend.py | 24 ++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/libs/deepagents/deepagents/backends/sandbox.py b/libs/deepagents/deepagents/backends/sandbox.py index b1191e7fcc..b80bbd1957 100644 --- a/libs/deepagents/deepagents/backends/sandbox.py +++ b/libs/deepagents/deepagents/backends/sandbox.py @@ -206,6 +206,13 @@ _READ_COMMAND_TEMPLATE = """python3 -c " import os, sys, base64, json +MAX_OUTPUT_BYTES = 500 * 1024 +TRUNCATION_MSG = chr(10) + chr(10) + ( + '[Output was truncated due to size limits. ' + 'This paginated read result exceeded the sandbox stdout limit. ' + 'Continue reading with a larger offset or smaller limit to inspect the rest of the file.]' +) + path = base64.b64decode('{path_b64}').decode('utf-8') if not os.path.isfile(path): @@ -233,7 +240,25 @@ if offset >= len(lines): print(json.dumps({{'error': 'Line offset ' + str(offset) + ' exceeds file length (' + str(len(lines)) + ' lines)'}})) sys.exit(0) - text = chr(10).join(lines[offset:offset + limit]) + selected_lines = lines[offset:offset + limit] + truncated = False + parts = [] + current_bytes = 0 + for i, line in enumerate(selected_lines): + piece = line if i == 0 else chr(10) + line + piece_bytes = len(piece.encode('utf-8')) + if current_bytes + piece_bytes > MAX_OUTPUT_BYTES: + truncated = True + break + parts.append(piece) + current_bytes += piece_bytes + text = ''.join(parts) + if truncated: + msg_bytes = len(TRUNCATION_MSG.encode('utf-8')) + while parts and current_bytes + msg_bytes > MAX_OUTPUT_BYTES: + removed = parts.pop() + current_bytes -= len(removed.encode('utf-8')) + text = ''.join(parts) + TRUNCATION_MSG print(json.dumps({{'encoding': 'utf-8', 'content': text}})) " 2>&1""" @@ -330,7 +355,11 @@ def read( Runs a Python script on the sandbox via `execute()` that reads the file, detects encoding, and applies offset/limit pagination for text - files. Only the requested page is returned over the wire. + files. Only the requested page is returned over the wire, and text + output is capped to about 500 KiB to avoid backend stdout/log transport + failures. When that cap is exceeded, the returned content is truncated + with guidance to continue pagination using a different `offset` or + smaller `limit`. Binary files (non-UTF-8) are returned base64-encoded without pagination. diff --git a/libs/deepagents/tests/unit_tests/backends/test_sandbox_backend.py b/libs/deepagents/tests/unit_tests/backends/test_sandbox_backend.py index f0ff8bdebb..a6a73f929d 100644 --- a/libs/deepagents/tests/unit_tests/backends/test_sandbox_backend.py +++ b/libs/deepagents/tests/unit_tests/backends/test_sandbox_backend.py @@ -238,6 +238,30 @@ def test_read_handles_non_dict_json_output() -> None: assert "unexpected server response" in result.error +def test_read_allows_truncated_paginated_output() -> None: + """Test that read() accepts truncated paginated content returned by the server.""" + sandbox = MockSandbox() + truncated_content = ( + "line one\n\n" + "[Output was truncated due to size limits. Continue reading with a larger " + "offset or smaller limit to inspect the rest of the file.]" + ) + sandbox._next_output = json.dumps( + { + "encoding": "utf-8", + "content": truncated_content, + } + ) + + result = sandbox.read("/test/file.txt") + + assert result.error is None + assert result.file_data == { + "encoding": "utf-8", + "content": truncated_content, + } + + # -- write tests -------------------------------------------------------------- From ace710ec9834af563898008370d38ff8cc650744 Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Thu, 2 Apr 2026 17:53:54 -0400 Subject: [PATCH 8/9] x --- .../deepagents/deepagents/backends/sandbox.py | 77 +++++++++++++------ 1 file changed, 53 insertions(+), 24 deletions(-) diff --git a/libs/deepagents/deepagents/backends/sandbox.py b/libs/deepagents/deepagents/backends/sandbox.py index b80bbd1957..7e02d4ff73 100644 --- a/libs/deepagents/deepagents/backends/sandbox.py +++ b/libs/deepagents/deepagents/backends/sandbox.py @@ -207,6 +207,7 @@ import os, sys, base64, json MAX_OUTPUT_BYTES = 500 * 1024 +MAX_BINARY_BYTES = 500 * 1024 TRUNCATION_MSG = chr(10) + chr(10) + ( '[Output was truncated due to size limits. ' 'This paginated read result exceeded the sandbox stdout limit. ' @@ -223,42 +224,70 @@ print(json.dumps({{'encoding': 'utf-8', 'content': 'System reminder: File exists but has empty contents'}})) sys.exit(0) +file_type = '{file_type}' +if file_type != 'text': + file_size = os.path.getsize(path) + if file_size > MAX_BINARY_BYTES: + print(json.dumps({{'error': 'Binary file exceeds maximum preview size of ' + str(MAX_BINARY_BYTES) + ' bytes'}})) + sys.exit(0) + with open(path, 'rb') as f: + raw = f.read() + print(json.dumps({{'encoding': 'base64', 'content': base64.b64encode(raw).decode('ascii')}})) + sys.exit(0) + with open(path, 'rb') as f: - raw = f.read() + raw_prefix = f.read(8192) try: - text = raw.decode('utf-8') + raw_prefix.decode('utf-8') except UnicodeDecodeError: + with open(path, 'rb') as f: + raw = f.read() print(json.dumps({{'encoding': 'base64', 'content': base64.b64encode(raw).decode('ascii')}})) sys.exit(0) -file_type = '{file_type}' -if file_type == 'text': - lines = text.splitlines() - offset = {offset} - limit = {limit} - if offset >= len(lines): - print(json.dumps({{'error': 'Line offset ' + str(offset) + ' exceeds file length (' + str(len(lines)) + ' lines)'}})) - sys.exit(0) - selected_lines = lines[offset:offset + limit] - truncated = False - parts = [] - current_bytes = 0 - for i, line in enumerate(selected_lines): - piece = line if i == 0 else chr(10) + line +offset = {offset} +limit = {limit} +line_count = 0 +returned_lines = 0 +truncated = False +parts = [] +current_bytes = 0 +msg_bytes = len(TRUNCATION_MSG.encode('utf-8')) +effective_limit = MAX_OUTPUT_BYTES - msg_bytes + +with open(path, 'r', encoding='utf-8', newline=None) as f: + for raw_line in f: + line_count += 1 + if line_count <= offset: + continue + if returned_lines >= limit: + break + + line = raw_line.rstrip('\\n').rstrip('\\r') + piece = line if returned_lines == 0 else '\\n' + line piece_bytes = len(piece.encode('utf-8')) - if current_bytes + piece_bytes > MAX_OUTPUT_BYTES: + if current_bytes + piece_bytes > effective_limit: truncated = True + remaining_bytes = effective_limit - current_bytes + if remaining_bytes > 0: + prefix = piece.encode('utf-8')[:remaining_bytes].decode('utf-8', errors='ignore') + if prefix: + parts.append(prefix) + current_bytes += len(prefix.encode('utf-8')) break + parts.append(piece) current_bytes += piece_bytes - text = ''.join(parts) - if truncated: - msg_bytes = len(TRUNCATION_MSG.encode('utf-8')) - while parts and current_bytes + msg_bytes > MAX_OUTPUT_BYTES: - removed = parts.pop() - current_bytes -= len(removed.encode('utf-8')) - text = ''.join(parts) + TRUNCATION_MSG + returned_lines += 1 + +if returned_lines == 0 and not truncated: + print(json.dumps({{'error': 'Line offset ' + str(offset) + ' exceeds file length (' + str(line_count) + ' lines)'}})) + sys.exit(0) + +text = ''.join(parts) +if truncated: + text += TRUNCATION_MSG print(json.dumps({{'encoding': 'utf-8', 'content': text}})) " 2>&1""" From fd3c1fbd65b75b7faf20fdb566bf0b250ee2a8da Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Fri, 3 Apr 2026 10:51:25 -0400 Subject: [PATCH 9/9] x --- libs/deepagents/deepagents/backends/sandbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/deepagents/deepagents/backends/sandbox.py b/libs/deepagents/deepagents/backends/sandbox.py index 7e02d4ff73..077bfea9da 100644 --- a/libs/deepagents/deepagents/backends/sandbox.py +++ b/libs/deepagents/deepagents/backends/sandbox.py @@ -208,7 +208,7 @@ MAX_OUTPUT_BYTES = 500 * 1024 MAX_BINARY_BYTES = 500 * 1024 -TRUNCATION_MSG = chr(10) + chr(10) + ( +TRUNCATION_MSG = '\\n\\n' + ( '[Output was truncated due to size limits. ' 'This paginated read result exceeded the sandbox stdout limit. ' 'Continue reading with a larger offset or smaller limit to inspect the rest of the file.]'