diff --git a/libs/deepagents/deepagents/backends/sandbox.py b/libs/deepagents/deepagents/backends/sandbox.py index 1845617e7..077bfea9d 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 @@ -203,6 +206,14 @@ _READ_COMMAND_TEMPLATE = """python3 -c " import os, sys, base64, json +MAX_OUTPUT_BYTES = 500 * 1024 +MAX_BINARY_BYTES = 500 * 1024 +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.]' +) + path = base64.b64decode('{path_b64}').decode('utf-8') if not os.path.isfile(path): @@ -213,24 +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) - text = chr(10).join(lines[offset:offset + limit]) +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 > 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 + 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""" @@ -327,7 +384,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. @@ -384,9 +445,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. @@ -395,31 +453,23 @@ 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 + 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}") return WriteResult(path=file_path) @@ -678,6 +728,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 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 508fb86f7..a6a73f929 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 -------------------------------------------------------------- @@ -688,17 +712,7 @@ def test_sandbox_write_returns_correct_result_on_success() -> None: assert result.error is None assert result.path == "/test/file.txt" - - -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 + assert result.files_update is None def test_sandbox_edit_upload_returns_error_on_empty_upload_response() -> None: