From 638736f5118a20763d358845d0dcea64126ccff5 Mon Sep 17 00:00:00 2001 From: Yash Israni <118755067+yashisrani@users.noreply.github.com> Date: Sun, 19 Apr 2026 21:04:06 +0530 Subject: [PATCH 1/5] feat(sdk): implement LangChain Code Interpreter sandbox provider Signed-off-by: Yash Israni <118755067+yashisrani@users.noreply.github.com> --- .../clients/code_interpreter_data_plane.py | 9 +- sdk-python/agentcube/code_interpreter.py | 6 +- sdk-python/agentcube/integrations/__init__.py | 15 ++ .../agentcube/integrations/langchain.py | 192 ++++++++++++++++++ .../tests/test_langchain_integration.py | 97 +++++++++ 5 files changed, 313 insertions(+), 6 deletions(-) create mode 100644 sdk-python/agentcube/integrations/__init__.py create mode 100644 sdk-python/agentcube/integrations/langchain.py create mode 100644 sdk-python/tests/test_langchain_integration.py diff --git a/sdk-python/agentcube/clients/code_interpreter_data_plane.py b/sdk-python/agentcube/clients/code_interpreter_data_plane.py index a9350865..6de708fe 100644 --- a/sdk-python/agentcube/clients/code_interpreter_data_plane.py +++ b/sdk-python/agentcube/clients/code_interpreter_data_plane.py @@ -196,9 +196,12 @@ def run_code(self, language: str, code: str, timeout: Optional[float] = None) -> return self.execute_command(cmd, timeout) - def write_file(self, content: str, remote_path: str) -> None: - """Write text content to a file.""" - content_bytes = content.encode('utf-8') + def write_file(self, content: Union[str, bytes], remote_path: str) -> None: + """Write text or binary content to a file.""" + if isinstance(content, str): + content_bytes = content.encode('utf-8') + else: + content_bytes = content content_b64 = base64.b64encode(content_bytes).decode('utf-8') payload = { diff --git a/sdk-python/agentcube/code_interpreter.py b/sdk-python/agentcube/code_interpreter.py index d4f2fe5f..6852fbfc 100644 --- a/sdk-python/agentcube/code_interpreter.py +++ b/sdk-python/agentcube/code_interpreter.py @@ -14,7 +14,7 @@ import os import logging -from typing import Optional +from typing import Optional, Union from agentcube.clients.control_plane import ControlPlaneClient from agentcube.clients.code_interpreter_data_plane import CodeInterpreterDataPlaneClient @@ -194,12 +194,12 @@ def run_code(self, language: str, code: str, timeout: Optional[float] = None) -> """ return self.dp_client.run_code(language, code, timeout) - def write_file(self, content: str, remote_path: str): + def write_file(self, content: Union[str, bytes], remote_path: str): """ Write content to a file in the remote environment. Args: - content: The string content to write to the file. + content: The string or binary content to write to the file. remote_path: The destination path of the file in the remote environment. This path is relative to the session's working directory. """ diff --git a/sdk-python/agentcube/integrations/__init__.py b/sdk-python/agentcube/integrations/__init__.py new file mode 100644 index 00000000..429557c0 --- /dev/null +++ b/sdk-python/agentcube/integrations/__init__.py @@ -0,0 +1,15 @@ +# Copyright The Volcano Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""AgentCube SDK integrations.""" diff --git a/sdk-python/agentcube/integrations/langchain.py b/sdk-python/agentcube/integrations/langchain.py new file mode 100644 index 00000000..d8ed1f34 --- /dev/null +++ b/sdk-python/agentcube/integrations/langchain.py @@ -0,0 +1,192 @@ +# Copyright The Volcano Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""LangChain integration for AgentCube Code Interpreter.""" + +from __future__ import annotations + +import os +import tempfile +from typing import Optional + +from agentcube.code_interpreter import CodeInterpreterClient +from agentcube.exceptions import CommandExecutionError + +# Internal types for base class compliance +try: + from deepagents.backends.protocol import ( + ExecuteResponse, + FileDownloadResponse, + FileUploadResponse, + ) + from deepagents.backends.sandbox import BaseSandbox +except ImportError: + # Define fallback classes if deepagents is not installed + # This allows the module to be imported even if the optional integration + # dependencies are missing. + class BaseSandbox: + """Fallback BaseSandbox.""" + pass + + class ExecuteResponse: + """Fallback ExecuteResponse.""" + def __init__(self, output: str, exit_code: int, truncated: bool = False): + self.output = output + self.exit_code = exit_code + self.truncated = truncated + + class FileUploadResponse: + """Fallback FileUploadResponse.""" + def __init__(self, path: str, error: Optional[str] = None): + self.path = path + self.error = error + + class FileDownloadResponse: + """Fallback FileDownloadResponse.""" + def __init__(self, path: str, content: bytes, error: Optional[str] = None): + self.path = path + self.content = content + self.error = error + + +class AgentCubeSandbox(BaseSandbox): + """AgentCube implementation of the LangChain Sandbox integration. + + This class allows AgentCube to be used as a backend for autonomous agents + and code execution tools within the LangChain / DeepAgents ecosystem. + """ + + def __init__(self, client: CodeInterpreterClient) -> None: + """Initialize the sandbox with an AgentCube CodeInterpreterClient. + + Args: + client: An instance of AgentCube's CodeInterpreterClient. + """ + self._client = client + + @property + def id(self) -> str: + """Return the unique session ID of the sandbox instance.""" + return self._client.session_id or "unknown" + + def execute( + self, + command: str, + *, + timeout: int | None = None, + ) -> ExecuteResponse: + """Execute a shell command in the AgentCube sandbox. + + Args: + command: The command to execute. + timeout: Optional execution timeout in seconds. + + Returns: + An ExecuteResponse containing stdout, exit_code and truncated status. + """ + try: + # Map AgentCube output to ExecuteResponse + stdout = self._client.execute_command(command, timeout=timeout) + return ExecuteResponse( + output=stdout, + exit_code=0, + truncated=False, + ) + except CommandExecutionError as e: + # Map AgentCube execution error + return ExecuteResponse( + output=e.stderr or "", + exit_code=e.exit_code, + truncated=False, + ) + except Exception as e: + # Map unexpected errors + return ExecuteResponse( + output=str(e), + exit_code=1, + truncated=False, + ) + + def upload_files( + self, + files: list[tuple[str, bytes]], + ) -> list[FileUploadResponse]: + """Upload multiple files to the AgentCube sandbox. + + Args: + files: A list of (remote_path, content_bytes) tuples. + + Returns: + A list of FileUploadResponse objects in the same order as input. + """ + results = [] + for path, content in files: + try: + # Use write_file which now supports bytes + self._client.write_file(content, path) + results.append(FileUploadResponse(path=path, error=None)) + except Exception as e: + results.append(FileUploadResponse(path=path, error=str(e))) + return results + + def download_files(self, paths: list[str]) -> list[FileDownloadResponse]: + """Download multiple files from the AgentCube sandbox. + + Args: + paths: A list of remote file paths to download. + + Returns: + A list of FileDownloadResponse objects containing file contents. + """ + results = [] + for path in paths: + try: + # AgentCube download_file writes to a local path + # We use a temp file to read it into memory for the response + with tempfile.NamedTemporaryFile(delete=False) as tmp: + tmp_path = tmp.name + + try: + self._client.download_file(path, tmp_path) + with open(tmp_path, "rb") as f: + content = f.read() + results.append(FileDownloadResponse(path=path, content=content, error=None)) + finally: + if os.path.exists(tmp_path): + os.remove(tmp_path) + except Exception as e: + results.append(FileDownloadResponse(path=path, content=b"", error=str(e))) + return results + + # --- Async Support --- + + async def aexecute( + self, + command: str, + *, + timeout: int | None = None, + ) -> ExecuteResponse: + """Async version of execute. Currently wraps synchronous call.""" + return self.execute(command, timeout=timeout) + + async def aupload_files( + self, + files: list[tuple[str, bytes]], + ) -> list[FileUploadResponse]: + """Async version of upload_files. Currently wraps synchronous call.""" + return self.upload_files(files) + + async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]: + """Async version of download_files. Currently wraps synchronous call.""" + return self.download_files(paths) diff --git a/sdk-python/tests/test_langchain_integration.py b/sdk-python/tests/test_langchain_integration.py new file mode 100644 index 00000000..e58d949e --- /dev/null +++ b/sdk-python/tests/test_langchain_integration.py @@ -0,0 +1,97 @@ +# Copyright The Volcano Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from unittest.mock import MagicMock, patch + +from agentcube.integrations.langchain import AgentCubeSandbox +from agentcube.exceptions import CommandExecutionError + +class TestAgentCubeSandbox(unittest.TestCase): + """Test the LangChain Sandbox integration.""" + + def setUp(self): + self.mock_client = MagicMock() + self.mock_client.session_id = "test-session-123" + self.sandbox = AgentCubeSandbox(self.mock_client) + + def test_id_property(self): + """Test the id property returns session_id.""" + self.assertEqual(self.sandbox.id, "test-session-123") + + def test_execute_success(self): + """Test execute command success.""" + self.mock_client.execute_command.return_value = "hello world" + + response = self.sandbox.execute("echo hello world") + + self.assertEqual(response.output, "hello world") + self.assertEqual(response.exit_code, 0) + self.assertFalse(response.truncated) + self.mock_client.execute_command.assert_called_once_with("echo hello world", timeout=None) + + def test_execute_failure(self): + """Test execute command failure (CommandExecutionError).""" + self.mock_client.execute_command.side_effect = CommandExecutionError( + exit_code=127, stderr="command not found", command="invalid" + ) + + response = self.sandbox.execute("invalid") + + self.assertEqual(response.output, "command not found") + self.assertEqual(response.exit_code, 127) + self.mock_client.execute_command.assert_called_once() + + def test_upload_files(self): + """Test uploading multiple files.""" + files = [ + ("test1.txt", b"hello"), + ("test2.txt", b"world") + ] + + responses = self.sandbox.upload_files(files) + + self.assertEqual(len(responses), 2) + self.assertEqual(responses[0].path, "test1.txt") + self.assertIsNone(responses[0].error) + self.assertEqual(responses[1].path, "test2.txt") + self.assertIsNone(responses[1].error) + + # Verify client calls + self.assertEqual(self.mock_client.write_file.call_count, 2) + self.mock_client.write_file.assert_any_call(b"hello", "test1.txt") + self.mock_client.write_file.assert_any_call(b"world", "test2.txt") + + @patch("os.remove") + @patch("builtins.open", new_callable=MagicMock) + def test_download_files(self, mock_open, mock_remove): + """Test downloading files.""" + # Mock file content + file_content = b"file content" + mock_open.return_value.__enter__.return_value.read.return_value = file_content + + paths = ["remote1.txt"] + + responses = self.sandbox.download_files(paths) + + self.assertEqual(len(responses), 1) + self.assertEqual(responses[0].path, "remote1.txt") + self.assertEqual(responses[0].content, file_content) + self.assertIsNone(responses[0].error) + + self.mock_client.download_file.assert_called_once() + self.assertTrue(mock_open.called) + +if __name__ == "__main__": + unittest.main() From a75dc3274c16d2552d60cb70100806458d305e1b Mon Sep 17 00:00:00 2001 From: Yash Israni <118755067+yashisrani@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:48:12 +0530 Subject: [PATCH 2/5] refactor: address maintainer feedback on LangChain integration Signed-off-by: Yash Israni <118755067+yashisrani@users.noreply.github.com> --- .../clients/code_interpreter_data_plane.py | 21 +- sdk-python/agentcube/exceptions.py | 10 +- .../agentcube/integrations/langchain.py | 96 +++++---- .../integrations/langchain/README.md | 28 +++ .../integrations/langchain/__init__.py | 17 ++ .../integrations/langchain/sandbox.py | 203 ++++++++++++++++++ sdk-python/examples/test_langchain_sandbox.py | 92 ++++++++ sdk-python/pyproject.toml | 3 + .../tests/test_langchain_integration.py | 15 +- sdk-python/tests/test_langchain_standard.py | 52 +++++ 10 files changed, 480 insertions(+), 57 deletions(-) create mode 100644 sdk-python/agentcube/integrations/langchain/README.md create mode 100644 sdk-python/agentcube/integrations/langchain/__init__.py create mode 100644 sdk-python/agentcube/integrations/langchain/sandbox.py create mode 100644 sdk-python/examples/test_langchain_sandbox.py create mode 100644 sdk-python/tests/test_langchain_standard.py diff --git a/sdk-python/agentcube/clients/code_interpreter_data_plane.py b/sdk-python/agentcube/clients/code_interpreter_data_plane.py index 6de708fe..2f892bf6 100644 --- a/sdk-python/agentcube/clients/code_interpreter_data_plane.py +++ b/sdk-python/agentcube/clients/code_interpreter_data_plane.py @@ -150,14 +150,22 @@ def execute_command(self, command: Union[str, List[str]], timeout: Optional[floa resp.raise_for_status() result = resp.json() + stdout = result.get("stdout", "") + stderr = result.get("stderr", "") + if result["exit_code"] != 0: raise CommandExecutionError( exit_code=result["exit_code"], - stderr=result["stderr"], + stdout=stdout, + stderr=stderr, command=command ) - return result["stdout"] + # Combine stdout and stderr for the caller if stderr is present + output = stdout + if stderr: + output = f"{stdout}\n{stderr}".strip() if stdout else stderr + return output def run_code(self, language: str, code: str, timeout: Optional[float] = None) -> str: """Run a code snippet (python or bash).""" @@ -196,12 +204,9 @@ def run_code(self, language: str, code: str, timeout: Optional[float] = None) -> return self.execute_command(cmd, timeout) - def write_file(self, content: Union[str, bytes], remote_path: str) -> None: - """Write text or binary content to a file.""" - if isinstance(content, str): - content_bytes = content.encode('utf-8') - else: - content_bytes = content + def write_file(self, content: str, remote_path: str) -> None: + """Write text content to a file.""" + content_bytes = content.encode('utf-8') content_b64 = base64.b64encode(content_bytes).decode('utf-8') payload = { diff --git a/sdk-python/agentcube/exceptions.py b/sdk-python/agentcube/exceptions.py index 0240584f..1c87440f 100644 --- a/sdk-python/agentcube/exceptions.py +++ b/sdk-python/agentcube/exceptions.py @@ -18,11 +18,17 @@ class AgentCubeError(Exception): class CommandExecutionError(AgentCubeError): """Raised when a command execution fails (exit code != 0)""" - def __init__(self, exit_code, stderr, command=None): + def __init__(self, exit_code: int, stdout: str, stderr: str, command: str = None): self.exit_code = exit_code + self.stdout = stdout self.stderr = stderr self.command = command - super().__init__(f"Command failed (exit {exit_code}): {stderr}") + + # Combine for the error message + output = stdout + if stderr: + output = f"{stdout}\n{stderr}".strip() if stdout else stderr + super().__init__(f"Command failed (exit {exit_code}): {output}") class SessionError(AgentCubeError): """Raised when session creation or management fails""" diff --git a/sdk-python/agentcube/integrations/langchain.py b/sdk-python/agentcube/integrations/langchain.py index d8ed1f34..bc07fbb7 100644 --- a/sdk-python/agentcube/integrations/langchain.py +++ b/sdk-python/agentcube/integrations/langchain.py @@ -12,12 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""LangChain integration for AgentCube Code Interpreter.""" +"""AgentCube Code Interpreter sandbox integration for LangChain.""" from __future__ import annotations import os import tempfile +import asyncio from typing import Optional from agentcube.code_interpreter import CodeInterpreterClient @@ -31,33 +32,41 @@ FileUploadResponse, ) from deepagents.backends.sandbox import BaseSandbox -except ImportError: - # Define fallback classes if deepagents is not installed - # This allows the module to be imported even if the optional integration - # dependencies are missing. - class BaseSandbox: - """Fallback BaseSandbox.""" - pass - - class ExecuteResponse: - """Fallback ExecuteResponse.""" - def __init__(self, output: str, exit_code: int, truncated: bool = False): - self.output = output - self.exit_code = exit_code - self.truncated = truncated - - class FileUploadResponse: - """Fallback FileUploadResponse.""" - def __init__(self, path: str, error: Optional[str] = None): - self.path = path - self.error = error - - class FileDownloadResponse: - """Fallback FileDownloadResponse.""" - def __init__(self, path: str, content: bytes, error: Optional[str] = None): - self.path = path - self.content = content - self.error = error +except ModuleNotFoundError as e: + # Catching only if deepagents itself is missing. + # If deepagents is installed but has internal import errors, we should let them bubble up. + if e.name and (e.name.startswith("deepagents") or "deepagents" in e.name): + # Define fallback classes if deepagents is not installed + # This allows the module to be imported even if the optional integration + # dependencies are missing. + class BaseSandbox: # type: ignore + """Fallback BaseSandbox.""" + pass + + class ExecuteResponse: # type: ignore + """Fallback ExecuteResponse.""" + def __init__(self, output: str, exit_code: int, truncated: bool = False): + self.output = output + self.exit_code = exit_code + self.truncated = truncated + + class FileUploadResponse: # type: ignore + """Fallback FileUploadResponse.""" + def __init__(self, path: str, error: Optional[str] = None): + self.path = path + self.error = error + + class FileDownloadResponse: # type: ignore + """Fallback FileDownloadResponse.""" + def __init__(self, path: str, content: bytes, error: Optional[str] = None): + self.path = path + self.content = content + self.error = error + else: + raise +except ImportError as e: + # Re-raise with more context if it's an import error within deepagents + raise ImportError(f"Failed to import deepagents: {e}") from e class AgentCubeSandbox(BaseSandbox): @@ -97,26 +106,25 @@ def execute( """ try: # Map AgentCube output to ExecuteResponse - stdout = self._client.execute_command(command, timeout=timeout) + # execute_command now returns combined stdout and stderr + output = self._client.execute_command(command, timeout=timeout) return ExecuteResponse( - output=stdout, + output=output, exit_code=0, truncated=False, ) except CommandExecutionError as e: # Map AgentCube execution error + # Combine stdout and stderr for the agent + output = e.stdout + if e.stderr: + output = f"{output}\n{e.stderr}".strip() if output else e.stderr + return ExecuteResponse( - output=e.stderr or "", + output=output, exit_code=e.exit_code, truncated=False, ) - except Exception as e: - # Map unexpected errors - return ExecuteResponse( - output=str(e), - exit_code=1, - truncated=False, - ) def upload_files( self, @@ -177,16 +185,16 @@ async def aexecute( *, timeout: int | None = None, ) -> ExecuteResponse: - """Async version of execute. Currently wraps synchronous call.""" - return self.execute(command, timeout=timeout) + """Async version of execute. Offloaded to thread pool.""" + return await asyncio.to_thread(self.execute, command, timeout=timeout) async def aupload_files( self, files: list[tuple[str, bytes]], ) -> list[FileUploadResponse]: - """Async version of upload_files. Currently wraps synchronous call.""" - return self.upload_files(files) + """Async version of upload_files. Offloaded to thread pool.""" + return await asyncio.to_thread(self.upload_files, files) async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]: - """Async version of download_files. Currently wraps synchronous call.""" - return self.download_files(paths) + """Async version of download_files. Offloaded to thread pool.""" + return await asyncio.to_thread(self.download_files, paths) diff --git a/sdk-python/agentcube/integrations/langchain/README.md b/sdk-python/agentcube/integrations/langchain/README.md new file mode 100644 index 00000000..37c8e0d3 --- /dev/null +++ b/sdk-python/agentcube/integrations/langchain/README.md @@ -0,0 +1,28 @@ +# AgentCube LangChain Integration + +This directory contains the AgentCube sandbox provider for LangChain and the DeepAgents ecosystem. + +## Features + +- **AgentCubeSandbox**: A sandbox provider for executing code in AgentCube sessions. +- **Async Support**: Fully non-blocking async methods using `asyncio.to_thread`. +- **Combined Output**: Merges stdout and stderr for better agent reasoning. + +## Installation + +```bash +pip install agentcube-sdk[langchain] +``` + +## Usage + +```python +from agentcube import CodeInterpreterClient +from agentcube.integrations.langchain import AgentCubeSandbox + +client = CodeInterpreterClient() +sandbox = AgentCubeSandbox(client) + +response = sandbox.execute("print('hello world')") +print(response.output) +``` diff --git a/sdk-python/agentcube/integrations/langchain/__init__.py b/sdk-python/agentcube/integrations/langchain/__init__.py new file mode 100644 index 00000000..85edd1e3 --- /dev/null +++ b/sdk-python/agentcube/integrations/langchain/__init__.py @@ -0,0 +1,17 @@ +# Copyright The Volcano Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from .sandbox import AgentCubeSandbox + +__all__ = ["AgentCubeSandbox"] diff --git a/sdk-python/agentcube/integrations/langchain/sandbox.py b/sdk-python/agentcube/integrations/langchain/sandbox.py new file mode 100644 index 00000000..b00e48c8 --- /dev/null +++ b/sdk-python/agentcube/integrations/langchain/sandbox.py @@ -0,0 +1,203 @@ +# Copyright The Volcano Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""LangChain integration for AgentCube Code Interpreter.""" + +from __future__ import annotations + +import os +import tempfile +import asyncio +from typing import Optional + +from agentcube.code_interpreter import CodeInterpreterClient +from agentcube.exceptions import CommandExecutionError + +# Internal types for base class compliance +try: + from deepagents.backends.protocol import ( + ExecuteResponse, + FileDownloadResponse, + FileUploadResponse, + ) + from deepagents.backends.sandbox import BaseSandbox +except ModuleNotFoundError as e: + # Catching only if deepagents itself is missing. + # If deepagents is installed but has internal import errors, we should let them bubble up. + if e.name and (e.name.startswith("deepagents") or "deepagents" in e.name): + # Define fallback classes if deepagents is not installed + # This allows the module to be imported even if the optional integration + # dependencies are missing. + class BaseSandbox: # type: ignore + """Fallback BaseSandbox.""" + pass + + class ExecuteResponse: # type: ignore + """Fallback ExecuteResponse.""" + def __init__(self, output: str, exit_code: int, truncated: bool = False): + self.output = output + self.exit_code = exit_code + self.truncated = truncated + + class FileUploadResponse: # type: ignore + """Fallback FileUploadResponse.""" + def __init__(self, path: str, error: Optional[str] = None): + self.path = path + self.error = error + + class FileDownloadResponse: # type: ignore + """Fallback FileDownloadResponse.""" + def __init__(self, path: str, content: bytes, error: Optional[str] = None): + self.path = path + self.content = content + self.error = error + else: + raise +except ImportError as e: + # Re-raise with more context if it's an import error within deepagents + raise ImportError(f"Failed to import deepagents: {e}") from e + + +class AgentCubeSandbox(BaseSandbox): + """AgentCube implementation of the LangChain Sandbox integration. + + This class allows AgentCube to be used as a backend for autonomous agents + and code execution tools within the LangChain / DeepAgents ecosystem. + """ + + def __init__(self, client: CodeInterpreterClient) -> None: + """Initialize the sandbox with an AgentCube CodeInterpreterClient. + + Args: + client: An instance of AgentCube's CodeInterpreterClient. + """ + self._client = client + + @property + def id(self) -> str: + """Return the unique session ID of the sandbox instance.""" + return self._client.session_id or "unknown" + + def execute( + self, + command: str, + *, + timeout: int | None = None, + ) -> ExecuteResponse: + """Execute a shell command in the AgentCube sandbox. + + Args: + command: The command to execute. + timeout: Optional execution timeout in seconds. + + Returns: + An ExecuteResponse containing stdout, exit_code and truncated status. + """ + try: + # Map AgentCube output to ExecuteResponse + # execute_command now returns combined stdout and stderr + output = self._client.execute_command(command, timeout=timeout) + return ExecuteResponse( + output=output, + exit_code=0, + truncated=False, + ) + except CommandExecutionError as e: + # Map AgentCube execution error + # Combine stdout and stderr for the agent + output = e.stdout + if e.stderr: + output = f"{output}\n{e.stderr}".strip() if output else e.stderr + + return ExecuteResponse( + output=output, + exit_code=e.exit_code, + truncated=False, + ) + + def upload_files( + self, + files: list[tuple[str, bytes]], + ) -> list[FileUploadResponse]: + """Upload multiple files to the AgentCube sandbox. + + Args: + files: A list of (remote_path, content_bytes) tuples. + + Returns: + A list of FileUploadResponse objects in the same order as input. + """ + results = [] + for path, content in files: + try: + # If bytes, try to decode to string as write_file currently only supports str + # SDK support for raw bytes will be added in a separate PR. + if isinstance(content, bytes): + content = content.decode("utf-8") + self._client.write_file(content, path) + results.append(FileUploadResponse(path=path, error=None)) + except Exception as e: + results.append(FileUploadResponse(path=path, error=str(e))) + return results + + def download_files(self, paths: list[str]) -> list[FileDownloadResponse]: + """Download multiple files from the AgentCube sandbox. + + Args: + paths: A list of remote file paths to download. + + Returns: + A list of FileDownloadResponse objects containing file contents. + """ + results = [] + for path in paths: + try: + # AgentCube download_file writes to a local path + # We use a temp file to read it into memory for the response + with tempfile.NamedTemporaryFile(delete=False) as tmp: + tmp_path = tmp.name + + try: + self._client.download_file(path, tmp_path) + with open(tmp_path, "rb") as f: + content = f.read() + results.append(FileDownloadResponse(path=path, content=content, error=None)) + finally: + if os.path.exists(tmp_path): + os.remove(tmp_path) + except Exception as e: + results.append(FileDownloadResponse(path=path, content=b"", error=str(e))) + return results + + # --- Async Support --- + + async def aexecute( + self, + command: str, + *, + timeout: int | None = None, + ) -> ExecuteResponse: + """Async version of execute. Offloaded to thread pool.""" + return await asyncio.to_thread(self.execute, command, timeout=timeout) + + async def aupload_files( + self, + files: list[tuple[str, bytes]], + ) -> list[FileUploadResponse]: + """Async version of upload_files. Offloaded to thread pool.""" + return await asyncio.to_thread(self.upload_files, files) + + async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]: + """Async version of download_files. Offloaded to thread pool.""" + return await asyncio.to_thread(self.download_files, paths) diff --git a/sdk-python/examples/test_langchain_sandbox.py b/sdk-python/examples/test_langchain_sandbox.py new file mode 100644 index 00000000..b8ae9615 --- /dev/null +++ b/sdk-python/examples/test_langchain_sandbox.py @@ -0,0 +1,92 @@ +# Copyright The Volcano Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import asyncio +from agentcube import CodeInterpreterClient +from agentcube.integrations.langchain import AgentCubeSandbox + +async def test_sandbox_provider(): + """ + Test script to verify AgentCube as a LangChain-compatible sandbox provider. + This demonstrates the BaseSandbox interface compliance. + """ + print("๐Ÿ› ๏ธ Initializing AgentCube LangChain Sandbox Provider...") + + # 1. Setup the client + # Ensure ROUTER_URL is set in your environment + try: + client = CodeInterpreterClient(name="test-sandbox", verbose=False) + except Exception as e: + print(f"โŒ Failed to initialize client: {e}") + return + + # 2. Initialize the Sandbox Provider + # This object implements the LangChain BaseSandbox interface + sandbox = AgentCubeSandbox(client) + print(f"โœ… Sandbox initialized with ID: {sandbox.id}") + + try: + # 3. Test isolated execution (BaseSandbox.execute) + print("\n๐Ÿ“ Testing code execution...") + cmd = "python3 -c \"print('Hello from AgentCube Sandbox!'); import os; print(f'Working dir: {os.getcwd()}')\"" + response = await sandbox.aexecute(cmd) + + print(f"--- Output ---\n{response.output}") + print(f"Exit Code: {response.exit_code}") + + if response.exit_code == 0: + print("โœ… Execution successful.") + + # 4. Test file management (BaseSandbox.upload_files) + print("\n๐Ÿ“‚ Testing file upload...") + # Note: Using text content as SDK only supports str for now (per maintainer request) + files_to_upload = [ + ("greeting.txt", b"Hello LangChain!"), + ("config.json", b'{"status": "isolated"}') + ] + upload_results = await sandbox.aupload_files(files_to_upload) + for res in upload_results: + if not res.error: + print(f"โœ… Uploaded: {res.path}") + else: + print(f"โŒ Upload failed for {res.path}: {res.error}") + + # 5. Verify files exist and download them (BaseSandbox.download_files) + print("\n๐Ÿ” Verifying and downloading files...") + # Check files via execution + ls_res = await sandbox.aexecute("ls -lh greeting.txt config.json") + print(ls_res.output) + + # Download back + download_results = await sandbox.adownload_files(["greeting.txt"]) + for res in download_results: + if not res.error: + print(f"โœ… Downloaded {res.path}: '{res.content.decode()}'") + else: + print(f"โŒ Download failed for {res.path}: {res.error}") + + finally: + # 6. Cleanup the session + print("\n๐Ÿงน Cleaning up session...") + client.stop() + print("โœจ Done.") + +if __name__ == "__main__": + # Check for ROUTER_URL before running + if not os.getenv("ROUTER_URL"): + print("โš ๏ธ Warning: ROUTER_URL environment variable is not set.") + print("Please set it before running (e.g., export ROUTER_URL=http://localhost:8080)") + + asyncio.run(test_sandbox_provider()) diff --git a/sdk-python/pyproject.toml b/sdk-python/pyproject.toml index ddd14737..99688b5b 100644 --- a/sdk-python/pyproject.toml +++ b/sdk-python/pyproject.toml @@ -16,6 +16,9 @@ dependencies = [ "cryptography" ] +[project.optional-dependencies] +langchain = ["deepagents", "langchain-core", "langchain-tests"] + [tool.setuptools.packages.find] where = ["."] include = ["agentcube*"] diff --git a/sdk-python/tests/test_langchain_integration.py b/sdk-python/tests/test_langchain_integration.py index e58d949e..72f01022 100644 --- a/sdk-python/tests/test_langchain_integration.py +++ b/sdk-python/tests/test_langchain_integration.py @@ -44,12 +44,13 @@ def test_execute_success(self): def test_execute_failure(self): """Test execute command failure (CommandExecutionError).""" self.mock_client.execute_command.side_effect = CommandExecutionError( - exit_code=127, stderr="command not found", command="invalid" + exit_code=127, stdout="some output", stderr="command not found", command="invalid" ) response = self.sandbox.execute("invalid") - self.assertEqual(response.output, "command not found") + # Expect combined output + self.assertEqual(response.output, "some output\ncommand not found") self.assertEqual(response.exit_code, 127) self.mock_client.execute_command.assert_called_once() @@ -73,10 +74,15 @@ def test_upload_files(self): self.mock_client.write_file.assert_any_call(b"hello", "test1.txt") self.mock_client.write_file.assert_any_call(b"world", "test2.txt") + @patch("os.path.exists", return_value=True) @patch("os.remove") @patch("builtins.open", new_callable=MagicMock) - def test_download_files(self, mock_open, mock_remove): + @patch("tempfile.NamedTemporaryFile") + def test_download_files(self, mock_tmpfile, mock_open, mock_remove, mock_exists): """Test downloading files.""" + # Setup mock temp file + mock_tmpfile.return_value.__enter__.return_value.name = "/tmp/fake_path" + # Mock file content file_content = b"file content" mock_open.return_value.__enter__.return_value.read.return_value = file_content @@ -92,6 +98,9 @@ def test_download_files(self, mock_open, mock_remove): self.mock_client.download_file.assert_called_once() self.assertTrue(mock_open.called) + # Verify cleanup + mock_exists.assert_called_with("/tmp/fake_path") + mock_remove.assert_called_with("/tmp/fake_path") if __name__ == "__main__": unittest.main() diff --git a/sdk-python/tests/test_langchain_standard.py b/sdk-python/tests/test_langchain_standard.py new file mode 100644 index 00000000..27bc3ebf --- /dev/null +++ b/sdk-python/tests/test_langchain_standard.py @@ -0,0 +1,52 @@ +# Copyright The Volcano Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Iterator +import pytest +from unittest.mock import MagicMock + +from agentcube.integrations.langchain import AgentCubeSandbox +from agentcube.code_interpreter import CodeInterpreterClient +from langchain_tests.integration_tests import SandboxIntegrationTests + +class TestAgentCubeSandboxStandard(SandboxIntegrationTests): + """Standard LangChain integration tests for AgentCubeSandbox.""" + + @pytest.fixture(scope="class") + def sandbox(self) -> Iterator[AgentCubeSandbox]: + """Provide a configured AgentCubeSandbox for testing. + + Note: This currently uses a mocked backend to allow CI execution. + To test against a real backend, provide the necessary environment variables + (ROUTER_URL, etc.) and remove the mocking logic. + """ + # For standard integration tests, we provide a mocked client + # that simulates the behavior required by the test suite. + mock_client = MagicMock(spec=CodeInterpreterClient) + mock_client.session_id = "test-session-id" + + # Simulate successful command execution + mock_client.execute_command.return_value = "standard output" + + # Simulate file operations + mock_client.list_files.return_value = [] + + # Return the sandbox with the mocked client + backend = AgentCubeSandbox(client=mock_client) + + try: + yield backend + finally: + # Cleanup + mock_client.stop() From 53eaa39a4de8ee0dbe0595d930ff59ebaa65249a Mon Sep 17 00:00:00 2001 From: Yash Israni <118755067+yashisrani@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:58:01 +0530 Subject: [PATCH 3/5] fix: python test & lint Signed-off-by: Yash Israni <118755067+yashisrani@users.noreply.github.com> --- sdk-python/agentcube/exceptions.py | 2 +- .../agentcube/integrations/langchain.py | 200 ------------------ .../integrations/langchain/sandbox.py | 4 +- sdk-python/examples/test_langchain_sandbox.py | 10 +- .../tests/test_langchain_integration.py | 2 +- sdk-python/tests/test_langchain_standard.py | 21 +- 6 files changed, 24 insertions(+), 215 deletions(-) delete mode 100644 sdk-python/agentcube/integrations/langchain.py diff --git a/sdk-python/agentcube/exceptions.py b/sdk-python/agentcube/exceptions.py index 1c87440f..8de34bfe 100644 --- a/sdk-python/agentcube/exceptions.py +++ b/sdk-python/agentcube/exceptions.py @@ -23,7 +23,7 @@ def __init__(self, exit_code: int, stdout: str, stderr: str, command: str = None self.stdout = stdout self.stderr = stderr self.command = command - + # Combine for the error message output = stdout if stderr: diff --git a/sdk-python/agentcube/integrations/langchain.py b/sdk-python/agentcube/integrations/langchain.py deleted file mode 100644 index bc07fbb7..00000000 --- a/sdk-python/agentcube/integrations/langchain.py +++ /dev/null @@ -1,200 +0,0 @@ -# Copyright The Volcano Authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""AgentCube Code Interpreter sandbox integration for LangChain.""" - -from __future__ import annotations - -import os -import tempfile -import asyncio -from typing import Optional - -from agentcube.code_interpreter import CodeInterpreterClient -from agentcube.exceptions import CommandExecutionError - -# Internal types for base class compliance -try: - from deepagents.backends.protocol import ( - ExecuteResponse, - FileDownloadResponse, - FileUploadResponse, - ) - from deepagents.backends.sandbox import BaseSandbox -except ModuleNotFoundError as e: - # Catching only if deepagents itself is missing. - # If deepagents is installed but has internal import errors, we should let them bubble up. - if e.name and (e.name.startswith("deepagents") or "deepagents" in e.name): - # Define fallback classes if deepagents is not installed - # This allows the module to be imported even if the optional integration - # dependencies are missing. - class BaseSandbox: # type: ignore - """Fallback BaseSandbox.""" - pass - - class ExecuteResponse: # type: ignore - """Fallback ExecuteResponse.""" - def __init__(self, output: str, exit_code: int, truncated: bool = False): - self.output = output - self.exit_code = exit_code - self.truncated = truncated - - class FileUploadResponse: # type: ignore - """Fallback FileUploadResponse.""" - def __init__(self, path: str, error: Optional[str] = None): - self.path = path - self.error = error - - class FileDownloadResponse: # type: ignore - """Fallback FileDownloadResponse.""" - def __init__(self, path: str, content: bytes, error: Optional[str] = None): - self.path = path - self.content = content - self.error = error - else: - raise -except ImportError as e: - # Re-raise with more context if it's an import error within deepagents - raise ImportError(f"Failed to import deepagents: {e}") from e - - -class AgentCubeSandbox(BaseSandbox): - """AgentCube implementation of the LangChain Sandbox integration. - - This class allows AgentCube to be used as a backend for autonomous agents - and code execution tools within the LangChain / DeepAgents ecosystem. - """ - - def __init__(self, client: CodeInterpreterClient) -> None: - """Initialize the sandbox with an AgentCube CodeInterpreterClient. - - Args: - client: An instance of AgentCube's CodeInterpreterClient. - """ - self._client = client - - @property - def id(self) -> str: - """Return the unique session ID of the sandbox instance.""" - return self._client.session_id or "unknown" - - def execute( - self, - command: str, - *, - timeout: int | None = None, - ) -> ExecuteResponse: - """Execute a shell command in the AgentCube sandbox. - - Args: - command: The command to execute. - timeout: Optional execution timeout in seconds. - - Returns: - An ExecuteResponse containing stdout, exit_code and truncated status. - """ - try: - # Map AgentCube output to ExecuteResponse - # execute_command now returns combined stdout and stderr - output = self._client.execute_command(command, timeout=timeout) - return ExecuteResponse( - output=output, - exit_code=0, - truncated=False, - ) - except CommandExecutionError as e: - # Map AgentCube execution error - # Combine stdout and stderr for the agent - output = e.stdout - if e.stderr: - output = f"{output}\n{e.stderr}".strip() if output else e.stderr - - return ExecuteResponse( - output=output, - exit_code=e.exit_code, - truncated=False, - ) - - def upload_files( - self, - files: list[tuple[str, bytes]], - ) -> list[FileUploadResponse]: - """Upload multiple files to the AgentCube sandbox. - - Args: - files: A list of (remote_path, content_bytes) tuples. - - Returns: - A list of FileUploadResponse objects in the same order as input. - """ - results = [] - for path, content in files: - try: - # Use write_file which now supports bytes - self._client.write_file(content, path) - results.append(FileUploadResponse(path=path, error=None)) - except Exception as e: - results.append(FileUploadResponse(path=path, error=str(e))) - return results - - def download_files(self, paths: list[str]) -> list[FileDownloadResponse]: - """Download multiple files from the AgentCube sandbox. - - Args: - paths: A list of remote file paths to download. - - Returns: - A list of FileDownloadResponse objects containing file contents. - """ - results = [] - for path in paths: - try: - # AgentCube download_file writes to a local path - # We use a temp file to read it into memory for the response - with tempfile.NamedTemporaryFile(delete=False) as tmp: - tmp_path = tmp.name - - try: - self._client.download_file(path, tmp_path) - with open(tmp_path, "rb") as f: - content = f.read() - results.append(FileDownloadResponse(path=path, content=content, error=None)) - finally: - if os.path.exists(tmp_path): - os.remove(tmp_path) - except Exception as e: - results.append(FileDownloadResponse(path=path, content=b"", error=str(e))) - return results - - # --- Async Support --- - - async def aexecute( - self, - command: str, - *, - timeout: int | None = None, - ) -> ExecuteResponse: - """Async version of execute. Offloaded to thread pool.""" - return await asyncio.to_thread(self.execute, command, timeout=timeout) - - async def aupload_files( - self, - files: list[tuple[str, bytes]], - ) -> list[FileUploadResponse]: - """Async version of upload_files. Offloaded to thread pool.""" - return await asyncio.to_thread(self.upload_files, files) - - async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]: - """Async version of download_files. Offloaded to thread pool.""" - return await asyncio.to_thread(self.download_files, paths) diff --git a/sdk-python/agentcube/integrations/langchain/sandbox.py b/sdk-python/agentcube/integrations/langchain/sandbox.py index b00e48c8..01b9f3da 100644 --- a/sdk-python/agentcube/integrations/langchain/sandbox.py +++ b/sdk-python/agentcube/integrations/langchain/sandbox.py @@ -33,7 +33,7 @@ ) from deepagents.backends.sandbox import BaseSandbox except ModuleNotFoundError as e: - # Catching only if deepagents itself is missing. + # Catching only if deepagents itself is missing. # If deepagents is installed but has internal import errors, we should let them bubble up. if e.name and (e.name.startswith("deepagents") or "deepagents" in e.name): # Define fallback classes if deepagents is not installed @@ -119,7 +119,7 @@ def execute( output = e.stdout if e.stderr: output = f"{output}\n{e.stderr}".strip() if output else e.stderr - + return ExecuteResponse( output=output, exit_code=e.exit_code, diff --git a/sdk-python/examples/test_langchain_sandbox.py b/sdk-python/examples/test_langchain_sandbox.py index b8ae9615..742fbe09 100644 --- a/sdk-python/examples/test_langchain_sandbox.py +++ b/sdk-python/examples/test_langchain_sandbox.py @@ -23,7 +23,7 @@ async def test_sandbox_provider(): This demonstrates the BaseSandbox interface compliance. """ print("๐Ÿ› ๏ธ Initializing AgentCube LangChain Sandbox Provider...") - + # 1. Setup the client # Ensure ROUTER_URL is set in your environment try: @@ -31,7 +31,7 @@ async def test_sandbox_provider(): except Exception as e: print(f"โŒ Failed to initialize client: {e}") return - + # 2. Initialize the Sandbox Provider # This object implements the LangChain BaseSandbox interface sandbox = AgentCubeSandbox(client) @@ -42,10 +42,10 @@ async def test_sandbox_provider(): print("\n๐Ÿ“ Testing code execution...") cmd = "python3 -c \"print('Hello from AgentCube Sandbox!'); import os; print(f'Working dir: {os.getcwd()}')\"" response = await sandbox.aexecute(cmd) - + print(f"--- Output ---\n{response.output}") print(f"Exit Code: {response.exit_code}") - + if response.exit_code == 0: print("โœ… Execution successful.") @@ -88,5 +88,5 @@ async def test_sandbox_provider(): if not os.getenv("ROUTER_URL"): print("โš ๏ธ Warning: ROUTER_URL environment variable is not set.") print("Please set it before running (e.g., export ROUTER_URL=http://localhost:8080)") - + asyncio.run(test_sandbox_provider()) diff --git a/sdk-python/tests/test_langchain_integration.py b/sdk-python/tests/test_langchain_integration.py index 72f01022..30f8a4ff 100644 --- a/sdk-python/tests/test_langchain_integration.py +++ b/sdk-python/tests/test_langchain_integration.py @@ -82,7 +82,7 @@ def test_download_files(self, mock_tmpfile, mock_open, mock_remove, mock_exists) """Test downloading files.""" # Setup mock temp file mock_tmpfile.return_value.__enter__.return_value.name = "/tmp/fake_path" - + # Mock file content file_content = b"file content" mock_open.return_value.__enter__.return_value.read.return_value = file_content diff --git a/sdk-python/tests/test_langchain_standard.py b/sdk-python/tests/test_langchain_standard.py index 27bc3ebf..73ab9dad 100644 --- a/sdk-python/tests/test_langchain_standard.py +++ b/sdk-python/tests/test_langchain_standard.py @@ -18,15 +18,24 @@ from agentcube.integrations.langchain import AgentCubeSandbox from agentcube.code_interpreter import CodeInterpreterClient -from langchain_tests.integration_tests import SandboxIntegrationTests +try: + from langchain_tests.integration_tests import SandboxIntegrationTests + HAS_LANGCHAIN_TESTS = True +except ImportError: + # Fallback for CI environments where optional dependencies are not installed + class SandboxIntegrationTests: # type: ignore + pass + HAS_LANGCHAIN_TESTS = False + +@pytest.mark.skipif(not HAS_LANGCHAIN_TESTS, reason="langchain-tests not installed") class TestAgentCubeSandboxStandard(SandboxIntegrationTests): """Standard LangChain integration tests for AgentCubeSandbox.""" @pytest.fixture(scope="class") def sandbox(self) -> Iterator[AgentCubeSandbox]: """Provide a configured AgentCubeSandbox for testing. - + Note: This currently uses a mocked backend to allow CI execution. To test against a real backend, provide the necessary environment variables (ROUTER_URL, etc.) and remove the mocking logic. @@ -35,16 +44,16 @@ def sandbox(self) -> Iterator[AgentCubeSandbox]: # that simulates the behavior required by the test suite. mock_client = MagicMock(spec=CodeInterpreterClient) mock_client.session_id = "test-session-id" - + # Simulate successful command execution mock_client.execute_command.return_value = "standard output" - + # Simulate file operations mock_client.list_files.return_value = [] - + # Return the sandbox with the mocked client backend = AgentCubeSandbox(client=mock_client) - + try: yield backend finally: From c4410645f2e5e790857adfc3fe3a8f638e93fb73 Mon Sep 17 00:00:00 2001 From: Yash Israni <118755067+yashisrani@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:09:45 +0530 Subject: [PATCH 4/5] test: fix LangChain integration tests and linting issues Signed-off-by: Yash Israni <118755067+yashisrani@users.noreply.github.com> --- sdk-python/tests/test_langchain_integration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk-python/tests/test_langchain_integration.py b/sdk-python/tests/test_langchain_integration.py index 30f8a4ff..39f963a0 100644 --- a/sdk-python/tests/test_langchain_integration.py +++ b/sdk-python/tests/test_langchain_integration.py @@ -71,8 +71,8 @@ def test_upload_files(self): # Verify client calls self.assertEqual(self.mock_client.write_file.call_count, 2) - self.mock_client.write_file.assert_any_call(b"hello", "test1.txt") - self.mock_client.write_file.assert_any_call(b"world", "test2.txt") + self.mock_client.write_file.assert_any_call("hello", "test1.txt") + self.mock_client.write_file.assert_any_call("world", "test2.txt") @patch("os.path.exists", return_value=True) @patch("os.remove") From ab60921350ac4d52982f35ff8cf8bc81cec4908f Mon Sep 17 00:00:00 2001 From: Yash Israni <118755067+yashisrani@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:18:35 +0530 Subject: [PATCH 5/5] feat: added e2e test case Signed-off-by: Yash Israni <118755067+yashisrani@users.noreply.github.com> --- sdk-python/pyproject.toml | 2 +- sdk-python/tests/test_langchain_e2e.py | 86 ++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 sdk-python/tests/test_langchain_e2e.py diff --git a/sdk-python/pyproject.toml b/sdk-python/pyproject.toml index 99688b5b..47cf3fe4 100644 --- a/sdk-python/pyproject.toml +++ b/sdk-python/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ ] [project.optional-dependencies] -langchain = ["deepagents", "langchain-core", "langchain-tests"] +langchain = ["deepagents", "langchain-core", "langchain-tests", "pytest-asyncio"] [tool.setuptools.packages.find] where = ["."] diff --git a/sdk-python/tests/test_langchain_e2e.py b/sdk-python/tests/test_langchain_e2e.py new file mode 100644 index 00000000..e1e25120 --- /dev/null +++ b/sdk-python/tests/test_langchain_e2e.py @@ -0,0 +1,86 @@ +# Copyright The Volcano Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import pytest +from agentcube import CodeInterpreterClient +from agentcube.integrations.langchain import AgentCubeSandbox + +# Skip these tests unless the E2E environment variables are set +pytestmark = pytest.mark.skipif( + not os.getenv("ROUTER_URL") or not os.getenv("WORKLOAD_MANAGER_URL"), + reason="E2E environment variables (ROUTER_URL, WORKLOAD_MANAGER_URL) not set" +) + +@pytest.fixture +async def sandbox(): + """Fixture to manage the lifecycle of an AgentCubeSandbox during E2E tests.""" + client = CodeInterpreterClient(name="e2e-test-sandbox", verbose=False) + sb = AgentCubeSandbox(client) + yield sb + # Cleanup after test + client.stop() + +@pytest.mark.asyncio +async def test_langchain_sandbox_e2e_flow(sandbox): + """ + E2E test verifying the core BaseSandbox interface against a real backend. + Matches the flow in examples/test_langchain_sandbox.py + """ + + # 1. Test Command Execution + cmd = "python3 -c \"print('e2e_success')\"" + response = await sandbox.aexecute(cmd) + + assert response.exit_code == 0 + assert "e2e_success" in response.output + + # 2. Test File Upload + files_to_upload = [ + ("e2e_test.txt", b"e2e_content"), + ("data.json", b'{"key": "value"}') + ] + upload_results = await sandbox.aupload_files(files_to_upload) + + assert len(upload_results) == 2 + for res in upload_results: + assert res.error is None + assert res.path in ["e2e_test.txt", "data.json"] + + # 3. Verify files exist via remote ls + ls_res = await sandbox.aexecute("ls e2e_test.txt data.json") + assert ls_res.exit_code == 0 + assert "e2e_test.txt" in ls_res.output + assert "data.json" in ls_res.output + + # 4. Test File Download + download_results = await sandbox.adownload_files(["e2e_test.txt"]) + + assert len(download_results) == 1 + assert download_results[0].error is None + assert download_results[0].path == "e2e_test.txt" + assert download_results[0].content == b"e2e_content" + +@pytest.mark.asyncio +async def test_langchain_sandbox_environment_isolation(sandbox): + """Verify that multiple commands share the same stateful environment.""" + + # Set a variable in one command + await sandbox.aexecute("echo 'persisted_state' > state.txt") + + # Read it in another + response = await sandbox.aexecute("cat state.txt") + + assert response.exit_code == 0 + assert "persisted_state" in response.output