From b6c6081de4a7fbce723a422d8df36363ee932a61 Mon Sep 17 00:00:00 2001 From: raykkk Date: Mon, 26 Jan 2026 12:02:13 +0800 Subject: [PATCH 01/19] enhance list args in config --- src/agentscope_runtime/sandbox/manager/server/config.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/agentscope_runtime/sandbox/manager/server/config.py b/src/agentscope_runtime/sandbox/manager/server/config.py index e6a56278b..003f498cb 100644 --- a/src/agentscope_runtime/sandbox/manager/server/config.py +++ b/src/agentscope_runtime/sandbox/manager/server/config.py @@ -118,7 +118,12 @@ def validate_workers(cls, value, info): return 1 return value - @field_validator("DEFAULT_SANDBOX_TYPE", mode="before") + @field_validator( + "DEFAULT_SANDBOX_TYPE", + "FC_VSWITCH_IDS", + "AGENT_RUN_VSWITCH_IDS", + mode="before", + ) @classmethod def parse_default_type(cls, v): if isinstance(v, str): From e7c58a038fa9513fd4fe2655fa1bbebc6d04e275 Mon Sep 17 00:00:00 2001 From: raykkk Date: Mon, 26 Jan 2026 12:16:34 +0800 Subject: [PATCH 02/19] add redis session manager --- .../container_clients/agentrun_client.py | 68 +----------------- .../common/container_clients/fc_client.py | 65 +---------------- .../common/container_clients/utils.py | 69 +++++++++++++++++++ 3 files changed, 74 insertions(+), 128 deletions(-) create mode 100644 src/agentscope_runtime/common/container_clients/utils.py diff --git a/src/agentscope_runtime/common/container_clients/agentrun_client.py b/src/agentscope_runtime/common/container_clients/agentrun_client.py index 42a72b93b..a89117651 100644 --- a/src/agentscope_runtime/common/container_clients/agentrun_client.py +++ b/src/agentscope_runtime/common/container_clients/agentrun_client.py @@ -5,7 +5,6 @@ import string import time from http.client import HTTPS_PORT -from typing import List, Optional, Dict from urllib.parse import urlparse from alibabacloud_agentrun20250910.models import ( @@ -23,74 +22,13 @@ from alibabacloud_agentrun20250910.client import Client from alibabacloud_tea_openapi import models as open_api_models -from agentscope_runtime.sandbox.model import SandboxManagerEnvConfig from .base_client import BaseClient +from .utils import SessionManager +from ...sandbox.model import SandboxManagerEnvConfig logger = logging.getLogger(__name__) -class AgentRunSessionManager: - """ - Manager for AgentRun sessions that handles creation, retrieval, - updating, and deletion of sessions. - """ - - def __init__(self): - """Initialize the session manager with an empty session dictionary.""" - self.sessions = {} - logger.debug("AgentRunSessionManager initialized") - - def create_session(self, session_id: str, session_data: Dict): - """Create a new session with the given session_id and session_data. - - Args: - session_id (str): Unique identifier for the session. - session_data (Dict): Data to store in the session. - """ - self.sessions[session_id] = session_data - logger.info(f"Created AgentRun session: {session_id}") - - def get_session(self, session_id: str) -> Optional[Dict]: - """Retrieve session data by session_id. - - Args: - session_id (str): Unique identifier for the session. - - Returns: - Optional[Dict]: Session data if found, None otherwise. - """ - return self.sessions.get(session_id) - - def update_session(self, session_id: str, updates: Dict): - """Update an existing session with new data. - - Args: - session_id (str): Unique identifier for the session. - updates (Dict): Data to update in the session. - """ - if session_id in self.sessions: - self.sessions[session_id].update(updates) - logger.debug(f"Updated AgentRun session: {session_id}") - - def delete_session(self, session_id: str): - """Delete a session by session_id. - - Args: - session_id (str): Unique identifier for the session. - """ - if session_id in self.sessions: - del self.sessions[session_id] - logger.info(f"Deleted AgentRun session: {session_id}") - - def list_sessions(self) -> List[str]: - """List all session IDs. - - Returns: - List[str]: List of all session IDs. - """ - return list(self.sessions.keys()) - - class AgentRunClient(BaseClient): """ Client for managing AgentRun containers in the sandbox environment. @@ -117,7 +55,7 @@ def __init__(self, config: SandboxManagerEnvConfig): """ self.config = config self.client = self._create_agent_run_client() - self.session_manager = AgentRunSessionManager() + self.session_manager = SessionManager(config) self.agent_run_prefix = config.agent_run_prefix or "agentscope-sandbox" self._get_agent_runtime_status_max_attempts = ( self.GET_AGENT_RUNTIME_STATUS_MAX_ATTEMPTS diff --git a/src/agentscope_runtime/common/container_clients/fc_client.py b/src/agentscope_runtime/common/container_clients/fc_client.py index cc1538d69..51ed42fa5 100644 --- a/src/agentscope_runtime/common/container_clients/fc_client.py +++ b/src/agentscope_runtime/common/container_clients/fc_client.py @@ -8,7 +8,6 @@ import string import time from http.client import HTTPS_PORT -from typing import List, Dict, Optional from urllib.parse import urlparse from alibabacloud_fc20230330 import models as fc20230330_models @@ -18,71 +17,11 @@ from agentscope_runtime.sandbox.model import SandboxManagerEnvConfig from .base_client import BaseClient +from .utils import SessionManager logger = logging.getLogger(__name__) -class FCSessionManager: - """Manager for Function Compute sessions that handles creation, retrieval, - updating, and deletion of sessions. - """ - - def __init__(self): - """Initialize the session manager with an empty session dictionary.""" - self.sessions = {} - logger.debug("FC Session Manager initialized") - - def create_session(self, session_id: str, session_data: Dict): - """Create a new session with the given session_id and session_data. - - Args: - session_id (str): Unique identifier for the session. - session_data (Dict): Data to store in the session. - """ - self.sessions[session_id] = session_data - logger.debug(f"Created FC session: {session_id}") - - def get_session(self, session_id: str) -> Optional[Dict]: - """Retrieve session data by session_id. - - Args: - session_id (str): Unique identifier for the session. - - Returns: - Optional[Dict]: Session data if found, None otherwise. - """ - return self.sessions.get(session_id) - - def update_session(self, session_id: str, updates: Dict): - """Update an existing session with new data. - - Args: - session_id (str): Unique identifier for the session. - updates (Dict): Data to update in the session. - """ - if session_id in self.sessions: - self.sessions[session_id].update(updates) - logger.debug(f"Updated FC session: {session_id}") - - def delete_session(self, session_id: str): - """Delete a session by session_id. - - Args: - session_id (str): Unique identifier for the session. - """ - if session_id in self.sessions: - del self.sessions[session_id] - logger.debug(f"Deleted FC session: {session_id}") - - def list_sessions(self) -> List[str]: - """List all session IDs. - - Returns: - List[str]: List of all session IDs. - """ - return list(self.sessions.keys()) - - class FCClient(BaseClient): """Client for managing Function Compute containers in the sandbox environment. @@ -102,7 +41,7 @@ def __init__(self, config: SandboxManagerEnvConfig): """ self.config = config self.fc_client = self._create_fc_client() - self.session_manager = FCSessionManager() + self.session_manager = SessionManager(config) self.function_prefix = config.fc_prefix or "agentscope-sandbox" logger.info( diff --git a/src/agentscope_runtime/common/container_clients/utils.py b/src/agentscope_runtime/common/container_clients/utils.py new file mode 100644 index 000000000..762924941 --- /dev/null +++ b/src/agentscope_runtime/common/container_clients/utils.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +import logging +from typing import Dict, Optional, List + +from ..collections import RedisMapping, InMemoryMapping + +logger = logging.getLogger(__name__) + + +class SessionManager: + """Manager for sessions that handles creation, retrieval, + updating, and deletion of sessions. + """ + + def __init__(self, config): + """Initialize the session manager with an empty session dictionary.""" + self.config = config + + if self.config.redis_enabled: + import redis + + redis_client = redis.Redis( + host=self.config.redis_server, + port=self.config.redis_port, + db=self.config.redis_db, + username=self.config.redis_user, + password=self.config.redis_password, + decode_responses=True, + ) + try: + redis_client.ping() + except ConnectionError as e: + raise RuntimeError( + "Unable to connect to the Redis server.", + ) from e + + self.sessions = RedisMapping( + redis_client, + prefix="container_session_manager", # TODO: Configurable + ) + else: + self.sessions = InMemoryMapping() + + logger.debug("Session Manager initialized") + + def create_session(self, session_id: str, session_data: Dict): + """Create a new session with the given session_id and session_data.""" + self.sessions.set(session_id, session_data) + logger.debug(f"Created session: {session_id}") + + def get_session(self, session_id: str) -> Optional[Dict]: + """Retrieve session data by session_id.""" + return self.sessions.get(session_id) + + def update_session(self, session_id: str, updates: Dict): + """Update an existing session with new data.""" + if self.sessions.get(session_id): + self.sessions.set(session_id, updates) + logger.debug(f"Updated session: {session_id}") + + def delete_session(self, session_id: str): + """Delete a session by session_id.""" + if self.sessions.get(session_id): + self.sessions.delete(session_id) + logger.debug(f"Deleted session: {session_id}") + + def list_sessions(self) -> List[str]: + """List all session IDs.""" + return list(self.sessions.scan()) From 879ba5fdf30afdb09adc3663f91372e1ca4fadd9 Mon Sep 17 00:00:00 2001 From: raykkk Date: Mon, 26 Jan 2026 16:16:08 +0800 Subject: [PATCH 03/19] remove timeout in sdk --- .../sandbox/box/agentbay/agentbay_sandbox.py | 8 +++----- .../sandbox/box/base/base_sandbox.py | 4 ---- .../sandbox/box/browser/browser_sandbox.py | 4 ---- .../sandbox/box/cloud/cloud_sandbox.py | 4 ---- .../sandbox/box/dummy/dummy_sandbox.py | 2 -- .../sandbox/box/filesystem/filesystem_sandbox.py | 4 ---- .../sandbox/box/gui/gui_sandbox.py | 4 ---- .../sandbox/box/mobile/mobile_sandbox.py | 4 ---- src/agentscope_runtime/sandbox/box/sandbox.py | 9 --------- .../sandbox/box/training_box/training_box.py | 14 +++----------- 10 files changed, 6 insertions(+), 51 deletions(-) diff --git a/src/agentscope_runtime/sandbox/box/agentbay/agentbay_sandbox.py b/src/agentscope_runtime/sandbox/box/agentbay/agentbay_sandbox.py index f0858ec44..b12f3c23f 100644 --- a/src/agentscope_runtime/sandbox/box/agentbay/agentbay_sandbox.py +++ b/src/agentscope_runtime/sandbox/box/agentbay/agentbay_sandbox.py @@ -9,9 +9,10 @@ import os from typing import Any, Dict, Optional +from ..cloud.cloud_sandbox import CloudSandbox from ...registry import SandboxRegistry from ...enums import SandboxType -from ..cloud.cloud_sandbox import CloudSandbox +from ...constant import TIMEOUT logger = logging.getLogger(__name__) @@ -20,7 +21,7 @@ "agentbay-cloud", # Virtual image name indicating cloud service sandbox_type=SandboxType.AGENTBAY, security_level="high", - timeout=300, + timeout=TIMEOUT, description="AgentBay Cloud Sandbox Environment", ) class AgentbaySandbox(CloudSandbox): @@ -42,7 +43,6 @@ class AgentbaySandbox(CloudSandbox): def __init__( self, sandbox_id: Optional[str] = None, - timeout: int = 3000, base_url: Optional[str] = None, bearer_token: Optional[str] = None, sandbox_type: SandboxType = SandboxType.AGENTBAY, @@ -56,7 +56,6 @@ def __init__( Args: sandbox_id: Optional sandbox ID for existing sessions - timeout: Timeout for operations in seconds base_url: Base URL for AgentBay API (optional) bearer_token: Authentication token (deprecated, use api_key) sandbox_type: Type of sandbox (default: AGENTBAY) @@ -80,7 +79,6 @@ def __init__( super().__init__( sandbox_id=sandbox_id, - timeout=timeout, base_url=base_url, bearer_token=self.api_key, sandbox_type=sandbox_type, diff --git a/src/agentscope_runtime/sandbox/box/base/base_sandbox.py b/src/agentscope_runtime/sandbox/box/base/base_sandbox.py index 31c6f50cb..efbb8f53f 100644 --- a/src/agentscope_runtime/sandbox/box/base/base_sandbox.py +++ b/src/agentscope_runtime/sandbox/box/base/base_sandbox.py @@ -19,7 +19,6 @@ class BaseSandbox(Sandbox): def __init__( self, sandbox_id: Optional[str] = None, - timeout: int = 3000, base_url: Optional[str] = None, bearer_token: Optional[str] = None, sandbox_type: SandboxType = SandboxType.BASE, @@ -27,7 +26,6 @@ def __init__( ): super().__init__( sandbox_id, - timeout, base_url, bearer_token, sandbox_type, @@ -64,7 +62,6 @@ class BaseSandboxAsync(SandboxAsync): def __init__( self, sandbox_id: Optional[str] = None, - timeout: int = 3000, base_url: Optional[str] = None, bearer_token: Optional[str] = None, sandbox_type: SandboxType = SandboxType.BASE_ASYNC, @@ -72,7 +69,6 @@ def __init__( ): super().__init__( sandbox_id, - timeout, base_url, bearer_token, sandbox_type, diff --git a/src/agentscope_runtime/sandbox/box/browser/browser_sandbox.py b/src/agentscope_runtime/sandbox/box/browser/browser_sandbox.py index bfeb3f798..97de2a3d2 100644 --- a/src/agentscope_runtime/sandbox/box/browser/browser_sandbox.py +++ b/src/agentscope_runtime/sandbox/box/browser/browser_sandbox.py @@ -39,7 +39,6 @@ class BrowserSandbox(GUIMixin, BaseSandbox): def __init__( # pylint: disable=useless-parent-delegation self, sandbox_id: Optional[str] = None, - timeout: int = 3000, base_url: Optional[str] = None, bearer_token: Optional[str] = None, sandbox_type: SandboxType = SandboxType.BROWSER, @@ -47,7 +46,6 @@ def __init__( # pylint: disable=useless-parent-delegation ): super().__init__( sandbox_id, - timeout, base_url, bearer_token, sandbox_type, @@ -314,7 +312,6 @@ class BrowserSandboxAsync(GUIMixin, AsyncGUIMixin, BaseSandboxAsync): def __init__( # pylint: disable=useless-parent-delegation self, sandbox_id: Optional[str] = None, - timeout: int = 3000, base_url: Optional[str] = None, bearer_token: Optional[str] = None, sandbox_type: SandboxType = SandboxType.BROWSER_ASYNC, @@ -322,7 +319,6 @@ def __init__( # pylint: disable=useless-parent-delegation ): super().__init__( sandbox_id, - timeout, base_url, bearer_token, sandbox_type, diff --git a/src/agentscope_runtime/sandbox/box/cloud/cloud_sandbox.py b/src/agentscope_runtime/sandbox/box/cloud/cloud_sandbox.py index 0b394d572..befe006b6 100644 --- a/src/agentscope_runtime/sandbox/box/cloud/cloud_sandbox.py +++ b/src/agentscope_runtime/sandbox/box/cloud/cloud_sandbox.py @@ -34,7 +34,6 @@ class CloudSandbox(Sandbox, ABC): def __init__( self, sandbox_id: Optional[str] = None, - timeout: int = 3000, base_url: Optional[str] = None, bearer_token: Optional[str] = None, sandbox_type: SandboxType = SandboxType.AGENTBAY, @@ -45,7 +44,6 @@ def __init__( Args: sandbox_id: Optional sandbox ID for existing sessions - timeout: Timeout for operations in seconds base_url: Base URL for cloud API (optional, may use default) bearer_token: Authentication token for cloud API sandbox_type: Type of sandbox (default: AGENTBAY) @@ -79,7 +77,6 @@ def __init__( self._sandbox_id = sandbox_id self.sandbox_type = sandbox_type - self.timeout = timeout logger.info(f"Cloud sandbox initialized with ID: {self._sandbox_id}") @@ -171,7 +168,6 @@ def get_info(self) -> Dict[str, Any]: "sandbox_id": self._sandbox_id, "sandbox_type": self.sandbox_type.value, "cloud_provider": self._get_cloud_provider_name(), - "timeout": self.timeout, } @abstractmethod diff --git a/src/agentscope_runtime/sandbox/box/dummy/dummy_sandbox.py b/src/agentscope_runtime/sandbox/box/dummy/dummy_sandbox.py index 1214109e5..9470670a9 100644 --- a/src/agentscope_runtime/sandbox/box/dummy/dummy_sandbox.py +++ b/src/agentscope_runtime/sandbox/box/dummy/dummy_sandbox.py @@ -18,7 +18,6 @@ class DummySandbox(Sandbox): def __init__( self, sandbox_id: Optional[str] = None, - timeout: int = 3000, base_url: Optional[str] = None, bearer_token: Optional[str] = None, sandbox_type: SandboxType = SandboxType.DUMMY, @@ -26,7 +25,6 @@ def __init__( ): super().__init__( sandbox_id, - timeout, base_url, bearer_token, sandbox_type, diff --git a/src/agentscope_runtime/sandbox/box/filesystem/filesystem_sandbox.py b/src/agentscope_runtime/sandbox/box/filesystem/filesystem_sandbox.py index 1687e15eb..549df0776 100644 --- a/src/agentscope_runtime/sandbox/box/filesystem/filesystem_sandbox.py +++ b/src/agentscope_runtime/sandbox/box/filesystem/filesystem_sandbox.py @@ -21,7 +21,6 @@ class FilesystemSandbox(GUIMixin, BaseSandbox): def __init__( # pylint: disable=useless-parent-delegation self, sandbox_id: Optional[str] = None, - timeout: int = 3000, base_url: Optional[str] = None, bearer_token: Optional[str] = None, sandbox_type: SandboxType = SandboxType.FILESYSTEM, @@ -29,7 +28,6 @@ def __init__( # pylint: disable=useless-parent-delegation ): super().__init__( sandbox_id, - timeout, base_url, bearer_token, sandbox_type, @@ -169,7 +167,6 @@ class FilesystemSandboxAsync(GUIMixin, AsyncGUIMixin, BaseSandboxAsync): def __init__( # pylint: disable=useless-parent-delegation self, sandbox_id: Optional[str] = None, - timeout: int = 3000, base_url: Optional[str] = None, bearer_token: Optional[str] = None, sandbox_type: SandboxType = SandboxType.FILESYSTEM_ASYNC, @@ -177,7 +174,6 @@ def __init__( # pylint: disable=useless-parent-delegation ): super().__init__( sandbox_id, - timeout, base_url, bearer_token, sandbox_type, diff --git a/src/agentscope_runtime/sandbox/box/gui/gui_sandbox.py b/src/agentscope_runtime/sandbox/box/gui/gui_sandbox.py index cc3dd7334..8ba2d6dad 100644 --- a/src/agentscope_runtime/sandbox/box/gui/gui_sandbox.py +++ b/src/agentscope_runtime/sandbox/box/gui/gui_sandbox.py @@ -73,7 +73,6 @@ class GuiSandbox(GUIMixin, BaseSandbox): def __init__( # pylint: disable=useless-parent-delegation self, sandbox_id: Optional[str] = None, - timeout: int = 3000, base_url: Optional[str] = None, bearer_token: Optional[str] = None, sandbox_type: SandboxType = SandboxType.GUI, @@ -81,7 +80,6 @@ def __init__( # pylint: disable=useless-parent-delegation ): super().__init__( sandbox_id, - timeout, base_url, bearer_token, sandbox_type, @@ -164,7 +162,6 @@ class GuiSandboxAsync(GUIMixin, AsyncGUIMixin, BaseSandboxAsync): def __init__( # pylint: disable=useless-parent-delegation self, sandbox_id: Optional[str] = None, - timeout: int = 3000, base_url: Optional[str] = None, bearer_token: Optional[str] = None, sandbox_type: SandboxType = SandboxType.GUI_ASYNC, @@ -172,7 +169,6 @@ def __init__( # pylint: disable=useless-parent-delegation ): super().__init__( sandbox_id, - timeout, base_url, bearer_token, sandbox_type, diff --git a/src/agentscope_runtime/sandbox/box/mobile/mobile_sandbox.py b/src/agentscope_runtime/sandbox/box/mobile/mobile_sandbox.py index 730c028ed..62ab270b5 100644 --- a/src/agentscope_runtime/sandbox/box/mobile/mobile_sandbox.py +++ b/src/agentscope_runtime/sandbox/box/mobile/mobile_sandbox.py @@ -193,7 +193,6 @@ class MobileSandbox(MobileMixin, Sandbox): def __init__( # pylint: disable=useless-parent-delegation self, sandbox_id: Optional[str] = None, - timeout: int = 3000, base_url: Optional[str] = None, bearer_token: Optional[str] = None, sandbox_type: SandboxType = SandboxType.MOBILE, @@ -205,7 +204,6 @@ def __init__( # pylint: disable=useless-parent-delegation super().__init__( sandbox_id, - timeout, base_url, bearer_token, sandbox_type, @@ -344,7 +342,6 @@ class MobileSandboxAsync(MobileMixin, AsyncMobileMixin, SandboxAsync): def __init__( self, sandbox_id: Optional[str] = None, - timeout: int = 3000, base_url: Optional[str] = None, bearer_token: Optional[str] = None, sandbox_type: SandboxType = SandboxType.MOBILE_ASYNC, @@ -356,7 +353,6 @@ def __init__( super().__init__( sandbox_id, - timeout, base_url, bearer_token, sandbox_type, diff --git a/src/agentscope_runtime/sandbox/box/sandbox.py b/src/agentscope_runtime/sandbox/box/sandbox.py index 1e0dd083f..f5f742306 100644 --- a/src/agentscope_runtime/sandbox/box/sandbox.py +++ b/src/agentscope_runtime/sandbox/box/sandbox.py @@ -30,12 +30,6 @@ class SandboxBase: sandbox_id: Existing sandbox/container identifier to attach to. If not provided, a new sandbox will be created when entering the context manager. - timeout: HTTP request timeout in seconds for client-side calls to the - sandbox runtime/manager (e.g., `list_tools`, `call_tool`, and other - network requests). This parameter does not control sandbox idle, - recycle, or heartbeat timeouts, which are configured separately by - the sandbox runtime (for example via the `HEARTBEAT_TIMEOUT` - environment variable). base_url: Remote SandboxManager service URL. If provided, the sandbox runs in remote mode; otherwise, embedded mode is used. bearer_token: Optional bearer token for authenticating to the remote @@ -47,14 +41,12 @@ class SandboxBase: embed_mode: Whether the sandbox is running with an embedded local manager. sandbox_type: Selected sandbox type. - timeout: HTTP request timeout in seconds. _sandbox_id: The bound sandbox id (may be None until created). """ def __init__( self, sandbox_id: Optional[str] = None, - timeout: int = 3000, base_url: Optional[str] = None, bearer_token: Optional[str] = None, sandbox_type: SandboxType = SandboxType.BASE, @@ -63,7 +55,6 @@ def __init__( self.base_url = base_url self.embed_mode = not bool(base_url) self.sandbox_type = sandbox_type - self.timeout = timeout self._sandbox_id = sandbox_id self._warned_sandbox_not_started = False diff --git a/src/agentscope_runtime/sandbox/box/training_box/training_box.py b/src/agentscope_runtime/sandbox/box/training_box/training_box.py index f94216baa..4c416a94a 100644 --- a/src/agentscope_runtime/sandbox/box/training_box/training_box.py +++ b/src/agentscope_runtime/sandbox/box/training_box/training_box.py @@ -12,6 +12,7 @@ from ...registry import SandboxRegistry from ...enums import SandboxType from ...box.sandbox import Sandbox +from ...constant import TIMEOUT class TrainingSandbox(Sandbox): @@ -25,7 +26,6 @@ class TrainingSandbox(Sandbox): def __init__( self, sandbox_id: Optional[str] = None, - timeout: int = 3000, base_url: Optional[str] = None, bearer_token: Optional[str] = None, box_type: SandboxType = SandboxType.APPWORLD, @@ -35,13 +35,11 @@ def __init__( Args: sandbox_id (Optional[str]): Unique identifier for the sandbox. - timeout (int): Maximum time allowed for sandbox operations. base_url (Optional[str]): Base URL for sandbox API. bearer_token (Optional[str]): Authentication token for API access. """ super().__init__( sandbox_id, - timeout, base_url, bearer_token, box_type, @@ -210,7 +208,7 @@ def release_instance(self, instance_id: str): sandbox_type=SandboxType.APPWORLD, runtime_config={"shm_size": "5.06gb"}, security_level="medium", - timeout=30, + timeout=TIMEOUT, description="appworld Sandbox", ) class APPWorldSandbox(TrainingSandbox): @@ -224,7 +222,6 @@ class APPWorldSandbox(TrainingSandbox): def __init__( self, sandbox_id: Optional[str] = None, - timeout: int = 3000, base_url: Optional[str] = None, bearer_token: Optional[str] = None, sandbox_type: SandboxType = SandboxType.APPWORLD, @@ -234,13 +231,11 @@ def __init__( Args: sandbox_id (Optional[str]): Unique identifier for the sandbox. - timeout (int): Maximum time allowed for sandbox operations. base_url (Optional[str]): Base URL for sandbox API. bearer_token (Optional[str]): Authentication token for API access. """ super().__init__( sandbox_id, - timeout, base_url, bearer_token, sandbox_type, @@ -265,7 +260,7 @@ def __init__( }, # ["all","all_scoring","multi_turn","single_turn", # "live","non_live","non_python","python"] - timeout=30, + timeout=TIMEOUT, description="bfcl Sandbox", ) class BFCLSandbox(TrainingSandbox): @@ -279,7 +274,6 @@ class BFCLSandbox(TrainingSandbox): def __init__( self, sandbox_id: Optional[str] = None, - timeout: int = 3000, base_url: Optional[str] = None, bearer_token: Optional[str] = None, sandbox_type: SandboxType = SandboxType.BFCL, @@ -289,13 +283,11 @@ def __init__( Args: sandbox_id (Optional[str]): Unique identifier for the sandbox. - timeout (int): Maximum time allowed for sandbox operations. base_url (Optional[str]): Base URL for sandbox API. bearer_token (Optional[str]): Authentication token for API access. """ super().__init__( sandbox_id, - timeout, base_url, bearer_token, sandbox_type, From bf51f1cad6294ffd4790261ad05a4993ec310283 Mon Sep 17 00:00:00 2001 From: raykkk Date: Mon, 26 Jan 2026 16:19:42 +0800 Subject: [PATCH 04/19] update comments --- src/agentscope_runtime/sandbox/registry.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/agentscope_runtime/sandbox/registry.py b/src/agentscope_runtime/sandbox/registry.py index bd6b38126..4bb72ca7d 100644 --- a/src/agentscope_runtime/sandbox/registry.py +++ b/src/agentscope_runtime/sandbox/registry.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from .enums import SandboxType +from .constant import TIMEOUT @dataclass @@ -13,7 +14,7 @@ class SandboxConfig: sandbox_type: SandboxType | str resource_limits: Optional[Dict] = None security_level: str = "medium" - timeout: int = 60 # Default timeout of 5 minutes + timeout: int = TIMEOUT description: str = "" environment: Optional[Dict] = None runtime_config: Optional[Dict] = None @@ -42,7 +43,7 @@ def register( sandbox_type: SandboxType | str, resource_limits: Dict = None, security_level: str = "medium", # Not used for now - timeout: int = 60, + timeout: int = TIMEOUT, description: str = "", environment: Dict = None, runtime_config: Optional[Dict] = None, @@ -55,7 +56,7 @@ def register( sandbox_type: Sandbox type resource_limits: Resource limit configuration security_level: Security level (low/medium/high) - timeout: Timeout in seconds + timeout: HTTP timeout in seconds in server side description: Description environment: Environment variables runtime_config: runtime configurations From 263925f877452da97644e38759b6e410ba2aa03b Mon Sep 17 00:00:00 2001 From: raykkk Date: Mon, 26 Jan 2026 17:20:34 +0800 Subject: [PATCH 05/19] implement workspace API in sandbox --- .../sandbox/box/shared/app.py | 1 + .../sandbox/box/shared/routers/__init__.py | 2 +- .../sandbox/box/shared/routers/workspace.py | 579 ++++++++++-------- 3 files changed, 310 insertions(+), 272 deletions(-) diff --git a/src/agentscope_runtime/sandbox/box/shared/app.py b/src/agentscope_runtime/sandbox/box/shared/app.py index e50e1e842..27b8542a4 100644 --- a/src/agentscope_runtime/sandbox/box/shared/app.py +++ b/src/agentscope_runtime/sandbox/box/shared/app.py @@ -35,6 +35,7 @@ async def healthz(): app.include_router(watcher_router, dependencies=[Depends(verify_secret_token)]) app.include_router( workspace_router, + prefix="/workspace", dependencies=[Depends(verify_secret_token)], ) diff --git a/src/agentscope_runtime/sandbox/box/shared/routers/__init__.py b/src/agentscope_runtime/sandbox/box/shared/routers/__init__.py index c17a9ec0c..5d782ad50 100644 --- a/src/agentscope_runtime/sandbox/box/shared/routers/__init__.py +++ b/src/agentscope_runtime/sandbox/box/shared/routers/__init__.py @@ -2,7 +2,7 @@ from .generic import generic_router from .mcp import mcp_router from .runtime_watcher import watcher_router -from .workspace import workspace_router +from .workspace import router as workspace_router __all__ = [ "mcp_router", diff --git a/src/agentscope_runtime/sandbox/box/shared/routers/workspace.py b/src/agentscope_runtime/sandbox/box/shared/routers/workspace.py index d4f47c0fc..c6e9d79f4 100644 --- a/src/agentscope_runtime/sandbox/box/shared/routers/workspace.py +++ b/src/agentscope_runtime/sandbox/box/shared/routers/workspace.py @@ -1,325 +1,362 @@ # -*- coding: utf-8 -*- -import shutil import os -import logging -import traceback - -import aiofiles - -from fastapi import APIRouter, HTTPException, Query, Body -from fastapi.responses import FileResponse +import shutil +from typing import Optional, Literal, List, Dict, Any, Tuple + +import anyio +from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File +from fastapi.responses import ( + PlainTextResponse, + StreamingResponse, + JSONResponse, + Response, +) -workspace_router = APIRouter() +router = APIRouter() -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) +BASE_DIR = os.getenv("WORKSPACE_DIR", "/workspace") +CHUNK_SIZE = 1024 * 1024 # 1MB -def ensure_within_workspace( - path: str, - base_directory: str = "/workspace", -) -> str: +def ensure_within_workspace(path: str, base_directory: str = BASE_DIR) -> str: """ - Ensure the provided path is within the /workspace directory. + Ensure the provided path is within the BASE_DIR directory. """ base_directory = os.path.abspath(base_directory) - # Determine if the input path is absolute or relative if os.path.isabs(path): full_path = os.path.abspath(path) else: full_path = os.path.abspath(os.path.join(base_directory, path)) - # Check for path traversal attacks and ensure path is within base_directory if not full_path.startswith(base_directory): raise HTTPException( status_code=403, - detail="Permission error. Access restricted to /workspace " + detail=f"Permission error. Access restricted to {BASE_DIR} " "directory.", ) return full_path -@workspace_router.get( - "/workspace/files", - summary="Retrieve a file within the /workspace directory", -) -async def get_workspace_file( - file_path: str = Query( - ..., - description="Path to the file within /workspace relative to its root", - ), -): +# ---------- threaded helpers (to avoid blocking event loop) ---------- + + +async def _exists(p: str) -> bool: + return await anyio.to_thread.run_sync(os.path.exists, p) + + +async def _isdir(p: str) -> bool: + return await anyio.to_thread.run_sync(os.path.isdir, p) + + +async def _isfile(p: str) -> bool: + return await anyio.to_thread.run_sync(os.path.isfile, p) + + +async def _islink(p: str) -> bool: + return await anyio.to_thread.run_sync(os.path.islink, p) + + +async def _makedirs(p: str) -> None: + if not p: + return + await anyio.to_thread.run_sync(lambda: os.makedirs(p, exist_ok=True)) + + +async def _remove_file(p: str) -> None: + await anyio.to_thread.run_sync(os.remove, p) + + +async def _rmtree(p: str) -> None: + await anyio.to_thread.run_sync(shutil.rmtree, p) + + +async def _replace(src: str, dst: str) -> None: + await anyio.to_thread.run_sync(os.replace, src, dst) + + +async def _lstat(p: str): + return await anyio.to_thread.run_sync(os.lstat, p) + + +async def _read_text(p: str) -> str: + def _read(): + with open(p, "r", encoding="utf-8") as f: + return f.read() + + return await anyio.to_thread.run_sync(_read) + + +async def _write_bytes_stream_to_file( + full_path: str, + request: Request, +) -> None: """ - Get a file within the /workspace directory. + Stream request body -> file, chunked, with file writes in a thread. """ + + def _open(): + return open(full_path, "wb") + + f = await anyio.to_thread.run_sync(_open) try: - # Ensure the file path is within the /workspace directory - full_path = ensure_within_workspace(file_path) - - # Check if the file exists - if not os.path.isfile(full_path): - raise HTTPException(status_code=404, detail="File not found.") - - # Return the file using FileResponse - return FileResponse( - full_path, - media_type="application/octet-stream", - filename=os.path.basename(full_path), - ) + async for chunk in request.stream(): + if not chunk: + continue + await anyio.to_thread.run_sync(f.write, chunk) + await anyio.to_thread.run_sync(f.flush) + finally: + await anyio.to_thread.run_sync(f.close) - except Exception as e: - logger.error(f"{str(e)}:\n{traceback.format_exc()}") - raise HTTPException( - status_code=500, - detail=f"{str(e)}: {traceback.format_exc()}", - ) from e +async def _write_uploadfile_to_path(uf: UploadFile, target: str) -> None: + """ + Stream UploadFile -> disk in chunks. UploadFile.read is async; disk + write is threaded. + """ -@workspace_router.post( - "/workspace/files", - summary="Create or edit a file within the /workspace directory", -) -async def create_or_edit_file( - file_path: str = Query( - ..., - description="Path to the file within /workspace", - ), - content: str = Body(..., description="Content to write to the file"), -): + def _open(): + return open(target, "wb") + + f = await anyio.to_thread.run_sync(_open) try: - full_path = ensure_within_workspace(file_path) - async with aiofiles.open(full_path, "w", encoding="utf-8") as f: - await f.write(content) - return {"message": "File created or edited successfully."} - except Exception as e: - logger.error( - f"Error creating or editing file: {str(e)}:\ - n{traceback.format_exc()}", - ) - raise HTTPException( - status_code=500, - detail=f"Error creating or editing file: {str(e)}", - ) from e + while True: + chunk = await uf.read(CHUNK_SIZE) + if not chunk: + break + await anyio.to_thread.run_sync(f.write, chunk) + await anyio.to_thread.run_sync(f.flush) + finally: + await anyio.to_thread.run_sync(f.close) + + +async def entry_info(full_path: str) -> Dict[str, Any]: + st = await _lstat(full_path) + + if await _isdir(full_path): + t = "dir" + elif await _isfile(full_path): + t = "file" + elif await _islink(full_path): + t = "symlink" + else: + t = "other" + return { + "path": full_path, + "name": os.path.basename(full_path.rstrip("/")), + "type": t, + "size": st.st_size if t == "file" else None, + "mtime_ms": int(st.st_mtime * 1000), + } -@workspace_router.get( - "/workspace/list-directories", - summary="List file items in the /workspace directory, including nested " - "files and directories", -) -async def list_workspace_files( - directory: str = Query( - "/workspace", - description="Directory to list files and directories from, default " - "is /workspace.", - ), -): + +async def _list_dir_recursive(root: str, depth: Optional[int]) -> List[str]: """ - List all files and directories in the specified directory, including - nested items, with type indication and statistics. + Return list of absolute paths under root, up to depth. + Uses blocking os.scandir in thread, but recursion logic stays async. """ - try: - target_directory = ensure_within_workspace(directory) - - # Verify if the specified directory exists - if not os.path.isdir(target_directory): - raise HTTPException(status_code=404, detail="Directory not found.") - - nested_items = [] - file_count = 0 - directory_count = 0 - - for root, dirs, files in os.walk(target_directory): - for d in dirs: - dir_path = os.path.join(root, d) - nested_items.append( - { - "type": "directory", - "path": os.path.relpath(dir_path, target_directory), - }, - ) - directory_count += 1 - - for f in files: - file_path = os.path.join(root, f) - nested_items.append( - { - "type": "file", - "path": os.path.relpath(file_path, target_directory), - }, - ) - file_count += 1 - - return { - "items": nested_items, - "statistics": { - "total_directories": directory_count, - "total_files": file_count, - }, - } - - except Exception as e: - logger.error( - f"Error listing files: {str(e)}:\n{traceback.format_exc()}", - ) - raise HTTPException( - status_code=500, - detail=f"An error occurred while listing files: {str(e)}", - ) from e + results: List[str] = [] + def _scandir(p: str) -> List[Tuple[str, bool]]: + out = [] + with os.scandir(p) as it: + for ent in it: + # follow_symlinks=False to avoid escaping via symlink dirs + is_dir = ent.is_dir(follow_symlinks=False) + out.append((ent.path, is_dir)) + return out -@workspace_router.post( - "/workspace/directories", - summary="Create a directory within the /workspace directory", -) -async def create_directory( - directory_path: str = Query( - ..., - description="Path to the directory within /workspace", - ), -): - try: - full_path = ensure_within_workspace(directory_path) - os.makedirs(full_path, exist_ok=True) - return {"message": "Directory created successfully."} - except Exception as e: - logger.error( - f"Error creating directory: {str(e)}:\n{traceback.format_exc()}", - ) - raise HTTPException( - status_code=500, - detail=f"Error creating directory: {str(e)}", - ) from e + async def walk(cur: str, d: int): + try: + items = await anyio.to_thread.run_sync(_scandir, cur) + except FileNotFoundError: + return + for p, is_dir in items: + results.append(p) + if is_dir and (depth is None or d < depth): + await walk(p, d + 1) -@workspace_router.delete( - "/workspace/files", - summary="Delete a file within the /workspace directory", -) -async def delete_file( - file_path: str = Query( - ..., - description="Path to the file within /workspace", - ), -): - try: - full_path = ensure_within_workspace(file_path) - if os.path.isfile(full_path): - os.remove(full_path) - return {"message": "File deleted successfully."} - else: - raise HTTPException(status_code=404, detail="File not found.") - except Exception as e: - logger.error( - f"Error deleting file: {str(e)}:\n{traceback.format_exc()}", - ) - raise HTTPException( - status_code=500, - detail=f"Error deleting file: {str(e)}", - ) from e + await walk(root, 1) + return results -@workspace_router.delete( - "/workspace/directories", - summary="Delete a directory within the /workspace directory", -) -async def delete_directory( - directory_path: str = Query( - ..., - description="Path to the directory within /workspace", - ), - recursive: bool = Query( - False, - description="Recursively delete directory contents", - ), +# -------------------- routes -------------------- + + +@router.get("/file") +async def read_file( + path: str = Query(...), + fmt: Literal["text", "bytes"] = Query("text", alias="format"), ): - try: - full_path = ensure_within_workspace(directory_path) - if recursive: - shutil.rmtree(full_path) - else: - os.rmdir(full_path) - return {"message": "Directory deleted successfully."} - except Exception as e: - logger.error( - f"Error deleting directory: {str(e)}:\n{traceback.format_exc()}", - ) + full_path = ensure_within_workspace(path) + + if not await _exists(full_path) or await _isdir(full_path): + raise HTTPException(status_code=404, detail="not found") + + if fmt == "text": + text = await _read_text(full_path) + return PlainTextResponse(text) + + async def aiter_file_bytes(): + """ + Async generator producing file chunks, but file reads happen in a + thread. + """ + + def _open(): + return open(full_path, "rb") + + f = await anyio.to_thread.run_sync(_open) + try: + while True: + chunk = await anyio.to_thread.run_sync(f.read, CHUNK_SIZE) + if not chunk: + break + yield chunk + finally: + await anyio.to_thread.run_sync(f.close) + + return StreamingResponse( + aiter_file_bytes(), + media_type="application/octet-stream", + ) + + +@router.put("/file") +async def write_file( + request: Request, + path: str = Query(...), +): + full_path = ensure_within_workspace(path) + parent = os.path.dirname(full_path) + + await _makedirs(parent) + + if await _exists(full_path) and await _isdir(full_path): raise HTTPException( - status_code=500, - detail=f"Error deleting directory: {str(e)}", - ) from e + status_code=409, + detail="path exists and is a directory", + ) + try: + await _write_bytes_stream_to_file(full_path, request) + except Exception: + try: + if await _exists(full_path): + await _remove_file(full_path) + except Exception: + pass + raise -@workspace_router.put( - "/workspace/move", - summary="Move or rename a file or directory within the /workspace " - "directory", -) -async def move_or_rename( - source_path: str = Query( - ..., - description="Source path within /workspace", - ), - destination_path: str = Query( - ..., - description="Destination path within /workspace", - ), + return JSONResponse(await entry_info(full_path)) + + +@router.post("/files:batch") +async def batch_write( + files: List[UploadFile] = File(default=[]), ): - try: - full_source_path = ensure_within_workspace(source_path) - full_destination_path = ensure_within_workspace(destination_path) - if not os.path.exists(full_source_path): + out: List[Dict[str, Any]] = [] + + for uf in files: + if not uf.filename: + raise HTTPException(400, detail="missing filename for a part") + + target = ensure_within_workspace(uf.filename) + await _makedirs(os.path.dirname(target)) + + if await _exists(target) and await _isdir(target): raise HTTPException( - status_code=404, - detail="Source file or directory not found.", + status_code=409, + detail=f"target exists and is a directory: {uf.filename}", ) - os.rename(full_source_path, full_destination_path) - return {"message": "Move or rename operation successful."} - except Exception as e: - logger.error( - f"Error moving or renaming: {str(e)}:\n{traceback.format_exc()}", - ) - raise HTTPException( - status_code=500, - detail=f"Error moving or renaming: {str(e)}", - ) from e + await _write_uploadfile_to_path(uf, target) + out.append(await entry_info(target)) -@workspace_router.post( - "/workspace/copy", - summary="Copy a file or directory within the /workspace directory", -) -async def copy( - source_path: str = Query( - ..., - description="Source path within /workspace", - ), - destination_path: str = Query( - ..., - description="Destination path within /workspace", - ), + return JSONResponse(out) + + +@router.get("/list") +async def list_dir( + path: str = Query(...), + depth: Optional[int] = Query(1, ge=1), ): - try: - full_source_path = ensure_within_workspace(source_path) - full_destination_path = ensure_within_workspace(destination_path) - if not os.path.exists(full_source_path): + full_path = ensure_within_workspace(path) + + if not await _exists(full_path) or not await _isdir(full_path): + raise HTTPException(404, detail="not found") + + paths = await _list_dir_recursive(full_path, depth) + + entries: List[Dict[str, Any]] = [] + for p in paths: + if await _exists(p): + entries.append(await entry_info(p)) + + return JSONResponse(entries) + + +@router.get("/exists") +async def exists(path: str = Query(...)): + full_path = ensure_within_workspace(path) + return JSONResponse({"exists": await _exists(full_path)}) + + +@router.delete("/entry") +async def remove(path: str = Query(...)): + full_path = ensure_within_workspace(path) + + if not await _exists(full_path): + return Response(status_code=204) + + if await _isdir(full_path) and not await _islink(full_path): + await _rmtree(full_path) + else: + await _remove_file(full_path) + + return Response(status_code=204) + + +@router.post("/move") +async def move(request: Request): + body = await request.json() + source = body.get("source") + destination = body.get("destination") + + if not source or not destination: + raise HTTPException(400, detail="source and destination are required") + + src = ensure_within_workspace(source) + dst = ensure_within_workspace(destination) + + if not await _exists(src): + raise HTTPException(404, detail="source not found") + + await _makedirs(os.path.dirname(dst)) + await _replace(src, dst) + + return JSONResponse(await entry_info(dst)) + + +@router.post("/mkdir") +async def mkdir(request: Request): + body = await request.json() + path = body.get("path") + if not path: + raise HTTPException(400, detail="path is required") + + full_path = ensure_within_workspace(path) + + if await _exists(full_path): + if not await _isdir(full_path): raise HTTPException( - status_code=404, - detail="Source file or directory not found.", + 409, + detail="path exists and is not a directory", ) + return JSONResponse({"created": False}) - if os.path.isdir(full_source_path): - shutil.copytree(full_source_path, full_destination_path) - else: - shutil.copy2(full_source_path, full_destination_path) - - return {"message": "Copy operation successful."} - except Exception as e: - logger.error(f"Error copying: {str(e)}:\n{traceback.format_exc()}") - raise HTTPException( - status_code=500, - detail=f"Error copying: " f"{str(e)}", - ) from e + await _makedirs(full_path) + return JSONResponse({"created": True}) From e40fd65fb0547496027e24c060bc6bbf36c96b78 Mon Sep 17 00:00:00 2001 From: raykkk Date: Mon, 26 Jan 2026 19:05:53 +0800 Subject: [PATCH 06/19] implement http client --- .../sandbox/client/async_http_client.py | 129 +----- .../sandbox/client/http_client.py | 131 +----- .../sandbox/client/workspace_mixin.py | 397 ++++++++++++++++++ 3 files changed, 401 insertions(+), 256 deletions(-) create mode 100644 src/agentscope_runtime/sandbox/client/workspace_mixin.py diff --git a/src/agentscope_runtime/sandbox/client/async_http_client.py b/src/agentscope_runtime/sandbox/client/async_http_client.py index f3bc0d6fc..e23c86c75 100644 --- a/src/agentscope_runtime/sandbox/client/async_http_client.py +++ b/src/agentscope_runtime/sandbox/client/async_http_client.py @@ -8,13 +8,14 @@ from pydantic import Field from .base import SandboxHttpBase +from .workspace_mixin import WorkspaceAsyncMixin from ..model import ContainerModel logging.getLogger("httpx").setLevel(logging.WARNING) logger = logging.getLogger(__name__) -class SandboxHttpAsyncClient(SandboxHttpBase): +class SandboxHttpAsyncClient(SandboxHttpBase, WorkspaceAsyncMixin): """ A Python async client for interacting with the runtime API. Connect directly to the container. @@ -212,129 +213,3 @@ async def git_logs(self) -> dict: "get", f"{self.base_url}/watcher/git_logs", ) - - # -------- Workspace File APIs -------- - - async def get_workspace_file(self, file_path: str) -> dict: - """ - Retrieve a file from the /workspace directory. - """ - try: - endpoint = f"{self.base_url}/workspace/files" - params = {"file_path": file_path} - r = await self._request("get", endpoint, params=params) - r.raise_for_status() - - # Check for empty content - if r.headers.get("Content-Length") == "0": - logger.warning(f"The file {file_path} is empty.") - return {"data": b""} - - # Accumulate the content in chunks - file_content = bytearray() - async for chunk in r.aiter_bytes(): - file_content.extend(chunk) - - return {"data": bytes(file_content)} - except httpx.RequestError as e: - logger.error(f"An error occurred while retrieving the file: {e}") - return { - "isError": True, - "content": [{"type": "text", "text": str(e)}], - } - - async def create_or_edit_workspace_file( - self, - file_path: str, - content: str, - ) -> dict: - """ - Create or edit a file within the /workspace directory. - """ - return await self.safe_request( - "post", - f"{self.base_url}/workspace/files", - params={"file_path": file_path}, - json={"content": content}, - ) - - async def list_workspace_directories( - self, - directory: str = "/workspace", - ) -> dict: - """ - List files in the specified directory within the /workspace. - """ - return await self.safe_request( - "get", - f"{self.base_url}/workspace/list-directories", - params={"directory": directory}, - ) - - async def create_workspace_directory(self, directory_path: str) -> dict: - """ - Create a directory within the /workspace directory. - """ - return await self.safe_request( - "post", - f"{self.base_url}/workspace/directories", - params={"directory_path": directory_path}, - ) - - async def delete_workspace_file(self, file_path: str) -> dict: - """ - Delete a file within the /workspace directory. - """ - return await self.safe_request( - "delete", - f"{self.base_url}/workspace/files", - params={"file_path": file_path}, - ) - - async def delete_workspace_directory( - self, - directory_path: str, - recursive: bool = False, - ) -> dict: - """ - Delete a directory within the /workspace directory. - """ - return await self.safe_request( - "delete", - f"{self.base_url}/workspace/directories", - params={"directory_path": directory_path, "recursive": recursive}, - ) - - async def move_or_rename_workspace_item( - self, - source_path: str, - destination_path: str, - ) -> dict: - """ - Move or rename a file or directory within the /workspace directory. - """ - return await self.safe_request( - "put", - f"{self.base_url}/workspace/move", - params={ - "source_path": source_path, - "destination_path": destination_path, - }, - ) - - async def copy_workspace_item( - self, - source_path: str, - destination_path: str, - ) -> dict: - """ - Copy a file or directory within the /workspace directory. - """ - return await self.safe_request( - "post", - f"{self.base_url}/workspace/copy", - params={ - "source_path": source_path, - "destination_path": destination_path, - }, - ) diff --git a/src/agentscope_runtime/sandbox/client/http_client.py b/src/agentscope_runtime/sandbox/client/http_client.py index 85507b045..231f400d7 100644 --- a/src/agentscope_runtime/sandbox/client/http_client.py +++ b/src/agentscope_runtime/sandbox/client/http_client.py @@ -8,6 +8,7 @@ from pydantic import Field from .base import SandboxHttpBase +from .workspace_mixin import WorkspaceMixin from ..model import ContainerModel @@ -16,7 +17,7 @@ logger = logging.getLogger(__name__) -class SandboxHttpClient(SandboxHttpBase): +class SandboxHttpClient(SandboxHttpBase, WorkspaceMixin): """ A Python client for interacting with the runtime API. Connect with container directly. @@ -203,131 +204,3 @@ def git_logs(self) -> dict: Retrieve the git logs. """ return self.safe_request("get", f"{self.base_url}/watcher/git_logs") - - def get_workspace_file(self, file_path: str) -> dict: - """ - Retrieve a file from the /workspace directory. - """ - try: - endpoint = f"{self.base_url}/workspace/files" - params = {"file_path": file_path} - response = self._request( - "get", - endpoint, - params=params, - ) - response.raise_for_status() - # Return the binary content of the file - # Check for empty content - if response.headers.get("Content-Length") == "0": - logger.warning(f"The file {file_path} is empty.") - return {"data": b""} - - # Accumulate the content in chunks - file_content = bytearray() - for chunk in response.iter_content(chunk_size=4096): - file_content.extend(chunk) - - return {"data": bytes(file_content)} - except requests.exceptions.RequestException as e: - logger.error(f"An error occurred while retrieving the file: {e}") - return { - "isError": True, - "content": [{"type": "text", "text": str(e)}], - } - - def create_or_edit_workspace_file( - self, - file_path: str, - content: str, - ) -> dict: - """ - Create or edit a file within the /workspace directory. - """ - return self.safe_request( - "post", - f"{self.base_url}/workspace/files", - params={"file_path": file_path}, - json={"content": content}, - ) - - def list_workspace_directories( - self, - directory: str = "/workspace", - ) -> dict: - """ - List files in the specified directory within the /workspace. - """ - return self.safe_request( - "get", - f"{self.base_url}/workspace/list-directories", - params={"directory": directory}, - ) - - def create_workspace_directory(self, directory_path: str) -> dict: - """ - Create a directory within the /workspace directory. - """ - return self.safe_request( - "post", - f"{self.base_url}/workspace/directories", - params={"directory_path": directory_path}, - ) - - def delete_workspace_file(self, file_path: str) -> dict: - """ - Delete a file within the /workspace directory. - """ - return self.safe_request( - "delete", - f"{self.base_url}/workspace/files", - params={"file_path": file_path}, - ) - - def delete_workspace_directory( - self, - directory_path: str, - recursive: bool = False, - ) -> dict: - """ - Delete a directory within the /workspace directory. - """ - return self.safe_request( - "delete", - f"{self.base_url}/workspace/directories", - params={"directory_path": directory_path, "recursive": recursive}, - ) - - def move_or_rename_workspace_item( - self, - source_path: str, - destination_path: str, - ) -> dict: - """ - Move or rename a file or directory within the /workspace directory. - """ - return self.safe_request( - "put", - f"{self.base_url}/workspace/move", - params={ - "source_path": source_path, - "destination_path": destination_path, - }, - ) - - def copy_workspace_item( - self, - source_path: str, - destination_path: str, - ) -> dict: - """ - Copy a file or directory within the /workspace directory. - """ - return self.safe_request( - "post", - f"{self.base_url}/workspace/copy", - params={ - "source_path": source_path, - "destination_path": destination_path, - }, - ) diff --git a/src/agentscope_runtime/sandbox/client/workspace_mixin.py b/src/agentscope_runtime/sandbox/client/workspace_mixin.py new file mode 100644 index 000000000..4b3a562ee --- /dev/null +++ b/src/agentscope_runtime/sandbox/client/workspace_mixin.py @@ -0,0 +1,397 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +from typing import ( + IO, + Any, + Dict, + Iterator, + List, + Literal, + Optional, + Union, + AsyncIterator, +) + + +class WorkspaceMixin: + """ + Mixin for /workspace router. + Requires the host class to provide: + - self.base_url: str + - self.session: requests.Session + - self.timeout: int|float + - self.safe_request(method, url, **kwargs) + - self._request(method, url, **kwargs) (optional, for streaming) + """ + + def workspace_read( + self, + path: str, + fmt: Literal["text", "bytes", "stream"] = "text", + *, + chunk_size: int = 1024 * 1024, + ) -> Union[str, bytes, Iterator[bytes]]: + """ + Read a workspace file. + + - fmt="text": returns str + - fmt="bytes": returns bytes + - fmt="stream": returns Iterator[bytes] + """ + url = f"{self.base_url}/workspace/file" + + if fmt == "stream": + + def gen() -> Iterator[bytes]: + # Use raw request to keep the Response open during iteration. + r = self._request( # type: ignore[attr-defined] + "get", + url, + params={"path": path, "format": "bytes"}, + stream=True, + ) + r.raise_for_status() + try: + for chunk in r.iter_content(chunk_size=chunk_size): + if chunk: + yield chunk + finally: + r.close() + + return gen() + + r = self._request( # type: ignore[attr-defined] + "get", + url, + params={ + "path": path, + "format": "text" if fmt == "text" else "bytes", + }, + ) + r.raise_for_status() + return r.text if fmt == "text" else r.content + + def workspace_write( + self, + path: str, + data: Union[str, bytes, bytearray, IO[bytes]], + *, + content_type: str = "application/octet-stream", + ) -> Dict[str, Any]: + """ + Write a file to workspace. Supports streaming when data is file-like. + """ + url = f"{self.base_url}/workspace/file" + + headers: Dict[str, str] = {} + body: Union[bytes, IO[bytes]] + + if isinstance(data, str): + body = data.encode("utf-8") + headers["Content-Type"] = "text/plain; charset=utf-8" + elif isinstance(data, (bytes, bytearray)): + body = bytes(data) + headers["Content-Type"] = content_type + else: + body = data + headers["Content-Type"] = content_type + + r = self._request( # type: ignore[attr-defined] + "put", + url, + params={"path": path}, + data=body, + headers=headers, + ) + r.raise_for_status() + return r.json() + + def workspace_write_many( + self, + files: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + """ + Batch upload multiple files via multipart/form-data. + + files item format: + {"path": "dir/a.txt", "data": , + "content_type": "..."} # content_type optional + """ + url = f"{self.base_url}/workspace/files:batch" + + multipart = [] + for item in files: + p = item["path"] + d = item["data"] + ct = item.get("content_type", "application/octet-stream") + + if isinstance(d, str): + d = d.encode("utf-8") + ct = "text/plain; charset=utf-8" + + # requests `files=` format: (fieldname, (filename, + # fileobj/bytes, content_type)) + if isinstance(d, (bytes, bytearray)): + multipart.append(("files", (p, bytes(d), ct))) + else: + multipart.append(("files", (p, d, ct))) + + r = self._request( # type: ignore[attr-defined] + "post", + url, + files=multipart, + ) + r.raise_for_status() + return r.json() + + def workspace_list( + self, + path: str, + depth: Optional[int] = 1, + ) -> List[Dict[str, Any]]: + return self.safe_request( # type: ignore[attr-defined] + "get", + f"{self.base_url}/workspace/list", + params={"path": path, "depth": depth}, + ) + + def workspace_exists(self, path: str) -> bool: + data = self.safe_request( # type: ignore[attr-defined] + "get", + f"{self.base_url}/workspace/exists", + params={"path": path}, + ) + return bool(isinstance(data, dict) and data.get("exists")) + + def workspace_remove(self, path: str) -> None: + r = self._request( # type: ignore[attr-defined] + "delete", + f"{self.base_url}/workspace/entry", + params={"path": path}, + ) + r.raise_for_status() + + def workspace_move(self, source: str, destination: str) -> Dict[str, Any]: + return self.safe_request( # type: ignore[attr-defined] + "post", + f"{self.base_url}/workspace/move", + json={"source": source, "destination": destination}, + ) + + def workspace_mkdir(self, path: str) -> bool: + data = self.safe_request( # type: ignore[attr-defined] + "post", + f"{self.base_url}/workspace/mkdir", + json={"path": path}, + ) + return bool(isinstance(data, dict) and data.get("created")) + + def workspace_write_from_path( + self, + workspace_path: str, + local_path: str, + *, + content_type: str = "application/octet-stream", + ) -> Dict[str, Any]: + """ + Stream upload a local file to workspace_path. + """ + with open(local_path, "rb") as f: + return self.workspace_write( + workspace_path, + f, + content_type=content_type, + ) + + +class WorkspaceAsyncMixin: + """ + Async mixin for /workspace router. + + Requires the host class to provide: + - self.base_url: str + - self.client: httpx.AsyncClient + - self.safe_request(method, url, **kwargs) -> awaitable + - self._request(method, url, **kwargs) -> + awaitable returning httpx.Response + """ + + async def workspace_read( + self, + path: str, + fmt: Literal["text", "bytes", "stream"] = "text", + ) -> Union[str, bytes, AsyncIterator[bytes]]: + """ + Read a workspace file. + + - fmt="text": returns str + - fmt="bytes": returns bytes + - fmt="stream": returns AsyncIterator[bytes] + """ + url = f"{self.base_url}/workspace/file" + + if fmt == "stream": + + async def gen() -> AsyncIterator[bytes]: + async with self.client.stream( # type: ignore[attr-defined] + "GET", + url, + params={"path": path, "format": "bytes"}, + ) as r: + r.raise_for_status() + async for chunk in r.aiter_bytes(): + yield chunk + + return gen() + + r = await self._request( # type: ignore[attr-defined] + "get", + url, + params={ + "path": path, + "format": "text" if fmt == "text" else "bytes", + }, + ) + r.raise_for_status() + return r.text if fmt == "text" else r.content + + async def workspace_write( + self, + path: str, + data: Union[str, bytes, bytearray, IO[bytes]], + *, + content_type: str = "application/octet-stream", + ) -> Dict[str, Any]: + """ + Write a file to workspace. Streams when data is file-like. + """ + url = f"{self.base_url}/workspace/file" + + headers: Dict[str, str] = {} + body: Union[bytes, IO[bytes]] + + if isinstance(data, str): + body = data.encode("utf-8") + headers["Content-Type"] = "text/plain; charset=utf-8" + elif isinstance(data, (bytes, bytearray)): + body = bytes(data) + headers["Content-Type"] = content_type + else: + # file-like object; httpx will stream it + body = data + headers["Content-Type"] = content_type + + r = await self._request( # type: ignore[attr-defined] + "put", + url, + params={"path": path}, + content=body, # NOTE: httpx uses `content=` + headers=headers, + ) + r.raise_for_status() + return r.json() + + async def workspace_write_many( + self, + files: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + """ + Batch upload multiple files via multipart/form-data. + + files item format: + {"path": "dir/a.txt", "data": , + "content_type": "..."} # content_type optional + """ + url = f"{self.base_url}/workspace/files:batch" + + multipart = [] + for item in files: + p = item["path"] + d = item["data"] + ct = item.get("content_type", "application/octet-stream") + + if isinstance(d, str): + d = d.encode("utf-8") + ct = "text/plain; charset=utf-8" + + # httpx `files=` format: (fieldname, (filename, fileobj/bytes, + # content_type)) + if isinstance(d, (bytes, bytearray)): + multipart.append(("files", (p, bytes(d), ct))) + else: + multipart.append(("files", (p, d, ct))) + + r = await self._request( # type: ignore[attr-defined] + "post", + url, + files=multipart, + ) + r.raise_for_status() + return r.json() + + async def workspace_list( + self, + path: str, + depth: Optional[int] = 1, + ) -> List[Dict[str, Any]]: + return await self.safe_request( # type: ignore[attr-defined] + "get", + f"{self.base_url}/workspace/list", + params={"path": path, "depth": depth}, + ) + + async def workspace_exists(self, path: str) -> bool: + data = await self.safe_request( # type: ignore[attr-defined] + "get", + f"{self.base_url}/workspace/exists", + params={"path": path}, + ) + return bool(isinstance(data, dict) and data.get("exists")) + + async def workspace_remove(self, path: str) -> None: + r = await self._request( # type: ignore[attr-defined] + "delete", + f"{self.base_url}/workspace/entry", + params={"path": path}, + ) + r.raise_for_status() + + async def workspace_move( + self, + source: str, + destination: str, + ) -> Dict[str, Any]: + return await self.safe_request( # type: ignore[attr-defined] + "post", + f"{self.base_url}/workspace/move", + json={"source": source, "destination": destination}, + ) + + async def workspace_mkdir(self, path: str) -> bool: + data = await self.safe_request( # type: ignore[attr-defined] + "post", + f"{self.base_url}/workspace/mkdir", + json={"path": path}, + ) + return bool(isinstance(data, dict) and data.get("created")) + + async def workspace_write_from_path( + self, + workspace_path: str, + local_path: str, + *, + content_type: str = "application/octet-stream", + ) -> Dict[str, Any]: + """ + Stream upload a local file to workspace_path. + (Note: reading local file is sync I/O; if you want fully async disk + I/O, use aiofiles. This keeps the behavior consistent with your sync + version.) + """ + with open(local_path, "rb") as f: + return await self.workspace_write( + workspace_path, + f, + content_type=content_type, + ) From 4b46b8109e43a88b9f99ee99c4e6d05a599703d8 Mon Sep 17 00:00:00 2001 From: raykkk Date: Mon, 26 Jan 2026 19:29:08 +0800 Subject: [PATCH 07/19] implement workspace mixin --- .../sandbox/manager/server/app.py | 18 + .../sandbox/manager/workspace_mixin.py | 564 ++++++++++++++++++ 2 files changed, 582 insertions(+) create mode 100644 src/agentscope_runtime/sandbox/manager/workspace_mixin.py diff --git a/src/agentscope_runtime/sandbox/manager/server/app.py b/src/agentscope_runtime/sandbox/manager/server/app.py index 4e41326d6..06f232066 100644 --- a/src/agentscope_runtime/sandbox/manager/server/app.py +++ b/src/agentscope_runtime/sandbox/manager/server/app.py @@ -356,6 +356,24 @@ async def forward_to_client(): await websocket.close() +@app.api_route( + "/proxy/{identity}/{path:path}", + methods=["GET", "POST", "PUT", "DELETE", "PATCH"], +) +async def proxy_to_runtime( + identity: str, + path: str, + request: Request, + token=Depends(verify_token), +): + mgr = get_sandbox_manager() + return await mgr.proxy_to_runtime( + identity=identity, + path="/" + path, + request=request, + ) + + def setup_logging(log_level: str): """Setup logging configuration based on log level""" # Convert string to logging level diff --git a/src/agentscope_runtime/sandbox/manager/workspace_mixin.py b/src/agentscope_runtime/sandbox/manager/workspace_mixin.py new file mode 100644 index 000000000..34bda76af --- /dev/null +++ b/src/agentscope_runtime/sandbox/manager/workspace_mixin.py @@ -0,0 +1,564 @@ +# -*- coding: utf-8 -*- +""" +Workspace proxy mixins for SandboxManager (SDK side). + +This file is designed for the "two-layer" architecture: + SDK -> Manager(Server) -> Runtime(Container API) + +In *remote mode*, SandboxManager talks to Manager(Server) via HTTP. +To support streaming upload/download for workspace APIs, the Manager(Server) +must expose a generic streaming proxy endpoint: + + /proxy/{identity}/{path:path} + +The proxy endpoint should forward the request to the target runtime container, +injecting the runtime Authorization token, and streaming request/response +bodies without JSON-RPC wrapping. + +These mixins provide a workspace-like API on SandboxManager by calling the +proxy endpoint, covering all WorkspaceClient methods: + + - fs_read / fs_write / fs_write_many + - fs_list / fs_exists / fs_remove / fs_move / fs_mkdir + - fs_write_from_path + +Important: + - Sync and async method names MUST NOT collide. Therefore, async variants use + the `_async` suffix. +""" + +from __future__ import annotations + +from typing import ( + IO, + Any, + AsyncIterator, + Dict, + Iterator, + List, + Literal, + Optional, + Union, +) + +from ..constant import TIMEOUT + + +class ProxyBaseMixin: + """ + Base mixin for building proxy URLs to the Manager(Server). + + Host class requirements (remote mode): + - self.base_url: str + + The Manager(Server) must expose: + /proxy/{identity}/{path:path} + + which forwards to: + {runtime_base_url}/{path} + + and injects runtime Authorization token automatically. + """ + + def proxy_url(self, identity: str, runtime_path: str) -> str: + """ + Build a Manager(Server) proxy URL for a given runtime path. + + Args: + identity: Sandbox/container identity (sandbox_id/container_name). + runtime_path: Runtime path, e.g. "/workspace/file". + + Returns: + Full URL to Manager(Server) proxy endpoint. + """ + base_url = getattr(self, "base_url", None) + if not base_url: + raise RuntimeError( + "Proxy is only available in remote mode (base_url required).", + ) + runtime_path = runtime_path.lstrip("/") + return f"{base_url.rstrip('/')}/proxy/{identity}/{runtime_path}" + + +class WorkspaceProxySyncMixin(ProxyBaseMixin): + """ + Synchronous workspace proxy mixin for SandboxManager. + + Host class requirements: + - self.http_session: requests.Session + + This mixin implements workspace APIs by calling the Manager(Server) proxy + endpoint. It supports streaming downloads (fmt='stream') and streaming + uploads (data is a file-like object). + """ + + def fs_read( + self, + identity: str, + path: str, + fmt: Literal["text", "bytes", "stream"] = "text", + *, + chunk_size: int = 1024 * 1024, + ) -> Union[str, bytes, Iterator[bytes]]: + """ + Read a file from runtime workspace via Manager(Server) proxy. + + Args: + identity: Sandbox/container identity. + path: Workspace file path. + fmt: "text" | "bytes" | "stream". + chunk_size: Chunk size for streaming response iteration. + + Returns: + - str when fmt="text" + - bytes when fmt="bytes" + - Iterator[bytes] when fmt="stream" + """ + url = self.proxy_url(identity, "/workspace/file") + + if fmt == "stream": + r = self.http_session.get( # type: ignore[attr-defined] + url, + params={"path": path, "format": "bytes"}, + stream=True, + timeout=TIMEOUT, + ) + r.raise_for_status() + + def gen() -> Iterator[bytes]: + with r: + for c in r.iter_content(chunk_size=chunk_size): + if c: + yield c + + return gen() + + r = self.http_session.get( # type: ignore[attr-defined] + url, + params={ + "path": path, + "format": "text" if fmt == "text" else "bytes", + }, + timeout=TIMEOUT, + ) + r.raise_for_status() + return r.text if fmt == "text" else r.content + + def fs_write( + self, + identity: str, + path: str, + data: Union[str, bytes, bytearray, IO[bytes]], + *, + content_type: str = "application/octet-stream", + ) -> Dict[str, Any]: + """ + Write a file to runtime workspace via Manager(Server) proxy. + + Args: + identity: Sandbox/container identity. + path: Workspace file path. + data: str/bytes/file-like. If file-like, request body is streamed. + content_type: Content-Type used when data is bytes or file-like. + + Returns: + JSON dict from runtime (as returned by /workspace/file PUT). + """ + url = self.proxy_url(identity, "/workspace/file") + + headers: Dict[str, str] = {} + if isinstance(data, str): + body = data.encode("utf-8") + headers["Content-Type"] = "text/plain; charset=utf-8" + elif isinstance(data, (bytes, bytearray)): + body = bytes(data) + headers["Content-Type"] = content_type + else: + body = data + headers["Content-Type"] = content_type + + r = self.http_session.put( # type: ignore[attr-defined] + url, + params={"path": path}, + data=body, + headers=headers, + timeout=TIMEOUT, + ) + r.raise_for_status() + return r.json() + + def fs_write_many( + self, + identity: str, + files: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + """ + Batch upload multiple files via Manager(Server) proxy. + + Args: + identity: Sandbox/container identity. + files: A list of items: + { + "path": "dir/a.txt", + "data": , + "content_type": "..." # optional + } + + Returns: + List of JSON dicts from runtime. + """ + multipart = [] + for item in files: + p = item["path"] + d = item["data"] + ct = item.get("content_type", "application/octet-stream") + + if isinstance(d, str): + d = d.encode("utf-8") + ct = "text/plain; charset=utf-8" + + # requests format: + # (field, (filename, fileobj_or_bytes, content_type)) + if isinstance(d, (bytes, bytearray)): + multipart.append(("files", (p, bytes(d), ct))) + else: + multipart.append(("files", (p, d, ct))) + + url = self.proxy_url(identity, "/workspace/files:batch") + r = self.http_session.post( # type: ignore[attr-defined] + url, + files=multipart, + timeout=TIMEOUT, + ) + r.raise_for_status() + return r.json() + + def fs_list( + self, + identity: str, + path: str, + depth: Optional[int] = 1, + ) -> List[Dict[str, Any]]: + """ + List workspace directory entries via proxy. + + Args: + identity: Sandbox/container identity. + path: Workspace directory path. + depth: Depth of traversal. + + Returns: + List of dict entries. + """ + url = self.proxy_url(identity, "/workspace/list") + r = self.http_session.get( # type: ignore[attr-defined] + url, + params={"path": path, "depth": depth}, + timeout=TIMEOUT, + ) + r.raise_for_status() + return r.json() + + def fs_exists(self, identity: str, path: str) -> bool: + """ + Check if a workspace entry exists via proxy. + + Returns: + True if exists. + """ + url = self.proxy_url(identity, "/workspace/exists") + r = self.http_session.get( # type: ignore[attr-defined] + url, + params={"path": path}, + timeout=TIMEOUT, + ) + r.raise_for_status() + return bool(r.json().get("exists")) + + def fs_remove(self, identity: str, path: str) -> None: + """ + Remove a workspace entry (file or directory) via proxy. + """ + url = self.proxy_url(identity, "/workspace/entry") + r = self.http_session.delete( # type: ignore[attr-defined] + url, + params={"path": path}, + timeout=TIMEOUT, + ) + r.raise_for_status() + + def fs_move( + self, + identity: str, + source: str, + destination: str, + ) -> Dict[str, Any]: + """ + Move/rename a workspace entry via proxy. + """ + url = self.proxy_url(identity, "/workspace/move") + r = self.http_session.post( # type: ignore[attr-defined] + url, + json={"source": source, "destination": destination}, + timeout=TIMEOUT, + ) + r.raise_for_status() + return r.json() + + def fs_mkdir(self, identity: str, path: str) -> bool: + """ + Create a workspace directory via proxy. + + Returns: + True if created. + """ + url = self.proxy_url(identity, "/workspace/mkdir") + r = self.http_session.post( # type: ignore[attr-defined] + url, + json={"path": path}, + timeout=TIMEOUT, + ) + r.raise_for_status() + return bool(r.json().get("created")) + + def fs_write_from_path( + self, + identity: str, + workspace_path: str, + local_path: str, + *, + content_type: str = "application/octet-stream", + ) -> Dict[str, Any]: + """ + Stream upload a local file to runtime workspace via proxy. + + This avoids loading the whole file into memory on the SDK side. + + Args: + identity: Sandbox/container identity. + workspace_path: Target workspace path in runtime. + local_path: Local filesystem path to upload. + content_type: Content-Type for the uploaded file. + + Returns: + JSON dict from runtime. + """ + with open(local_path, "rb") as f: + return self.fs_write( + identity, + workspace_path, + f, + content_type=content_type, + ) + + +class WorkspaceProxyAsyncMixin(ProxyBaseMixin): + """ + Asynchronous workspace proxy mixin for SandboxManager. + + Host class requirements: + - self.httpx_client: httpx.AsyncClient + + Async method names use the `_async` suffix to avoid name collisions with + the sync mixin (Python MRO would otherwise overwrite methods). + """ + + async def fs_read_async( + self, + identity: str, + path: str, + fmt: Literal["text", "bytes", "stream"] = "text", + ) -> Union[str, bytes, AsyncIterator[bytes]]: + """ + Async read a file from workspace via proxy. + + Returns: + - str when fmt="text" + - bytes when fmt="bytes" + - AsyncIterator[bytes] when fmt="stream" + """ + url = self.proxy_url(identity, "/workspace/file") + + if fmt == "stream": + + async def gen() -> AsyncIterator[bytes]: + async with self.httpx_client.stream( + "GET", + url, + params={"path": path, "format": "bytes"}, + ) as r: + r.raise_for_status() + async for c in r.aiter_bytes(): + if c: + yield c + + return gen() + + r = await self.httpx_client.get( # type: ignore[attr-defined] + url, + params={ + "path": path, + "format": "text" if fmt == "text" else "bytes", + }, + ) + r.raise_for_status() + return r.text if fmt == "text" else r.content + + async def fs_write_async( + self, + identity: str, + path: str, + data: Union[str, bytes, bytearray, IO[bytes]], + *, + content_type: str = "application/octet-stream", + ) -> Dict[str, Any]: + """ + Async write a file to workspace via proxy (streaming supported). + + Returns: + JSON dict from runtime. + """ + url = self.proxy_url(identity, "/workspace/file") + + headers: Dict[str, str] = {} + if isinstance(data, str): + body = data.encode("utf-8") + headers["Content-Type"] = "text/plain; charset=utf-8" + elif isinstance(data, (bytes, bytearray)): + body = bytes(data) + headers["Content-Type"] = content_type + else: + body = data + headers["Content-Type"] = content_type + + r = await self.httpx_client.put( # type: ignore[attr-defined] + url, + params={"path": path}, + content=body, + headers=headers, + ) + r.raise_for_status() + return r.json() + + async def fs_write_many_async( + self, + identity: str, + files: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + """ + Async batch upload files via proxy. + """ + multipart = [] + for item in files: + p = item["path"] + d = item["data"] + ct = item.get("content_type", "application/octet-stream") + + if isinstance(d, str): + d = d.encode("utf-8") + ct = "text/plain; charset=utf-8" + + if isinstance(d, (bytes, bytearray)): + multipart.append(("files", (p, bytes(d), ct))) + else: + multipart.append(("files", (p, d, ct))) + + url = self.proxy_url(identity, "/workspace/files:batch") + r = await self.httpx_client.post( # type: ignore[attr-defined] + url, + files=multipart, + ) + r.raise_for_status() + return r.json() + + async def fs_list_async( + self, + identity: str, + path: str, + depth: Optional[int] = 1, + ) -> List[Dict[str, Any]]: + """ + Async list workspace entries via proxy. + """ + url = self.proxy_url(identity, "/workspace/list") + r = await self.httpx_client.get( # type: ignore[attr-defined] + url, + params={"path": path, "depth": depth}, + ) + r.raise_for_status() + return r.json() + + async def fs_exists_async(self, identity: str, path: str) -> bool: + """ + Async exists check via proxy. + """ + url = self.proxy_url(identity, "/workspace/exists") + r = await self.httpx_client.get( # type: ignore[attr-defined] + url, + params={"path": path}, + ) + r.raise_for_status() + return bool(r.json().get("exists")) + + async def fs_remove_async(self, identity: str, path: str) -> None: + """ + Async remove a workspace entry via proxy. + """ + url = self.proxy_url(identity, "/workspace/entry") + r = await self.httpx_client.delete( # type: ignore[attr-defined] + url, + params={"path": path}, + ) + r.raise_for_status() + + async def fs_move_async( + self, + identity: str, + source: str, + destination: str, + ) -> Dict[str, Any]: + """ + Async move/rename a workspace entry via proxy. + """ + url = self.proxy_url(identity, "/workspace/move") + r = await self.httpx_client.post( # type: ignore[attr-defined] + url, + json={"source": source, "destination": destination}, + ) + r.raise_for_status() + return r.json() + + async def fs_mkdir_async(self, identity: str, path: str) -> bool: + """ + Async mkdir via proxy. + """ + url = self.proxy_url(identity, "/workspace/mkdir") + r = await self.httpx_client.post( # type: ignore[attr-defined] + url, + json={"path": path}, + ) + r.raise_for_status() + return bool(r.json().get("created")) + + async def fs_write_from_path_async( + self, + identity: str, + workspace_path: str, + local_path: str, + *, + content_type: str = "application/octet-stream", + ) -> Dict[str, Any]: + """ + Async stream upload a local file to workspace via proxy. + + Note: + Local disk reading here is synchronous (built-in `open`). + If you need fully async disk I/O, use aiofiles and pass the stream. + """ + with open(local_path, "rb") as f: + return await self.fs_write_async( + identity, + workspace_path, + f, + content_type=content_type, + ) From 2009b4f565e1002a73842abc91dbc419c676a743 Mon Sep 17 00:00:00 2001 From: raykkk Date: Mon, 26 Jan 2026 19:30:07 +0800 Subject: [PATCH 08/19] implement workspace mixin --- .../sandbox/manager/workspace_mixin.py | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/agentscope_runtime/sandbox/manager/workspace_mixin.py b/src/agentscope_runtime/sandbox/manager/workspace_mixin.py index 34bda76af..7da7025ac 100644 --- a/src/agentscope_runtime/sandbox/manager/workspace_mixin.py +++ b/src/agentscope_runtime/sandbox/manager/workspace_mixin.py @@ -117,7 +117,7 @@ def fs_read( url = self.proxy_url(identity, "/workspace/file") if fmt == "stream": - r = self.http_session.get( # type: ignore[attr-defined] + r = self.http_session.get( url, params={"path": path, "format": "bytes"}, stream=True, @@ -133,7 +133,7 @@ def gen() -> Iterator[bytes]: return gen() - r = self.http_session.get( # type: ignore[attr-defined] + r = self.http_session.get( url, params={ "path": path, @@ -177,7 +177,7 @@ def fs_write( body = data headers["Content-Type"] = content_type - r = self.http_session.put( # type: ignore[attr-defined] + r = self.http_session.put( url, params={"path": path}, data=body, @@ -225,7 +225,7 @@ def fs_write_many( multipart.append(("files", (p, d, ct))) url = self.proxy_url(identity, "/workspace/files:batch") - r = self.http_session.post( # type: ignore[attr-defined] + r = self.http_session.post( url, files=multipart, timeout=TIMEOUT, @@ -251,7 +251,7 @@ def fs_list( List of dict entries. """ url = self.proxy_url(identity, "/workspace/list") - r = self.http_session.get( # type: ignore[attr-defined] + r = self.http_session.get( url, params={"path": path, "depth": depth}, timeout=TIMEOUT, @@ -267,7 +267,7 @@ def fs_exists(self, identity: str, path: str) -> bool: True if exists. """ url = self.proxy_url(identity, "/workspace/exists") - r = self.http_session.get( # type: ignore[attr-defined] + r = self.http_session.get( url, params={"path": path}, timeout=TIMEOUT, @@ -280,7 +280,7 @@ def fs_remove(self, identity: str, path: str) -> None: Remove a workspace entry (file or directory) via proxy. """ url = self.proxy_url(identity, "/workspace/entry") - r = self.http_session.delete( # type: ignore[attr-defined] + r = self.http_session.delete( url, params={"path": path}, timeout=TIMEOUT, @@ -297,7 +297,7 @@ def fs_move( Move/rename a workspace entry via proxy. """ url = self.proxy_url(identity, "/workspace/move") - r = self.http_session.post( # type: ignore[attr-defined] + r = self.http_session.post( url, json={"source": source, "destination": destination}, timeout=TIMEOUT, @@ -313,7 +313,7 @@ def fs_mkdir(self, identity: str, path: str) -> bool: True if created. """ url = self.proxy_url(identity, "/workspace/mkdir") - r = self.http_session.post( # type: ignore[attr-defined] + r = self.http_session.post( url, json={"path": path}, timeout=TIMEOUT, @@ -394,7 +394,7 @@ async def gen() -> AsyncIterator[bytes]: return gen() - r = await self.httpx_client.get( # type: ignore[attr-defined] + r = await self.httpx_client.get( url, params={ "path": path, @@ -431,7 +431,7 @@ async def fs_write_async( body = data headers["Content-Type"] = content_type - r = await self.httpx_client.put( # type: ignore[attr-defined] + r = await self.httpx_client.put( url, params={"path": path}, content=body, @@ -464,7 +464,7 @@ async def fs_write_many_async( multipart.append(("files", (p, d, ct))) url = self.proxy_url(identity, "/workspace/files:batch") - r = await self.httpx_client.post( # type: ignore[attr-defined] + r = await self.httpx_client.post( url, files=multipart, ) @@ -481,7 +481,7 @@ async def fs_list_async( Async list workspace entries via proxy. """ url = self.proxy_url(identity, "/workspace/list") - r = await self.httpx_client.get( # type: ignore[attr-defined] + r = await self.httpx_client.get( url, params={"path": path, "depth": depth}, ) @@ -493,7 +493,7 @@ async def fs_exists_async(self, identity: str, path: str) -> bool: Async exists check via proxy. """ url = self.proxy_url(identity, "/workspace/exists") - r = await self.httpx_client.get( # type: ignore[attr-defined] + r = await self.httpx_client.get( url, params={"path": path}, ) @@ -505,7 +505,7 @@ async def fs_remove_async(self, identity: str, path: str) -> None: Async remove a workspace entry via proxy. """ url = self.proxy_url(identity, "/workspace/entry") - r = await self.httpx_client.delete( # type: ignore[attr-defined] + r = await self.httpx_client.delete( url, params={"path": path}, ) @@ -521,7 +521,7 @@ async def fs_move_async( Async move/rename a workspace entry via proxy. """ url = self.proxy_url(identity, "/workspace/move") - r = await self.httpx_client.post( # type: ignore[attr-defined] + r = await self.httpx_client.post( url, json={"source": source, "destination": destination}, ) @@ -533,7 +533,7 @@ async def fs_mkdir_async(self, identity: str, path: str) -> bool: Async mkdir via proxy. """ url = self.proxy_url(identity, "/workspace/mkdir") - r = await self.httpx_client.post( # type: ignore[attr-defined] + r = await self.httpx_client.post( url, json={"path": path}, ) From f5dc405fa765d7bbf504af7a202e5974527db07a Mon Sep 17 00:00:00 2001 From: raykkk Date: Tue, 27 Jan 2026 10:39:00 +0800 Subject: [PATCH 09/19] add sdk for client --- .../sandbox/box/components/__init__.py | 7 + .../sandbox/box/components/fs.py | 278 ++++++++++++++++ src/agentscope_runtime/sandbox/box/sandbox.py | 4 + .../sandbox/manager/sandbox_manager.py | 3 +- .../sandbox/manager/workspace_mixin.py | 314 +++++++++++------- 5 files changed, 477 insertions(+), 129 deletions(-) create mode 100644 src/agentscope_runtime/sandbox/box/components/__init__.py create mode 100644 src/agentscope_runtime/sandbox/box/components/fs.py diff --git a/src/agentscope_runtime/sandbox/box/components/__init__.py b/src/agentscope_runtime/sandbox/box/components/__init__.py new file mode 100644 index 000000000..acfaa4846 --- /dev/null +++ b/src/agentscope_runtime/sandbox/box/components/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +from .fs import SandboxFS, SandboxFSAsync + +__all__ = [ + "SandboxFS", + "SandboxFSAsync", +] diff --git a/src/agentscope_runtime/sandbox/box/components/fs.py b/src/agentscope_runtime/sandbox/box/components/fs.py new file mode 100644 index 000000000..6ecb31433 --- /dev/null +++ b/src/agentscope_runtime/sandbox/box/components/fs.py @@ -0,0 +1,278 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +from typing import ( + IO, + Any, + AsyncIterator, + Dict, + Iterator, + List, + Literal, + Optional, + Union, +) + + +class SandboxFS: + """ + Sync filesystem facade bound to a Sandbox instance. + + Expected Sandbox interface: + - sandbox.sandbox_id: Optional[str] + - sandbox.manager_api: SandboxManager (with fs_* methods) + """ + + def __init__(self, sandbox) -> None: + self._sandbox = sandbox + + @property + def sandbox_id(self) -> str: + sid = self._sandbox.sandbox_id + if not sid: + raise RuntimeError( + "Sandbox is not started yet (sandbox_id is None).", + ) + return sid + + def read( + self, + path: str, + fmt: Literal["text", "bytes", "stream"] = "text", + *, + chunk_size: int = 1024 * 1024, + ) -> Union[str, bytes, Iterator[bytes]]: + """ + Read a workspace file. + """ + return self._sandbox.manager_api.fs_read( + self.sandbox_id, + path, + fmt=fmt, + chunk_size=chunk_size, + ) + + def write( + self, + path: str, + data: Union[str, bytes, bytearray, IO[bytes]], + *, + content_type: str = "application/octet-stream", + ) -> Dict[str, Any]: + """ + Write a workspace file (supports file-like streaming upload). + """ + return self._sandbox.manager_api.fs_write( + self.sandbox_id, + path, + data, + content_type=content_type, + ) + + def write_many(self, files: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Batch upload multiple files. + """ + return self._sandbox.manager_api.fs_write_many(self.sandbox_id, files) + + def list( + self, + path: str, + depth: Optional[int] = 1, + ) -> List[Dict[str, Any]]: + """ + List workspace entries. + """ + return self._sandbox.manager_api.fs_list( + self.sandbox_id, + path, + depth=depth, + ) + + def exists(self, path: str) -> bool: + """ + Check if a workspace entry exists. + """ + return self._sandbox.manager_api.fs_exists(self.sandbox_id, path) + + def remove(self, path: str) -> None: + """ + Remove a workspace entry (file or directory). + """ + return self._sandbox.manager_api.fs_remove(self.sandbox_id, path) + + def move(self, source: str, destination: str) -> Dict[str, Any]: + """ + Move/rename a workspace entry. + """ + return self._sandbox.manager_api.fs_move( + self.sandbox_id, + source, + destination, + ) + + def mkdir(self, path: str) -> bool: + """ + Create a workspace directory. + """ + return self._sandbox.manager_api.fs_mkdir(self.sandbox_id, path) + + def write_from_path( + self, + workspace_path: str, + local_path: str, + *, + content_type: str = "application/octet-stream", + ) -> Dict[str, Any]: + """ + Stream upload a local file to workspace. + """ + return self._sandbox.manager_api.fs_write_from_path( + self.sandbox_id, + workspace_path, + local_path, + content_type=content_type, + ) + + +class SandboxFSAsync: + """ + Async filesystem facade bound to a SandboxAsync instance. + + Expected SandboxAsync interface: + - sandbox.sandbox_id: Optional[str] + - sandbox.manager_api: SandboxManager (with fs_*_async methods) + """ + + def __init__(self, sandbox) -> None: + self._sandbox = sandbox + + @property + def sandbox_id(self) -> str: + sid = self._sandbox.sandbox_id + if not sid: + raise RuntimeError( + "Sandbox is not started yet (sandbox_id is None).", + ) + return sid + + async def read_async( + self, + path: str, + fmt: Literal["text", "bytes", "stream"] = "text", + ) -> Union[str, bytes, AsyncIterator[bytes]]: + """ + Async read a workspace file. + + If fmt="stream", returns an AsyncIterator[bytes]. + """ + return await self._sandbox.manager_api.fs_read_async( + self.sandbox_id, + path, + fmt=fmt, + ) + + async def write_async( + self, + path: str, + data: Union[str, bytes, bytearray, IO[bytes]], + *, + content_type: str = "application/octet-stream", + ) -> Dict[str, Any]: + """ + Async write a workspace file (supports file-like streaming upload). + """ + return await self._sandbox.manager_api.fs_write_async( + self.sandbox_id, + path, + data, + content_type=content_type, + ) + + async def write_many_async( + self, + files: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + """ + Async batch upload multiple files. + """ + return await self._sandbox.manager_api.fs_write_many_async( + self.sandbox_id, + files, + ) + + async def list_async( + self, + path: str, + depth: Optional[int] = 1, + ) -> List[Dict[str, Any]]: + """ + Async list workspace entries. + """ + return await self._sandbox.manager_api.fs_list_async( + self.sandbox_id, + path, + depth=depth, + ) + + async def exists_async(self, path: str) -> bool: + """ + Async exists check. + """ + return await self._sandbox.manager_api.fs_exists_async( + self.sandbox_id, + path, + ) + + async def remove_async(self, path: str) -> None: + """ + Async remove workspace entry. + """ + return await self._sandbox.manager_api.fs_remove_async( + self.sandbox_id, + path, + ) + + async def move_async( + self, + source: str, + destination: str, + ) -> Dict[str, Any]: + """ + Async move/rename workspace entry. + """ + return await self._sandbox.manager_api.fs_move_async( + self.sandbox_id, + source, + destination, + ) + + async def mkdir_async(self, path: str) -> bool: + """ + Async mkdir. + """ + return await self._sandbox.manager_api.fs_mkdir_async( + self.sandbox_id, + path, + ) + + async def write_from_path_async( + self, + workspace_path: str, + local_path: str, + *, + content_type: str = "application/octet-stream", + ) -> Dict[str, Any]: + """ + Async stream upload a local file to workspace. + + Note: + local file reading is synchronous in the manager mixin + implementation. + """ + return await self._sandbox.manager_api.fs_write_from_path_async( + self.sandbox_id, + workspace_path, + local_path, + content_type=content_type, + ) diff --git a/src/agentscope_runtime/sandbox/box/sandbox.py b/src/agentscope_runtime/sandbox/box/sandbox.py index f5f742306..d037cb7b6 100644 --- a/src/agentscope_runtime/sandbox/box/sandbox.py +++ b/src/agentscope_runtime/sandbox/box/sandbox.py @@ -6,6 +6,7 @@ import shortuuid +from .components import SandboxFS, SandboxFSAsync from ..enums import SandboxType from ..manager.sandbox_manager import SandboxManager from ..manager.server.app import get_config @@ -57,6 +58,7 @@ def __init__( self.sandbox_type = sandbox_type self._sandbox_id = sandbox_id self._warned_sandbox_not_started = False + self.fs = None self.workspace_dir = workspace_dir @@ -176,6 +178,7 @@ def __enter__(self): if self.embed_mode: atexit.register(self._cleanup) self._register_signal_handlers() + self.fs = SandboxFS(self) return self def __exit__(self, exc_type, exc_value, traceback): @@ -241,6 +244,7 @@ async def __aenter__(self): if self.embed_mode: atexit.register(self._cleanup) self._register_signal_handlers() + self.fs = SandboxFSAsync(self) return self async def __aexit__(self, exc_type, exc_value, traceback): diff --git a/src/agentscope_runtime/sandbox/manager/sandbox_manager.py b/src/agentscope_runtime/sandbox/manager/sandbox_manager.py index 29e196b78..39649f7e7 100644 --- a/src/agentscope_runtime/sandbox/manager/sandbox_manager.py +++ b/src/agentscope_runtime/sandbox/manager/sandbox_manager.py @@ -20,6 +20,7 @@ import httpx from .heartbeat_mixin import HeartbeatMixin, touch_session +from .workspace_mixin import WorkspaceFSMixin from ..constant import TIMEOUT from ..client import ( SandboxHttpClient, @@ -133,7 +134,7 @@ async def wrapper(self, *args, **kwargs): return decorator -class SandboxManager(HeartbeatMixin): +class SandboxManager(HeartbeatMixin, WorkspaceFSMixin): def __init__( self, config: Optional[SandboxManagerEnvConfig] = None, diff --git a/src/agentscope_runtime/sandbox/manager/workspace_mixin.py b/src/agentscope_runtime/sandbox/manager/workspace_mixin.py index 7da7025ac..af8a92eba 100644 --- a/src/agentscope_runtime/sandbox/manager/workspace_mixin.py +++ b/src/agentscope_runtime/sandbox/manager/workspace_mixin.py @@ -1,32 +1,22 @@ # -*- coding: utf-8 -*- """ -Workspace proxy mixins for SandboxManager (SDK side). - -This file is designed for the "two-layer" architecture: - SDK -> Manager(Server) -> Runtime(Container API) - -In *remote mode*, SandboxManager talks to Manager(Server) via HTTP. -To support streaming upload/download for workspace APIs, the Manager(Server) -must expose a generic streaming proxy endpoint: - - /proxy/{identity}/{path:path} - -The proxy endpoint should forward the request to the target runtime container, -injecting the runtime Authorization token, and streaming request/response -bodies without JSON-RPC wrapping. - -These mixins provide a workspace-like API on SandboxManager by calling the -proxy endpoint, covering all WorkspaceClient methods: - - - fs_read / fs_write / fs_write_many - - fs_list / fs_exists / fs_remove / fs_move / fs_mkdir - - fs_write_from_path - -Important: - - Sync and async method names MUST NOT collide. Therefore, async variants use - the `_async` suffix. +Workspace filesystem mixins for SandboxManager. + +This module provides a single set of workspace APIs on SandboxManager that +works in BOTH SDK modes: + +1) Embedded/local mode: + - SandboxManager has no http_session/httpx_client. + - It can connect to the runtime container directly via: + _establish_connection(identity) / _establish_connection_async(identity) + - Then it calls the runtime client's workspace_* APIs. + +2) Remote mode: + - SandboxManager has http_session/httpx_client and base_url. + - It calls Manager(Server)'s streaming proxy endpoint: + /proxy/{identity}/{path:path} + - The proxy forwards request/response to runtime with streaming support. """ - from __future__ import annotations from typing import ( @@ -46,52 +36,64 @@ class ProxyBaseMixin: """ - Base mixin for building proxy URLs to the Manager(Server). + Base helper to build Manager(Server) proxy URL in remote mode. - Host class requirements (remote mode): - - self.base_url: str + Host class attributes: + - base_url: str (remote mode only) - The Manager(Server) must expose: + Manager(Server) must expose: /proxy/{identity}/{path:path} - - which forwards to: - {runtime_base_url}/{path} - - and injects runtime Authorization token automatically. """ def proxy_url(self, identity: str, runtime_path: str) -> str: """ - Build a Manager(Server) proxy URL for a given runtime path. + Build the proxy URL for a runtime path. Args: - identity: Sandbox/container identity (sandbox_id/container_name). + identity: Sandbox/container identity + (sandbox_id or container_name). runtime_path: Runtime path, e.g. "/workspace/file". Returns: - Full URL to Manager(Server) proxy endpoint. + A full URL like: + {base_url}/proxy/{identity}/workspace/file """ base_url = getattr(self, "base_url", None) if not base_url: raise RuntimeError( - "Proxy is only available in remote mode (base_url required).", + "proxy_url is only available in remote mode (base_url " + "required).", ) - runtime_path = runtime_path.lstrip("/") - return f"{base_url.rstrip('/')}/proxy/{identity}/{runtime_path}" + return ( + f"{base_url.rstrip('/')}/proxy/{identity}" + f"/{runtime_path.lstrip('/')}" + ) -class WorkspaceProxySyncMixin(ProxyBaseMixin): +class WorkspaceFSSyncMixin(ProxyBaseMixin): """ - Synchronous workspace proxy mixin for SandboxManager. + Sync workspace filesystem APIs for SandboxManager. - Host class requirements: - - self.http_session: requests.Session + Embedded mode requirements: + - _establish_connection(identity) -> + runtime client implementing workspace_* methods - This mixin implements workspace APIs by calling the Manager(Server) proxy - endpoint. It supports streaming downloads (fmt='stream') and streaming - uploads (data is a file-like object). + Remote mode requirements: + - http_session: requests.Session + - base_url: str + - Manager(Server) provides /proxy/{identity}/{path:path} """ + # -------- internal helpers -------- + + def _is_remote_mode(self) -> bool: + return bool(getattr(self, "http_session", None)) + + def _runtime_client(self, identity: str): + return self._establish_connection(identity) + + # -------- public APIs (cover WorkspaceClient) -------- + def fs_read( self, identity: str, @@ -101,19 +103,21 @@ def fs_read( chunk_size: int = 1024 * 1024, ) -> Union[str, bytes, Iterator[bytes]]: """ - Read a file from runtime workspace via Manager(Server) proxy. + Read a workspace file. Args: identity: Sandbox/container identity. - path: Workspace file path. + path: Workspace path. fmt: "text" | "bytes" | "stream". - chunk_size: Chunk size for streaming response iteration. + chunk_size: Only used for remote streaming downloads. Returns: - - str when fmt="text" - - bytes when fmt="bytes" - - Iterator[bytes] when fmt="stream" + str / bytes / Iterator[bytes] """ + if not self._is_remote_mode(): + client = self._runtime_client(identity) + return client.workspace_read(path, fmt=fmt) + url = self.proxy_url(identity, "/workspace/file") if fmt == "stream": @@ -153,17 +157,16 @@ def fs_write( content_type: str = "application/octet-stream", ) -> Dict[str, Any]: """ - Write a file to runtime workspace via Manager(Server) proxy. - - Args: - identity: Sandbox/container identity. - path: Workspace file path. - data: str/bytes/file-like. If file-like, request body is streamed. - content_type: Content-Type used when data is bytes or file-like. - - Returns: - JSON dict from runtime (as returned by /workspace/file PUT). + Write a workspace file. Supports streaming upload via file-like object. """ + if not self._is_remote_mode(): + client = self._runtime_client(identity) + return client.workspace_write( + path, + data, + content_type=content_type, + ) + url = self.proxy_url(identity, "/workspace/file") headers: Dict[str, str] = {} @@ -193,20 +196,15 @@ def fs_write_many( files: List[Dict[str, Any]], ) -> List[Dict[str, Any]]: """ - Batch upload multiple files via Manager(Server) proxy. - - Args: - identity: Sandbox/container identity. - files: A list of items: - { - "path": "dir/a.txt", - "data": , - "content_type": "..." # optional - } + Batch upload files. - Returns: - List of JSON dicts from runtime. + Each item: + {"path": "...", "data": , "content_type": "..."} """ + if not self._is_remote_mode(): + client = self._runtime_client(identity) + return client.workspace_write_many(files) + multipart = [] for item in files: p = item["path"] @@ -240,16 +238,12 @@ def fs_list( depth: Optional[int] = 1, ) -> List[Dict[str, Any]]: """ - List workspace directory entries via proxy. - - Args: - identity: Sandbox/container identity. - path: Workspace directory path. - depth: Depth of traversal. - - Returns: - List of dict entries. + List directory entries in workspace. """ + if not self._is_remote_mode(): + client = self._runtime_client(identity) + return client.workspace_list(path, depth=depth) + url = self.proxy_url(identity, "/workspace/list") r = self.http_session.get( url, @@ -261,11 +255,12 @@ def fs_list( def fs_exists(self, identity: str, path: str) -> bool: """ - Check if a workspace entry exists via proxy. - - Returns: - True if exists. + Check if workspace entry exists. """ + if not self._is_remote_mode(): + client = self._runtime_client(identity) + return client.workspace_exists(path) + url = self.proxy_url(identity, "/workspace/exists") r = self.http_session.get( url, @@ -277,8 +272,13 @@ def fs_exists(self, identity: str, path: str) -> bool: def fs_remove(self, identity: str, path: str) -> None: """ - Remove a workspace entry (file or directory) via proxy. + Remove a workspace entry (file or directory). """ + if not self._is_remote_mode(): + client = self._runtime_client(identity) + client.workspace_remove(path) + return + url = self.proxy_url(identity, "/workspace/entry") r = self.http_session.delete( url, @@ -294,8 +294,12 @@ def fs_move( destination: str, ) -> Dict[str, Any]: """ - Move/rename a workspace entry via proxy. + Move/rename a workspace entry. """ + if not self._is_remote_mode(): + client = self._runtime_client(identity) + return client.workspace_move(source, destination) + url = self.proxy_url(identity, "/workspace/move") r = self.http_session.post( url, @@ -307,11 +311,12 @@ def fs_move( def fs_mkdir(self, identity: str, path: str) -> bool: """ - Create a workspace directory via proxy. - - Returns: - True if created. + Create a directory in workspace. """ + if not self._is_remote_mode(): + client = self._runtime_client(identity) + return client.workspace_mkdir(path) + url = self.proxy_url(identity, "/workspace/mkdir") r = self.http_session.post( url, @@ -330,19 +335,19 @@ def fs_write_from_path( content_type: str = "application/octet-stream", ) -> Dict[str, Any]: """ - Stream upload a local file to runtime workspace via proxy. - - This avoids loading the whole file into memory on the SDK side. - - Args: - identity: Sandbox/container identity. - workspace_path: Target workspace path in runtime. - local_path: Local filesystem path to upload. - content_type: Content-Type for the uploaded file. + Stream upload a local file to workspace. - Returns: - JSON dict from runtime. + In embedded mode: delegated to runtime client implementation. + In remote mode: uploads via proxy using a file handle. """ + if not self._is_remote_mode(): + client = self._runtime_client(identity) + return client.workspace_write_from_path( + workspace_path, + local_path, + content_type=content_type, + ) + with open(local_path, "rb") as f: return self.fs_write( identity, @@ -352,17 +357,26 @@ def fs_write_from_path( ) -class WorkspaceProxyAsyncMixin(ProxyBaseMixin): +class WorkspaceFSAsyncMixin(ProxyBaseMixin): """ - Asynchronous workspace proxy mixin for SandboxManager. + Async workspace filesystem APIs for SandboxManager. - Host class requirements: - - self.httpx_client: httpx.AsyncClient + Embedded mode requirements: + - _establish_connection_async(identity) -> + runtime async client implementing workspace_* async methods - Async method names use the `_async` suffix to avoid name collisions with - the sync mixin (Python MRO would otherwise overwrite methods). + Remote mode requirements: + - httpx_client: httpx.AsyncClient + - base_url: str + - Manager(Server) provides /proxy/{identity}/{path:path} """ + def _is_remote_mode_async(self) -> bool: + return bool(getattr(self, "httpx_client", None)) + + async def _runtime_client_async(self, identity: str): + return await self._establish_connection_async(identity) + async def fs_read_async( self, identity: str, @@ -370,13 +384,15 @@ async def fs_read_async( fmt: Literal["text", "bytes", "stream"] = "text", ) -> Union[str, bytes, AsyncIterator[bytes]]: """ - Async read a file from workspace via proxy. + Async read workspace file. Returns: - - str when fmt="text" - - bytes when fmt="bytes" - - AsyncIterator[bytes] when fmt="stream" + str / bytes / AsyncIterator[bytes] """ + if not self._is_remote_mode_async(): + client = await self._runtime_client_async(identity) + return await client.workspace_read(path, fmt=fmt) + url = self.proxy_url(identity, "/workspace/file") if fmt == "stream": @@ -413,11 +429,16 @@ async def fs_write_async( content_type: str = "application/octet-stream", ) -> Dict[str, Any]: """ - Async write a file to workspace via proxy (streaming supported). - - Returns: - JSON dict from runtime. + Async write workspace file (stream upload supported). """ + if not self._is_remote_mode_async(): + client = await self._runtime_client_async(identity) + return await client.workspace_write( + path, + data, + content_type=content_type, + ) + url = self.proxy_url(identity, "/workspace/file") headers: Dict[str, str] = {} @@ -446,8 +467,12 @@ async def fs_write_many_async( files: List[Dict[str, Any]], ) -> List[Dict[str, Any]]: """ - Async batch upload files via proxy. + Async batch upload files. """ + if not self._is_remote_mode_async(): + client = await self._runtime_client_async(identity) + return await client.workspace_write_many(files) + multipart = [] for item in files: p = item["path"] @@ -478,8 +503,12 @@ async def fs_list_async( depth: Optional[int] = 1, ) -> List[Dict[str, Any]]: """ - Async list workspace entries via proxy. + Async list workspace entries. """ + if not self._is_remote_mode_async(): + client = await self._runtime_client_async(identity) + return await client.workspace_list(path, depth=depth) + url = self.proxy_url(identity, "/workspace/list") r = await self.httpx_client.get( url, @@ -490,8 +519,12 @@ async def fs_list_async( async def fs_exists_async(self, identity: str, path: str) -> bool: """ - Async exists check via proxy. + Async exists check. """ + if not self._is_remote_mode_async(): + client = await self._runtime_client_async(identity) + return await client.workspace_exists(path) + url = self.proxy_url(identity, "/workspace/exists") r = await self.httpx_client.get( url, @@ -502,8 +535,13 @@ async def fs_exists_async(self, identity: str, path: str) -> bool: async def fs_remove_async(self, identity: str, path: str) -> None: """ - Async remove a workspace entry via proxy. + Async remove workspace entry. """ + if not self._is_remote_mode_async(): + client = await self._runtime_client_async(identity) + await client.workspace_remove(path) + return + url = self.proxy_url(identity, "/workspace/entry") r = await self.httpx_client.delete( url, @@ -518,8 +556,12 @@ async def fs_move_async( destination: str, ) -> Dict[str, Any]: """ - Async move/rename a workspace entry via proxy. + Async move/rename workspace entry. """ + if not self._is_remote_mode_async(): + client = await self._runtime_client_async(identity) + return await client.workspace_move(source, destination) + url = self.proxy_url(identity, "/workspace/move") r = await self.httpx_client.post( url, @@ -530,8 +572,12 @@ async def fs_move_async( async def fs_mkdir_async(self, identity: str, path: str) -> bool: """ - Async mkdir via proxy. + Async mkdir. """ + if not self._is_remote_mode_async(): + client = await self._runtime_client_async(identity) + return await client.workspace_mkdir(path) + url = self.proxy_url(identity, "/workspace/mkdir") r = await self.httpx_client.post( url, @@ -549,12 +595,20 @@ async def fs_write_from_path_async( content_type: str = "application/octet-stream", ) -> Dict[str, Any]: """ - Async stream upload a local file to workspace via proxy. + Async upload local file to workspace_path. Note: Local disk reading here is synchronous (built-in `open`). - If you need fully async disk I/O, use aiofiles and pass the stream. + If you need fully async disk I/O, use aiofiles. """ + if not self._is_remote_mode_async(): + client = await self._runtime_client_async(identity) + return await client.workspace_write_from_path( + workspace_path, + local_path, + content_type=content_type, + ) + with open(local_path, "rb") as f: return await self.fs_write_async( identity, @@ -562,3 +616,7 @@ async def fs_write_from_path_async( f, content_type=content_type, ) + + +class WorkspaceFSMixin(WorkspaceFSSyncMixin, WorkspaceFSAsyncMixin): + pass From aa8b747147a2066be4b6a416bf201eadcfde8d48 Mon Sep 17 00:00:00 2001 From: raykkk Date: Tue, 27 Jan 2026 10:56:59 +0800 Subject: [PATCH 10/19] add fs test --- .../sandbox/client/workspace_mixin.py | 36 ++++----- tests/sandbox/test_sandbox.py | 75 +++++++++++++++++++ 2 files changed, 93 insertions(+), 18 deletions(-) diff --git a/src/agentscope_runtime/sandbox/client/workspace_mixin.py b/src/agentscope_runtime/sandbox/client/workspace_mixin.py index 4b3a562ee..edfb446fb 100644 --- a/src/agentscope_runtime/sandbox/client/workspace_mixin.py +++ b/src/agentscope_runtime/sandbox/client/workspace_mixin.py @@ -45,7 +45,7 @@ def workspace_read( def gen() -> Iterator[bytes]: # Use raw request to keep the Response open during iteration. - r = self._request( # type: ignore[attr-defined] + r = self._request( "get", url, params={"path": path, "format": "bytes"}, @@ -61,7 +61,7 @@ def gen() -> Iterator[bytes]: return gen() - r = self._request( # type: ignore[attr-defined] + r = self._request( "get", url, params={ @@ -97,7 +97,7 @@ def workspace_write( body = data headers["Content-Type"] = content_type - r = self._request( # type: ignore[attr-defined] + r = self._request( "put", url, params={"path": path}, @@ -137,7 +137,7 @@ def workspace_write_many( else: multipart.append(("files", (p, d, ct))) - r = self._request( # type: ignore[attr-defined] + r = self._request( "post", url, files=multipart, @@ -150,14 +150,14 @@ def workspace_list( path: str, depth: Optional[int] = 1, ) -> List[Dict[str, Any]]: - return self.safe_request( # type: ignore[attr-defined] + return self.safe_request( "get", f"{self.base_url}/workspace/list", params={"path": path, "depth": depth}, ) def workspace_exists(self, path: str) -> bool: - data = self.safe_request( # type: ignore[attr-defined] + data = self.safe_request( "get", f"{self.base_url}/workspace/exists", params={"path": path}, @@ -165,7 +165,7 @@ def workspace_exists(self, path: str) -> bool: return bool(isinstance(data, dict) and data.get("exists")) def workspace_remove(self, path: str) -> None: - r = self._request( # type: ignore[attr-defined] + r = self._request( "delete", f"{self.base_url}/workspace/entry", params={"path": path}, @@ -173,14 +173,14 @@ def workspace_remove(self, path: str) -> None: r.raise_for_status() def workspace_move(self, source: str, destination: str) -> Dict[str, Any]: - return self.safe_request( # type: ignore[attr-defined] + return self.safe_request( "post", f"{self.base_url}/workspace/move", json={"source": source, "destination": destination}, ) def workspace_mkdir(self, path: str) -> bool: - data = self.safe_request( # type: ignore[attr-defined] + data = self.safe_request( "post", f"{self.base_url}/workspace/mkdir", json={"path": path}, @@ -234,7 +234,7 @@ async def workspace_read( if fmt == "stream": async def gen() -> AsyncIterator[bytes]: - async with self.client.stream( # type: ignore[attr-defined] + async with self.client.stream( "GET", url, params={"path": path, "format": "bytes"}, @@ -245,7 +245,7 @@ async def gen() -> AsyncIterator[bytes]: return gen() - r = await self._request( # type: ignore[attr-defined] + r = await self._request( "get", url, params={ @@ -282,7 +282,7 @@ async def workspace_write( body = data headers["Content-Type"] = content_type - r = await self._request( # type: ignore[attr-defined] + r = await self._request( "put", url, params={"path": path}, @@ -322,7 +322,7 @@ async def workspace_write_many( else: multipart.append(("files", (p, d, ct))) - r = await self._request( # type: ignore[attr-defined] + r = await self._request( "post", url, files=multipart, @@ -335,14 +335,14 @@ async def workspace_list( path: str, depth: Optional[int] = 1, ) -> List[Dict[str, Any]]: - return await self.safe_request( # type: ignore[attr-defined] + return await self.safe_request( "get", f"{self.base_url}/workspace/list", params={"path": path, "depth": depth}, ) async def workspace_exists(self, path: str) -> bool: - data = await self.safe_request( # type: ignore[attr-defined] + data = await self.safe_request( "get", f"{self.base_url}/workspace/exists", params={"path": path}, @@ -350,7 +350,7 @@ async def workspace_exists(self, path: str) -> bool: return bool(isinstance(data, dict) and data.get("exists")) async def workspace_remove(self, path: str) -> None: - r = await self._request( # type: ignore[attr-defined] + r = await self._request( "delete", f"{self.base_url}/workspace/entry", params={"path": path}, @@ -362,14 +362,14 @@ async def workspace_move( source: str, destination: str, ) -> Dict[str, Any]: - return await self.safe_request( # type: ignore[attr-defined] + return await self.safe_request( "post", f"{self.base_url}/workspace/move", json={"source": source, "destination": destination}, ) async def workspace_mkdir(self, path: str) -> bool: - data = await self.safe_request( # type: ignore[attr-defined] + data = await self.safe_request( "post", f"{self.base_url}/workspace/mkdir", json={"path": path}, diff --git a/tests/sandbox/test_sandbox.py b/tests/sandbox/test_sandbox.py index 87fd895ad..4069d3798 100644 --- a/tests/sandbox/test_sandbox.py +++ b/tests/sandbox/test_sandbox.py @@ -187,6 +187,81 @@ async def test_remote_sandbox(env): print(f"Error during cleanup: {cleanup_error}") +@pytest.mark.asyncio +async def test_local_sandbox_fs_async(env): + async with BaseSandboxAsync() as box: + # create dir + write + read(text) + ok = await box.fs.mkdir_async("dir_async") + assert isinstance(ok, bool) + + r1 = await box.fs.write_async("dir_async/a.txt", "hello async") + assert isinstance(r1, dict) + + txt = await box.fs.read_async("dir_async/a.txt", fmt="text") + assert txt == "hello async" + + # exists + list + assert await box.fs.exists_async("dir_async/a.txt") is True + items = await box.fs.list_async("dir_async", depth=2) + assert isinstance(items, list) + + # streaming download + stream = await box.fs.read_async("dir_async/a.txt", fmt="stream") + assert hasattr(stream, "__aiter__") + buf = b"" + async for chunk in stream: + buf += chunk + assert buf == b"hello async" + + # move + remove + mv = await box.fs.move_async("dir_async/a.txt", "dir_async/b.txt") + assert isinstance(mv, dict) + assert await box.fs.exists_async("dir_async/b.txt") is True + + await box.fs.remove_async("dir_async/b.txt") + assert await box.fs.exists_async("dir_async/b.txt") is False + + +def test_local_sandbox_fs(env, tmp_path): + with BaseSandbox() as box: + # create dir + write + read(text) + ok = box.fs.mkdir("dir") + assert isinstance(ok, bool) + + r1 = box.fs.write("dir/a.txt", "hello") + assert isinstance(r1, dict) + + txt = box.fs.read("dir/a.txt", fmt="text") + assert txt == "hello" + + # exists + list + assert box.fs.exists("dir/a.txt") is True + items = box.fs.list("dir", depth=2) + assert isinstance(items, list) + + # streaming download + out = b"" + for chunk in box.fs.read("dir/a.txt", fmt="stream"): + out += chunk + assert out == b"hello" + + # write_from_path (stream upload) + local_file = tmp_path / "local.txt" + local_file.write_text("from local", encoding="utf-8") + + r2 = box.fs.write_from_path("dir/from_local.txt", str(local_file)) + assert isinstance(r2, dict) + assert box.fs.read("dir/from_local.txt", fmt="text") == "from local" + + # move + remove + mv = box.fs.move("dir/a.txt", "dir/b.txt") + assert isinstance(mv, dict) + assert box.fs.exists("dir/b.txt") is True + + box.fs.remove("dir/b.txt") + assert box.fs.exists("dir/b.txt") is False + + if __name__ == "__main__": if os.path.exists("../../.env"): load_dotenv("../../.env") From d6b7b970a4490590171ff9b3545cc6720e26bba8 Mon Sep 17 00:00:00 2001 From: raykkk Date: Tue, 27 Jan 2026 11:00:26 +0800 Subject: [PATCH 11/19] fix ut --- tests/unit/test_cloud_sandbox.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/unit/test_cloud_sandbox.py b/tests/unit/test_cloud_sandbox.py index df91e4fb6..87beaaf59 100644 --- a/tests/unit/test_cloud_sandbox.py +++ b/tests/unit/test_cloud_sandbox.py @@ -48,7 +48,6 @@ def mock_cloud_sandbox(): """Create a mock CloudSandbox instance.""" return MockCloudSandbox( sandbox_id="test-sandbox-123", - timeout=3000, base_url="https://api.example.com", bearer_token="test-token", sandbox_type=SandboxType.AGENTBAY, @@ -60,7 +59,6 @@ def mock_cloud_sandbox_with_auto_create(): """Create a CloudSandbox instance that auto-creates session.""" return MockCloudSandbox( sandbox_id=None, - timeout=3000, base_url="https://api.example.com", bearer_token="test-token", sandbox_type=SandboxType.AGENTBAY, @@ -74,7 +72,6 @@ def test_init_with_sandbox_id(self, mock_cloud_sandbox): """Test initialization with existing sandbox ID.""" assert mock_cloud_sandbox._sandbox_id == "test-sandbox-123" assert mock_cloud_sandbox.sandbox_type == SandboxType.AGENTBAY - assert mock_cloud_sandbox.timeout == 3000 assert mock_cloud_sandbox.base_url == "https://api.example.com" assert mock_cloud_sandbox.bearer_token == "test-token" assert mock_cloud_sandbox.embed_mode is False @@ -133,7 +130,6 @@ def test_get_info(self, mock_cloud_sandbox): assert info["sandbox_id"] == "test-sandbox-123" assert info["sandbox_type"] == SandboxType.AGENTBAY.value assert info["cloud_provider"] == "MockCloudProvider" - assert info["timeout"] == 3000 def test_list_tools(self, mock_cloud_sandbox): """Test listing tools.""" From 1a7372a7bc443e52c3696d1faf7eb892ee0ee520 Mon Sep 17 00:00:00 2001 From: raykkk Date: Tue, 27 Jan 2026 11:01:52 +0800 Subject: [PATCH 12/19] fix ut --- src/agentscope_runtime/sandbox/custom/custom_sandbox.py | 2 -- src/agentscope_runtime/sandbox/custom/example.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/agentscope_runtime/sandbox/custom/custom_sandbox.py b/src/agentscope_runtime/sandbox/custom/custom_sandbox.py index e280b3865..015223094 100644 --- a/src/agentscope_runtime/sandbox/custom/custom_sandbox.py +++ b/src/agentscope_runtime/sandbox/custom/custom_sandbox.py @@ -27,13 +27,11 @@ class CustomSandbox(Sandbox): def __init__( self, sandbox_id: Optional[str] = None, - timeout: int = 3000, base_url: Optional[str] = None, bearer_token: Optional[str] = None, ): super().__init__( sandbox_id, - timeout, base_url, bearer_token, SandboxType(SANDBOX_TYPE), diff --git a/src/agentscope_runtime/sandbox/custom/example.py b/src/agentscope_runtime/sandbox/custom/example.py index cf7bc409e..6dd573b37 100644 --- a/src/agentscope_runtime/sandbox/custom/example.py +++ b/src/agentscope_runtime/sandbox/custom/example.py @@ -21,13 +21,11 @@ class ExampleSandbox(Sandbox): def __init__( self, sandbox_id: Optional[str] = None, - timeout: int = 3000, base_url: Optional[str] = None, bearer_token: Optional[str] = None, ): super().__init__( sandbox_id, - timeout, base_url, bearer_token, SandboxType(SANDBOX_TYPE), From c52b4d69a4eb10309a018fa0d278241eaa3fc5f8 Mon Sep 17 00:00:00 2001 From: raykkk Date: Tue, 27 Jan 2026 14:43:35 +0800 Subject: [PATCH 13/19] fix ut --- .../container_clients/boxlite_client.py | 1 + .../sandbox/box/shared/routers/workspace.py | 43 ++- .../sandbox/client/workspace_mixin.py | 64 +++- .../sandbox/manager/workspace_mixin.py | 48 ++- tests/sandbox/test_sandbox.py | 345 +++++++++++++++--- 5 files changed, 431 insertions(+), 70 deletions(-) diff --git a/src/agentscope_runtime/common/container_clients/boxlite_client.py b/src/agentscope_runtime/common/container_clients/boxlite_client.py index 574eed879..37ca6d8b6 100644 --- a/src/agentscope_runtime/common/container_clients/boxlite_client.py +++ b/src/agentscope_runtime/common/container_clients/boxlite_client.py @@ -101,6 +101,7 @@ def __init__(self, config=None): raise RuntimeError( f"BoxLite client initialization failed: {str(e)}\n" "Solutions:\n" + "• Switch to the sync SDK (async is not supported yet)\n" "• Ensure BoxLite is properly installed\n" "• Check BoxLite runtime configuration", ) from e diff --git a/src/agentscope_runtime/sandbox/box/shared/routers/workspace.py b/src/agentscope_runtime/sandbox/box/shared/routers/workspace.py index c6e9d79f4..93e91b574 100644 --- a/src/agentscope_runtime/sandbox/box/shared/routers/workspace.py +++ b/src/agentscope_runtime/sandbox/box/shared/routers/workspace.py @@ -4,7 +4,15 @@ from typing import Optional, Literal, List, Dict, Any, Tuple import anyio -from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File +from fastapi import ( + APIRouter, + HTTPException, + Query, + Request, + UploadFile, + File, + Form, +) from fastapi.responses import ( PlainTextResponse, StreamingResponse, @@ -257,20 +265,43 @@ async def write_file( @router.post("/files:batch") async def batch_write( files: List[UploadFile] = File(default=[]), + paths: List[str] = Form(default=[]), ): + """ + Batch write workspace files. + + Compatibility: + - If `paths` is provided, it must have the same length as `files` and + will be used as the target workspace path for each file. + - Otherwise, fall back to `UploadFile.filename` (legacy behavior). + + Rationale: + Some HTTP clients may sanitize the `filename` field + (e.g., dropping directory components). Providing `paths` as an + explicit form field is more reliable. + """ + if paths and len(paths) != len(files): + raise HTTPException( + status_code=400, + detail="`paths` length must match `files` length", + ) + out: List[Dict[str, Any]] = [] - for uf in files: - if not uf.filename: - raise HTTPException(400, detail="missing filename for a part") + for idx, uf in enumerate(files): + # choose target path + relpath = paths[idx] if paths else uf.filename + + if not relpath: + raise HTTPException(400, detail="missing target path for a part") - target = ensure_within_workspace(uf.filename) + target = ensure_within_workspace(relpath) await _makedirs(os.path.dirname(target)) if await _exists(target) and await _isdir(target): raise HTTPException( status_code=409, - detail=f"target exists and is a directory: {uf.filename}", + detail=f"target exists and is a directory: {relpath}", ) await _write_uploadfile_to_path(uf, target) diff --git a/src/agentscope_runtime/sandbox/client/workspace_mixin.py b/src/agentscope_runtime/sandbox/client/workspace_mixin.py index edfb446fb..c97153930 100644 --- a/src/agentscope_runtime/sandbox/client/workspace_mixin.py +++ b/src/agentscope_runtime/sandbox/client/workspace_mixin.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- from __future__ import annotations +import os +import asyncio from typing import ( IO, Any, @@ -14,6 +16,21 @@ ) +async def _afile_iter( + f: IO[bytes], + chunk_size: int = 1024 * 1024, +) -> AsyncIterator[bytes]: + """ + Convert a sync file object into an async byte iterator for + httpx.AsyncClient. + """ + while True: + chunk = await asyncio.to_thread(f.read, chunk_size) + if not chunk: + break + yield chunk + + class WorkspaceMixin: """ Mixin for /workspace router. @@ -114,33 +131,42 @@ def workspace_write_many( """ Batch upload multiple files via multipart/form-data. - files item format: - {"path": "dir/a.txt", "data": , - "content_type": "..."} # content_type optional + Server supports: + - files: List[UploadFile] = File(...) + - paths: List[str] = Form(...) + + We send `paths` explicitly to avoid relying on UploadFile.filename + preserving directory components. """ url = f"{self.base_url}/workspace/files:batch" multipart = [] + form_paths = [] + for item in files: p = item["path"] d = item["data"] ct = item.get("content_type", "application/octet-stream") + form_paths.append(("paths", p)) + if isinstance(d, str): d = d.encode("utf-8") ct = "text/plain; charset=utf-8" # requests `files=` format: (fieldname, (filename, # fileobj/bytes, content_type)) + filename = os.path.basename(p) if isinstance(d, (bytes, bytearray)): - multipart.append(("files", (p, bytes(d), ct))) + multipart.append(("files", (filename, bytes(d), ct))) else: - multipart.append(("files", (p, d, ct))) + multipart.append(("files", (filename, d, ct))) r = self._request( "post", url, files=multipart, + data=form_paths, ) r.raise_for_status() return r.json() @@ -278,9 +304,10 @@ async def workspace_write( body = bytes(data) headers["Content-Type"] = content_type else: - # file-like object; httpx will stream it - body = data + # IMPORTANT: AsyncClient cannot stream from sync file-like + # directly. headers["Content-Type"] = content_type + body = _afile_iter(data) r = await self._request( "put", @@ -299,33 +326,42 @@ async def workspace_write_many( """ Batch upload multiple files via multipart/form-data. - files item format: - {"path": "dir/a.txt", "data": , - "content_type": "..."} # content_type optional + Server supports: + - files: List[UploadFile] = File(...) + - paths: List[str] = Form(...) + + We send `paths` explicitly to avoid relying on UploadFile.filename + preserving directory components. """ url = f"{self.base_url}/workspace/files:batch" multipart = [] + form_paths = [] + for item in files: p = item["path"] d = item["data"] ct = item.get("content_type", "application/octet-stream") + form_paths.append(("paths", p)) + if isinstance(d, str): d = d.encode("utf-8") ct = "text/plain; charset=utf-8" - # httpx `files=` format: (fieldname, (filename, fileobj/bytes, - # content_type)) + filename = os.path.basename(p) + if isinstance(d, (bytes, bytearray)): - multipart.append(("files", (p, bytes(d), ct))) + multipart.append(("files", (filename, bytes(d), ct))) else: - multipart.append(("files", (p, d, ct))) + content_bytes = await asyncio.to_thread(d.read) + multipart.append(("files", (filename, content_bytes, ct))) r = await self._request( "post", url, files=multipart, + data=form_paths, ) r.raise_for_status() return r.json() diff --git a/src/agentscope_runtime/sandbox/manager/workspace_mixin.py b/src/agentscope_runtime/sandbox/manager/workspace_mixin.py index af8a92eba..1e4d7f8e0 100644 --- a/src/agentscope_runtime/sandbox/manager/workspace_mixin.py +++ b/src/agentscope_runtime/sandbox/manager/workspace_mixin.py @@ -19,6 +19,9 @@ """ from __future__ import annotations +import os +import asyncio + from typing import ( IO, Any, @@ -206,26 +209,31 @@ def fs_write_many( return client.workspace_write_many(files) multipart = [] + form_paths = [] + for item in files: p = item["path"] d = item["data"] ct = item.get("content_type", "application/octet-stream") + form_paths.append(("paths", p)) + if isinstance(d, str): d = d.encode("utf-8") ct = "text/plain; charset=utf-8" - # requests format: - # (field, (filename, fileobj_or_bytes, content_type)) + filename = os.path.basename(p) + if isinstance(d, (bytes, bytearray)): - multipart.append(("files", (p, bytes(d), ct))) + multipart.append(("files", (filename, bytes(d), ct))) else: - multipart.append(("files", (p, d, ct))) + multipart.append(("files", (filename, d, ct))) url = self.proxy_url(identity, "/workspace/files:batch") r = self.http_session.post( url, files=multipart, + data=form_paths, timeout=TIMEOUT, ) r.raise_for_status() @@ -377,6 +385,24 @@ def _is_remote_mode_async(self) -> bool: async def _runtime_client_async(self, identity: str): return await self._establish_connection_async(identity) + async def _async_iter_file( + self, + f: IO[bytes], + chunk_size: int = 1024 * 1024, + ) -> AsyncIterator[bytes]: + """ + Convert a sync file-like object into an async byte iterator. + + httpx.AsyncClient cannot accept a sync file object as `content=...`. + This helper reads the file in a worker thread to avoid blocking the + event loop. + """ + while True: + chunk = await asyncio.to_thread(f.read, chunk_size) + if not chunk: + break + yield chunk + async def fs_read_async( self, identity: str, @@ -449,8 +475,8 @@ async def fs_write_async( body = bytes(data) headers["Content-Type"] = content_type else: - body = data headers["Content-Type"] = content_type + body = self._async_iter_file(data) r = await self.httpx_client.put( url, @@ -474,24 +500,32 @@ async def fs_write_many_async( return await client.workspace_write_many(files) multipart = [] + form_paths = [] + for item in files: p = item["path"] d = item["data"] ct = item.get("content_type", "application/octet-stream") + form_paths.append(("paths", p)) + if isinstance(d, str): d = d.encode("utf-8") ct = "text/plain; charset=utf-8" + filename = os.path.basename(p) + if isinstance(d, (bytes, bytearray)): - multipart.append(("files", (p, bytes(d), ct))) + multipart.append(("files", (filename, bytes(d), ct))) else: - multipart.append(("files", (p, d, ct))) + content_bytes = await asyncio.to_thread(d.read) + multipart.append(("files", (filename, content_bytes, ct))) url = self.proxy_url(identity, "/workspace/files:batch") r = await self.httpx_client.post( url, files=multipart, + data=form_paths, ) r.raise_for_status() return r.json() diff --git a/tests/sandbox/test_sandbox.py b/tests/sandbox/test_sandbox.py index 4069d3798..8d190c654 100644 --- a/tests/sandbox/test_sandbox.py +++ b/tests/sandbox/test_sandbox.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # pylint: disable=redefined-outer-name, unused-argument, too-many-branches, too-many-statements, consider-using-with, subprocess-popen-preexec-fn # noqa: E501 import os +import tempfile import signal import subprocess import time @@ -189,77 +190,335 @@ async def test_remote_sandbox(env): @pytest.mark.asyncio async def test_local_sandbox_fs_async(env): + """ + Full coverage test for SandboxFSAsync facade: + - mkdir_async + - write_async (str / bytes / file-like stream) + - read_async (text / bytes / stream) + - exists_async + - list_async + - move_async + - remove_async + - write_many_async + - write_from_path_async + """ async with BaseSandboxAsync() as box: - # create dir + write + read(text) - ok = await box.fs.mkdir_async("dir_async") + base_dir = "dir_async" + + # ---- mkdir ---- + ok = await box.fs.mkdir_async(base_dir) assert isinstance(ok, bool) - r1 = await box.fs.write_async("dir_async/a.txt", "hello async") + # ---- write str + read text ---- + r1 = await box.fs.write_async(f"{base_dir}/a.txt", "hello async") assert isinstance(r1, dict) - txt = await box.fs.read_async("dir_async/a.txt", fmt="text") + txt = await box.fs.read_async(f"{base_dir}/a.txt", fmt="text") assert txt == "hello async" - # exists + list - assert await box.fs.exists_async("dir_async/a.txt") is True - items = await box.fs.list_async("dir_async", depth=2) + # ---- exists ---- + assert await box.fs.exists_async(f"{base_dir}/a.txt") is True + assert await box.fs.exists_async(f"{base_dir}/not-exist.txt") is False + + # ---- list ---- + items = await box.fs.list_async(base_dir, depth=10) assert isinstance(items, list) - # streaming download - stream = await box.fs.read_async("dir_async/a.txt", fmt="stream") - assert hasattr(stream, "__aiter__") - buf = b"" - async for chunk in stream: - buf += chunk - assert buf == b"hello async" + # ---- write bytes + read bytes ---- + payload_b = b"\x00\x01hello-bytes\xff" + r2 = await box.fs.write_async( + f"{base_dir}/b.bin", + payload_b, + content_type="application/octet-stream", + ) + assert isinstance(r2, dict) - # move + remove - mv = await box.fs.move_async("dir_async/a.txt", "dir_async/b.txt") + got_b = await box.fs.read_async(f"{base_dir}/b.bin", fmt="bytes") + assert isinstance(got_b, (bytes, bytearray)) + assert bytes(got_b) == payload_b + + # ---- stream write (file-like) + read bytes + read stream ---- + stream_payload = b"stream-upload-content-" * 1024 # ~22KB + with tempfile.NamedTemporaryFile("wb", delete=False) as tf: + tmp_path = tf.name + tf.write(stream_payload) + + try: + with open(tmp_path, "rb") as f: + r3 = await box.fs.write_async( + f"{base_dir}/c.bin", + f, # file-like streaming upload + content_type="application/octet-stream", + ) + assert isinstance(r3, dict) + + got_stream_b = await box.fs.read_async( + f"{base_dir}/c.bin", + fmt="bytes", + ) + assert bytes(got_stream_b) == stream_payload + + stream = await box.fs.read_async(f"{base_dir}/c.bin", fmt="stream") + buf = b"" + async for chunk in stream: + buf += chunk + assert buf == stream_payload + finally: + try: + os.remove(tmp_path) + except Exception: + pass + + # ---- move ---- + mv = await box.fs.move_async( + f"{base_dir}/a.txt", + f"{base_dir}/a_moved.txt", + ) assert isinstance(mv, dict) - assert await box.fs.exists_async("dir_async/b.txt") is True + assert await box.fs.exists_async(f"{base_dir}/a.txt") is False + assert await box.fs.exists_async(f"{base_dir}/a_moved.txt") is True + + # ---- write_many ---- + # include str + bytes (keep it small and deterministic) + batch_payload = b"batch-bytes-123" + batch = [ + {"path": f"{base_dir}/batch1.txt", "data": "batch hello"}, + { + "path": f"{base_dir}/batch2.bin", + "data": batch_payload, + "content_type": "application/octet-stream", + }, + ] + res_batch = await box.fs.write_many_async(batch) + assert isinstance(res_batch, list) + + assert await box.fs.exists_async(f"{base_dir}/batch1.txt") is True + assert ( + await box.fs.read_async( + f"{base_dir}/batch1.txt", + fmt="text", + ) + == "batch hello" + ) + assert ( + bytes( + await box.fs.read_async( + f"{base_dir}/batch2.bin", + fmt="bytes", + ), + ) + == batch_payload + ) + + # ---- write_from_path ---- + with tempfile.NamedTemporaryFile("wb", delete=False) as tf2: + tmp2 = tf2.name + tf2.write(b"from-local-file-async") + + try: + r4 = await box.fs.write_from_path_async( + f"{base_dir}/from_path.txt", + tmp2, + content_type="text/plain; charset=utf-8", + ) + assert isinstance(r4, dict) + + assert ( + await box.fs.read_async( + f"{base_dir}/from_path.txt", + fmt="text", + ) + == "from-local-file-async" + ) + finally: + try: + os.remove(tmp2) + except Exception: + pass + + # ---- remove (file) ---- + await box.fs.remove_async(f"{base_dir}/a_moved.txt") + assert await box.fs.exists_async(f"{base_dir}/a_moved.txt") is False - await box.fs.remove_async("dir_async/b.txt") - assert await box.fs.exists_async("dir_async/b.txt") is False + # ---- remove (directory) ---- + for p in [ + f"{base_dir}/b.bin", + f"{base_dir}/c.bin", + f"{base_dir}/batch1.txt", + f"{base_dir}/batch2.bin", + f"{base_dir}/from_path.txt", + ]: + if await box.fs.exists_async(p): + await box.fs.remove_async(p) + + try: + await box.fs.remove_async(base_dir) + except Exception: + pass def test_local_sandbox_fs(env, tmp_path): + """ + Full coverage test for SandboxFS facade (sync): + - mkdir + - write (str / bytes / file-like stream) + - read (text / bytes / stream) + - exists + - list + - move + - remove + - write_many + - write_from_path + """ with BaseSandbox() as box: - # create dir + write + read(text) - ok = box.fs.mkdir("dir") + base_dir = "dir_sync" + + # ---- mkdir ---- + ok = box.fs.mkdir(base_dir) assert isinstance(ok, bool) - r1 = box.fs.write("dir/a.txt", "hello") + # ---- write str + read text ---- + r1 = box.fs.write(f"{base_dir}/a.txt", "hello sync") assert isinstance(r1, dict) - txt = box.fs.read("dir/a.txt", fmt="text") - assert txt == "hello" + txt = box.fs.read(f"{base_dir}/a.txt", fmt="text") + assert txt == "hello sync" + + # ---- exists ---- + assert box.fs.exists(f"{base_dir}/a.txt") is True + assert box.fs.exists(f"{base_dir}/not-exist.txt") is False - # exists + list - assert box.fs.exists("dir/a.txt") is True - items = box.fs.list("dir", depth=2) + # ---- list ---- + items = box.fs.list(base_dir, depth=10) assert isinstance(items, list) - # streaming download - out = b"" - for chunk in box.fs.read("dir/a.txt", fmt="stream"): - out += chunk - assert out == b"hello" + # ---- write bytes + read bytes ---- + payload_b = b"\x00\x01hello-bytes\xff" + r2 = box.fs.write( + f"{base_dir}/b.bin", + payload_b, + content_type="application/octet-stream", + ) + assert isinstance(r2, dict) + + got_b = box.fs.read(f"{base_dir}/b.bin", fmt="bytes") + assert isinstance(got_b, (bytes, bytearray)) + assert bytes(got_b) == payload_b + + # ---- stream write (file-like) + read bytes + read stream ---- + stream_payload = b"stream-upload-content-" * 1024 # ~22KB + with tempfile.NamedTemporaryFile("wb", delete=False) as tf: + tmp_path = tf.name + tf.write(stream_payload) + + try: + with open(tmp_path, "rb") as f: + r3 = box.fs.write( + f"{base_dir}/c.bin", + f, # file-like streaming upload + content_type="application/octet-stream", + ) + assert isinstance(r3, dict) - # write_from_path (stream upload) - local_file = tmp_path / "local.txt" - local_file.write_text("from local", encoding="utf-8") + got_stream_b = box.fs.read(f"{base_dir}/c.bin", fmt="bytes") + assert bytes(got_stream_b) == stream_payload - r2 = box.fs.write_from_path("dir/from_local.txt", str(local_file)) - assert isinstance(r2, dict) - assert box.fs.read("dir/from_local.txt", fmt="text") == "from local" + buf = b"" + for chunk in box.fs.read(f"{base_dir}/c.bin", fmt="stream"): + buf += chunk + assert buf == stream_payload + finally: + try: + os.remove(tmp_path) + except Exception: + pass - # move + remove - mv = box.fs.move("dir/a.txt", "dir/b.txt") + # ---- move ---- + mv = box.fs.move( + f"{base_dir}/a.txt", + f"{base_dir}/a_moved.txt", + ) assert isinstance(mv, dict) - assert box.fs.exists("dir/b.txt") is True + assert box.fs.exists(f"{base_dir}/a.txt") is False + assert box.fs.exists(f"{base_dir}/a_moved.txt") is True + + # ---- write_many ---- + batch_payload = b"batch-bytes-123" + batch = [ + {"path": f"{base_dir}/batch1.txt", "data": "batch hello"}, + { + "path": f"{base_dir}/batch2.bin", + "data": batch_payload, + "content_type": "application/octet-stream", + }, + ] + res_batch = box.fs.write_many(batch) + assert isinstance(res_batch, list) + + assert box.fs.exists(f"{base_dir}/batch1.txt") is True + assert ( + box.fs.read( + f"{base_dir}/batch1.txt", + fmt="text", + ) + == "batch hello" + ) + assert ( + bytes( + box.fs.read( + f"{base_dir}/batch2.bin", + fmt="bytes", + ), + ) + == batch_payload + ) + + # ---- write_from_path ---- + with tempfile.NamedTemporaryFile("wb", delete=False) as tf2: + tmp2 = tf2.name + tf2.write(b"from-local-file-sync") + + try: + r4 = box.fs.write_from_path( + f"{base_dir}/from_path.txt", + tmp2, + content_type="text/plain; charset=utf-8", + ) + assert isinstance(r4, dict) + + assert ( + box.fs.read( + f"{base_dir}/from_path.txt", + fmt="text", + ) + == "from-local-file-sync" + ) + finally: + try: + os.remove(tmp2) + except Exception: + pass - box.fs.remove("dir/b.txt") - assert box.fs.exists("dir/b.txt") is False + # ---- remove (file) ---- + box.fs.remove(f"{base_dir}/a_moved.txt") + assert box.fs.exists(f"{base_dir}/a_moved.txt") is False + + # ---- remove (directory) ---- + for p in [ + f"{base_dir}/b.bin", + f"{base_dir}/c.bin", + f"{base_dir}/batch1.txt", + f"{base_dir}/batch2.bin", + f"{base_dir}/from_path.txt", + ]: + if box.fs.exists(p): + box.fs.remove(p) + + # directory delete policy may vary + try: + box.fs.remove(base_dir) + except Exception: + pass if __name__ == "__main__": From 863129d0e505167424eaeab88acb8ba34fac8b94 Mon Sep 17 00:00:00 2001 From: raykkk Date: Tue, 27 Jan 2026 15:41:02 +0800 Subject: [PATCH 14/19] fix ut --- .../sandbox/client/workspace_mixin.py | 93 +++++++++++++++---- 1 file changed, 75 insertions(+), 18 deletions(-) diff --git a/src/agentscope_runtime/sandbox/client/workspace_mixin.py b/src/agentscope_runtime/sandbox/client/workspace_mixin.py index c97153930..657db3e14 100644 --- a/src/agentscope_runtime/sandbox/client/workspace_mixin.py +++ b/src/agentscope_runtime/sandbox/client/workspace_mixin.py @@ -3,6 +3,7 @@ import os import asyncio +import secrets from typing import ( IO, Any, @@ -31,6 +32,49 @@ async def _afile_iter( yield chunk +def _encode_multipart_formdata(fields: List[tuple], boundary: str) -> bytes: + """ + fields: list of + - ("paths", "dir/a.txt") # simple field + - ("files", (filename, content_bytes, content_type)) + """ + lines: List[bytes] = [] + b = boundary.encode() + + for name, value in fields: + lines.append(b"--" + b) + + if isinstance(value, tuple): + filename, content, content_type = value + if isinstance(content, bytearray): + content = bytes(content) + if not isinstance(content, (bytes,)): + raise TypeError( + f"file content must be bytes, got {type(content)}", + ) + + lines.append( + f'Content-Disposition: form-data; name="{name}"; filename=' + f'"{filename}"'.encode(), + ) + lines.append(f"Content-Type: {content_type}".encode()) + lines.append(b"") # header/body separator + lines.append(content) + else: + # normal text field + if not isinstance(value, str): + value = str(value) + lines.append( + f'Content-Disposition: form-data; name="{name}"'.encode(), + ) + lines.append(b"") + lines.append(value.encode("utf-8")) + + lines.append(b"--" + b + b"--") + lines.append(b"") + return b"\r\n".join(lines) + + class WorkspaceMixin: """ Mixin for /workspace router. @@ -335,34 +379,47 @@ async def workspace_write_many( """ url = f"{self.base_url}/workspace/files:batch" - multipart = [] - form_paths = [] + fields: List[tuple] = [] for item in files: p = item["path"] d = item["data"] ct = item.get("content_type", "application/octet-stream") - form_paths.append(("paths", p)) + # Form field: paths (repeatable) + fields.append(("paths", p)) + # File field: files (repeatable) if isinstance(d, str): - d = d.encode("utf-8") + content = d.encode("utf-8") ct = "text/plain; charset=utf-8" - - filename = os.path.basename(p) - - if isinstance(d, (bytes, bytearray)): - multipart.append(("files", (filename, bytes(d), ct))) + elif isinstance(d, (bytes, bytearray)): + content = bytes(d) else: - content_bytes = await asyncio.to_thread(d.read) - multipart.append(("files", (filename, content_bytes, ct))) - - r = await self._request( - "post", - url, - files=multipart, - data=form_paths, - ) + if not hasattr(d, "read"): + raise TypeError( + f"files[].data must be str/bytes/bytearray or " + f"file-like, got {type(d)}", + ) + content = await asyncio.to_thread(d.read) + if isinstance(content, str): + content = content.encode("utf-8") + if not isinstance(content, (bytes, bytearray)): + raise TypeError( + f"file-like .read() must return bytes, got" + f" {type(content)}", + ) + content = bytes(content) + + filename = os.path.basename(p) or "file" + fields.append(("files", (filename, content, ct))) + + boundary = "----agentscope-" + secrets.token_hex(16) + body = _encode_multipart_formdata(fields, boundary) + + headers = {"Content-Type": f"multipart/form-data; boundary={boundary}"} + + r = await self._request("post", url, content=body, headers=headers) r.raise_for_status() return r.json() From 11e8bd26e473d1394b5b5148c899935fd6b0657f Mon Sep 17 00:00:00 2001 From: raykkk Date: Tue, 27 Jan 2026 15:53:05 +0800 Subject: [PATCH 15/19] fix ut --- .../sandbox/client/workspace_mixin.py | 67 ++++++++++++------- 1 file changed, 42 insertions(+), 25 deletions(-) diff --git a/src/agentscope_runtime/sandbox/client/workspace_mixin.py b/src/agentscope_runtime/sandbox/client/workspace_mixin.py index 657db3e14..52c8198b6 100644 --- a/src/agentscope_runtime/sandbox/client/workspace_mixin.py +++ b/src/agentscope_runtime/sandbox/client/workspace_mixin.py @@ -179,39 +179,53 @@ def workspace_write_many( - files: List[UploadFile] = File(...) - paths: List[str] = Form(...) - We send `paths` explicitly to avoid relying on UploadFile.filename - preserving directory components. + Implementation note: + We manually build the multipart/form-data payload (with an explicit + boundary) to ensure `paths` (repeatable form fields) are parsed + consistently by FastAPI and to avoid client-library multipart quirks. """ url = f"{self.base_url}/workspace/files:batch" - multipart = [] - form_paths = [] + fields: List[tuple] = [] for item in files: p = item["path"] d = item["data"] ct = item.get("content_type", "application/octet-stream") - form_paths.append(("paths", p)) + # repeatable form field + fields.append(("paths", p)) if isinstance(d, str): - d = d.encode("utf-8") + content = d.encode("utf-8") ct = "text/plain; charset=utf-8" - - # requests `files=` format: (fieldname, (filename, - # fileobj/bytes, content_type)) - filename = os.path.basename(p) - if isinstance(d, (bytes, bytearray)): - multipart.append(("files", (filename, bytes(d), ct))) + elif isinstance(d, (bytes, bytearray)): + content = bytes(d) else: - multipart.append(("files", (filename, d, ct))) + if not hasattr(d, "read"): + raise TypeError( + f"files[].data must be str/bytes/bytearray or " + f"file-like, got {type(d)}", + ) + content = d.read() + if isinstance(content, str): + content = content.encode("utf-8") + if not isinstance(content, (bytes, bytearray)): + raise TypeError( + f"file-like .read() must return bytes, got" + f" {type(content)}", + ) + content = bytes(content) - r = self._request( - "post", - url, - files=multipart, - data=form_paths, - ) + filename = os.path.basename(p) or "file" + fields.append(("files", (filename, content, ct))) + + boundary = "----agentscope-" + secrets.token_hex(16) + body = _encode_multipart_formdata(fields, boundary) + + headers = {"Content-Type": f"multipart/form-data; boundary={boundary}"} + + r = self._request("post", url, data=body, headers=headers) r.raise_for_status() return r.json() @@ -368,15 +382,18 @@ async def workspace_write_many( files: List[Dict[str, Any]], ) -> List[Dict[str, Any]]: """ - Batch upload multiple files via multipart/form-data. + Encode fields into a multipart/form-data body. - Server supports: - - files: List[UploadFile] = File(...) - - paths: List[str] = Form(...) + Args: + fields: [("paths", "dir/a.txt"), ("files", (filename, + content_bytes, content_type)), ...] + boundary: multipart boundary (without leading --) - We send `paths` explicitly to avoid relying on UploadFile.filename - preserving directory components. + Returns: + The full HTTP request body as bytes. Caller must set: + Content-Type: multipart/form-data; boundary={boundary} """ + url = f"{self.base_url}/workspace/files:batch" fields: List[tuple] = [] From d23891ac8220475e0a2709ad9d10ff6a38f6c382 Mon Sep 17 00:00:00 2001 From: raykkk Date: Tue, 27 Jan 2026 17:49:16 +0800 Subject: [PATCH 16/19] remote mode --- .../sandbox/manager/sandbox_manager.py | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/src/agentscope_runtime/sandbox/manager/sandbox_manager.py b/src/agentscope_runtime/sandbox/manager/sandbox_manager.py index 39649f7e7..16d7bbc4f 100644 --- a/src/agentscope_runtime/sandbox/manager/sandbox_manager.py +++ b/src/agentscope_runtime/sandbox/manager/sandbox_manager.py @@ -19,6 +19,9 @@ import shortuuid import httpx +from fastapi import Request, HTTPException +from fastapi.responses import StreamingResponse + from .heartbeat_mixin import HeartbeatMixin, touch_session from .workspace_mixin import WorkspaceFSMixin from ..constant import TIMEOUT @@ -1644,3 +1647,117 @@ def scan_released_cleanup_once(self, max_delete: int = 200) -> dict: ) return result + + @staticmethod + def _filter_hop_by_hop_headers(headers: Dict[str, str]) -> Dict[str, str]: + hop_by_hop = { + "host", + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + } + return { + k: v for k, v in headers.items() if k.lower() not in hop_by_hop + } + + async def proxy_to_runtime( + self, + identity: str, + path: str, + request: Request, + ): + # 1) locate runtime + try: + info = self.get_info(identity) + client = self._establish_connection(identity) + client.check_health() + except Exception as e: + raise HTTPException( + status_code=404, + detail=f"sandbox not found: {identity}", + ) from e + + cm = ContainerModel(**info) + if not cm.url: + raise HTTPException( + status_code=404, + detail="runtime url not found", + ) + + # 2) build target url (+ query) + target_url = f"{cm.url.rstrip('/')}/fastapi/{path.lstrip('/')}" + + if request.url.query: + target_url = f"{target_url}?{request.url.query}" + + print(f"--{target_url}---") + + # 3) forward headers + headers = self._filter_hop_by_hop_headers(dict(request.headers)) + if cm.runtime_token: + headers["Authorization"] = f"Bearer {cm.runtime_token}" + + hop_by_hop = { + "host", + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + } + + client = httpx.AsyncClient(timeout=None) + upstream = None + try: + req = client.build_request( + method=request.method, + url=target_url, + headers=headers, + content=request.stream(), # stream request body to runtime + ) + upstream = await client.send(req, stream=True) + + resp_headers = { + k: v + for k, v in upstream.headers.items() + if k.lower() not in hop_by_hop + } + media_type = upstream.headers.get("content-type") + + async def body_iter(): + try: + async for chunk in upstream.aiter_raw(): + if chunk: + yield chunk + finally: + try: + await upstream.aclose() + finally: + await client.aclose() + + return StreamingResponse( + body_iter(), + status_code=upstream.status_code, + headers=resp_headers, + media_type=media_type, + ) + + except httpx.RequestError as e: + if upstream is not None: + try: + await upstream.aclose() + except Exception: + pass + await client.aclose() + raise HTTPException( + status_code=502, + detail=f"proxy upstream error: {e}", + ) from e From acfdb6f08cedbd960bf6ab8ec495fb6fdd561131 Mon Sep 17 00:00:00 2001 From: raykkk Date: Tue, 27 Jan 2026 17:58:51 +0800 Subject: [PATCH 17/19] fix --- .../sandbox/manager/sandbox_manager.py | 8 +-- .../sandbox/manager/workspace_mixin.py | 70 ++++++++++++++----- 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/src/agentscope_runtime/sandbox/manager/sandbox_manager.py b/src/agentscope_runtime/sandbox/manager/sandbox_manager.py index 16d7bbc4f..f3a6db01b 100644 --- a/src/agentscope_runtime/sandbox/manager/sandbox_manager.py +++ b/src/agentscope_runtime/sandbox/manager/sandbox_manager.py @@ -577,9 +577,9 @@ def cleanup(self): logger.error(f"Error cleaning up container {key}: {e}") @remote_wrapper_async() - async def cleanup_async(self, *args, **kwargs): + async def cleanup_async(self): """Async wrapper for cleanup().""" - return await asyncio.to_thread(self.cleanup, *args, **kwargs) + return await asyncio.to_thread(self.cleanup) @remote_wrapper() def create_from_pool(self, sandbox_type=None, meta: Optional[Dict] = None): @@ -1026,9 +1026,9 @@ def release(self, identity): return False @remote_wrapper_async() - async def release_async(self, *args, **kwargs): + async def release_async(self, identity: str): """Async wrapper for release().""" - return await asyncio.to_thread(self.release, *args, **kwargs) + return await asyncio.to_thread(self.release, identity) @remote_wrapper() def start(self, identity): diff --git a/src/agentscope_runtime/sandbox/manager/workspace_mixin.py b/src/agentscope_runtime/sandbox/manager/workspace_mixin.py index 1e4d7f8e0..5ff5b487b 100644 --- a/src/agentscope_runtime/sandbox/manager/workspace_mixin.py +++ b/src/agentscope_runtime/sandbox/manager/workspace_mixin.py @@ -20,6 +20,7 @@ from __future__ import annotations import os +import secrets import asyncio from typing import ( @@ -37,6 +38,42 @@ from ..constant import TIMEOUT +def _encode_multipart_formdata(fields: List[tuple], boundary: str) -> bytes: + lines: List[bytes] = [] + b = boundary.encode() + + for name, value in fields: + lines.append(b"--" + b) + if isinstance(value, tuple): + filename, content, content_type = value + if isinstance(content, bytearray): + content = bytes(content) + if not isinstance(content, (bytes,)): + raise TypeError( + f"file content must be bytes, got {type(content)}", + ) + + lines.append( + f'Content-Disposition: form-data; name="{name}"; filename=' + f'"{filename}"'.encode(), + ) + lines.append(f"Content-Type: {content_type}".encode()) + lines.append(b"") + lines.append(content) + else: + if not isinstance(value, str): + value = str(value) + lines.append( + f'Content-Disposition: form-data; name="{name}"'.encode(), + ) + lines.append(b"") + lines.append(value.encode("utf-8")) + + lines.append(b"--" + b + b"--") + lines.append(b"") + return b"\r\n".join(lines) + + class ProxyBaseMixin: """ Base helper to build Manager(Server) proxy URL in remote mode. @@ -499,34 +536,35 @@ async def fs_write_many_async( client = await self._runtime_client_async(identity) return await client.workspace_write_many(files) - multipart = [] - form_paths = [] + url = self.proxy_url(identity, "/workspace/files:batch") + fields: List[tuple] = [] for item in files: p = item["path"] d = item["data"] ct = item.get("content_type", "application/octet-stream") - form_paths.append(("paths", p)) + fields.append(("paths", p)) if isinstance(d, str): - d = d.encode("utf-8") + content = d.encode("utf-8") ct = "text/plain; charset=utf-8" + elif isinstance(d, (bytes, bytearray)): + content = bytes(d) + else: + content = await asyncio.to_thread(d.read) + if isinstance(content, str): + content = content.encode("utf-8") + content = bytes(content) - filename = os.path.basename(p) + filename = os.path.basename(p) or "file" + fields.append(("files", (filename, content, ct))) - if isinstance(d, (bytes, bytearray)): - multipart.append(("files", (filename, bytes(d), ct))) - else: - content_bytes = await asyncio.to_thread(d.read) - multipart.append(("files", (filename, content_bytes, ct))) + boundary = "----agentscope-" + secrets.token_hex(16) + body = _encode_multipart_formdata(fields, boundary) + headers = {"Content-Type": f"multipart/form-data; boundary={boundary}"} - url = self.proxy_url(identity, "/workspace/files:batch") - r = await self.httpx_client.post( - url, - files=multipart, - data=form_paths, - ) + r = await self.httpx_client.post(url, content=body, headers=headers) r.raise_for_status() return r.json() From 2aafc5f487da49137803e252d134afadf003ffd9 Mon Sep 17 00:00:00 2001 From: raykkk Date: Wed, 11 Feb 2026 20:20:08 +0800 Subject: [PATCH 18/19] update --- .../sandbox/client/workspace_mixin.py | 13 +++++++++++- .../sandbox/manager/workspace_mixin.py | 9 ++++++++- tests/sandbox/test_sandbox.py | 20 +++++++++++++------ 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/agentscope_runtime/sandbox/client/workspace_mixin.py b/src/agentscope_runtime/sandbox/client/workspace_mixin.py index 52c8198b6..bd1951b58 100644 --- a/src/agentscope_runtime/sandbox/client/workspace_mixin.py +++ b/src/agentscope_runtime/sandbox/client/workspace_mixin.py @@ -436,7 +436,18 @@ async def workspace_write_many( headers = {"Content-Type": f"multipart/form-data; boundary={boundary}"} - r = await self._request("post", url, content=body, headers=headers) + # AsyncClient requires an async stream; passing raw bytes can be + # encoded as a sync stream in some httpx versions and raise + # RuntimeError. + async def _body_chunks() -> AsyncIterator[bytes]: + yield body + + r = await self._request( + "post", + url, + content=_body_chunks(), + headers=headers, + ) r.raise_for_status() return r.json() diff --git a/src/agentscope_runtime/sandbox/manager/workspace_mixin.py b/src/agentscope_runtime/sandbox/manager/workspace_mixin.py index 5ff5b487b..f0a895893 100644 --- a/src/agentscope_runtime/sandbox/manager/workspace_mixin.py +++ b/src/agentscope_runtime/sandbox/manager/workspace_mixin.py @@ -564,7 +564,14 @@ async def fs_write_many_async( body = _encode_multipart_formdata(fields, boundary) headers = {"Content-Type": f"multipart/form-data; boundary={boundary}"} - r = await self.httpx_client.post(url, content=body, headers=headers) + async def _body_chunks() -> AsyncIterator[bytes]: + yield body + + r = await self.httpx_client.post( + url, + content=_body_chunks(), + headers=headers, + ) r.raise_for_status() return r.json() diff --git a/tests/sandbox/test_sandbox.py b/tests/sandbox/test_sandbox.py index 8d190c654..320290622 100644 --- a/tests/sandbox/test_sandbox.py +++ b/tests/sandbox/test_sandbox.py @@ -189,7 +189,7 @@ async def test_remote_sandbox(env): @pytest.mark.asyncio -async def test_local_sandbox_fs_async(env): +async def test_local_sandbox_fs_async(env, tmp_path): """ Full coverage test for SandboxFSAsync facade: - mkdir_async @@ -240,11 +240,11 @@ async def test_local_sandbox_fs_async(env): # ---- stream write (file-like) + read bytes + read stream ---- stream_payload = b"stream-upload-content-" * 1024 # ~22KB with tempfile.NamedTemporaryFile("wb", delete=False) as tf: - tmp_path = tf.name + tmp_file_path = tf.name tf.write(stream_payload) try: - with open(tmp_path, "rb") as f: + with open(tmp_file_path, "rb") as f: r3 = await box.fs.write_async( f"{base_dir}/c.bin", f, # file-like streaming upload @@ -291,6 +291,10 @@ async def test_local_sandbox_fs_async(env): ] res_batch = await box.fs.write_many_async(batch) assert isinstance(res_batch, list) + assert len(res_batch) == 2, ( + f"write_many_async should return 2 entries, got {len(res_batch)}: " + f"{res_batch}" + ) assert await box.fs.exists_async(f"{base_dir}/batch1.txt") is True assert ( @@ -408,11 +412,11 @@ def test_local_sandbox_fs(env, tmp_path): # ---- stream write (file-like) + read bytes + read stream ---- stream_payload = b"stream-upload-content-" * 1024 # ~22KB with tempfile.NamedTemporaryFile("wb", delete=False) as tf: - tmp_path = tf.name + tmp_file_path = tf.name tf.write(stream_payload) try: - with open(tmp_path, "rb") as f: + with open(tmp_file_path, "rb") as f: r3 = box.fs.write( f"{base_dir}/c.bin", f, # file-like streaming upload @@ -429,7 +433,7 @@ def test_local_sandbox_fs(env, tmp_path): assert buf == stream_payload finally: try: - os.remove(tmp_path) + os.remove(tmp_file_path) except Exception: pass @@ -454,6 +458,10 @@ def test_local_sandbox_fs(env, tmp_path): ] res_batch = box.fs.write_many(batch) assert isinstance(res_batch, list) + assert len(res_batch) == 2, ( + f"write_many should return 2 entries, got {len(res_batch)}: " + f"{res_batch}" + ) assert box.fs.exists(f"{base_dir}/batch1.txt") is True assert ( From ba45229a99c82c031d4fb1cfd89004444fa4aef7 Mon Sep 17 00:00:00 2001 From: raykkk Date: Wed, 11 Feb 2026 20:43:15 +0800 Subject: [PATCH 19/19] update --- cookbook/_toc.yml | 2 ++ pyproject.toml | 2 +- .../common/container_clients/agentrun_client.py | 10 +++++----- .../common/container_clients/fc_client.py | 10 +++++----- src/agentscope_runtime/version.py | 2 +- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/cookbook/_toc.yml b/cookbook/_toc.yml index 1bc8bac10..81b8656a6 100644 --- a/cookbook/_toc.yml +++ b/cookbook/_toc.yml @@ -12,6 +12,7 @@ parts: - file: en/sandbox/sandbox.md sections: - file: en/sandbox/sandbox_service.md + - file: en/sandbox/sandbox_fs.md - file: en/sandbox/advanced.md - file: en/sandbox/training_sandbox.md - file: en/sandbox/troubleshooting.md @@ -71,6 +72,7 @@ parts: - file: zh/sandbox/sandbox.md sections: - file: zh/sandbox/sandbox_service.md + - file: zh/sandbox/sandbox_fs.md - file: zh/sandbox/advanced.md - file: zh/sandbox/training_sandbox.md - file: zh/sandbox/troubleshooting.md diff --git a/pyproject.toml b/pyproject.toml index d6efc04fe..d7193b054 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agentscope-runtime" -version = "1.1.0b3" +version = "1.1.0b4" description = "A production-ready runtime framework for agent applications, providing secure sandboxed execution environments and scalable deployment solutions with multi-framework support." readme = "README.md" requires-python = ">=3.10" diff --git a/src/agentscope_runtime/common/container_clients/agentrun_client.py b/src/agentscope_runtime/common/container_clients/agentrun_client.py index 8ffb693f0..e8a7f0f7b 100644 --- a/src/agentscope_runtime/common/container_clients/agentrun_client.py +++ b/src/agentscope_runtime/common/container_clients/agentrun_client.py @@ -1016,15 +1016,15 @@ def _replace_agent_runtime_images(self, image: str) -> str: """ replacement_map = { "agentscope/runtime-sandbox-base": "serverless-registry.cn-hangzhou.cr.aliyuncs.com/functionai" # noqa: E501 - "/agentscope_runtime-sandbox-base:20260106", + "/agentscope_runtime-sandbox-base:20260127", "agentscope/runtime-sandbox-browser": "serverless-registry.cn-hangzhou.cr.aliyuncs.com/functionai" # noqa: E501 - "/agentscope_runtime-sandbox-browser:20260106", + "/agentscope_runtime-sandbox-browser:20260127", "agentscope/runtime-sandbox-filesystem": "serverless-registry.cn-hangzhou.cr.aliyuncs.com/functionai" # noqa: E501 - "/agentscope_runtime-sandbox-filesystem:20260106", + "/agentscope_runtime-sandbox-filesystem:20260127", "agentscope/runtime-sandbox-gui": "serverless-registry.cn-hangzhou.cr.aliyuncs.com/functionai" # noqa: E501 - "/agentscope_runtime-sandbox-gui:20260106", + "/agentscope_runtime-sandbox-gui:20260127", "agentscope/runtime-sandbox-mobile": "serverless-registry.cn-hangzhou.cr.aliyuncs.com/functionai" # noqa: E501 - "/agentscope_runtime-sandbox-mobile:20251217", + "/agentscope_runtime-sandbox-mobile:20260206", } if ":" in image: diff --git a/src/agentscope_runtime/common/container_clients/fc_client.py b/src/agentscope_runtime/common/container_clients/fc_client.py index b90d2bd7a..70e4137b6 100644 --- a/src/agentscope_runtime/common/container_clients/fc_client.py +++ b/src/agentscope_runtime/common/container_clients/fc_client.py @@ -766,15 +766,15 @@ def _replace_fc_images(self, image: str) -> str: """ replacement_map = { "agentscope/runtime-sandbox-base": "serverless-registry.cn-hangzhou.cr.aliyuncs.com/functionai" # noqa: E501 - "/agentscope_runtime-sandbox-base:20260106", + "/agentscope_runtime-sandbox-base:20260127", "agentscope/runtime-sandbox-browser": "serverless-registry.cn-hangzhou.cr.aliyuncs.com/functionai" # noqa: E501 - "/agentscope_runtime-sandbox-browser:20260106", + "/agentscope_runtime-sandbox-browser:20260127", "agentscope/runtime-sandbox-filesystem": "serverless-registry.cn-hangzhou.cr.aliyuncs.com/functionai" # noqa: E501 - "/agentscope_runtime-sandbox-filesystem:20260106", + "/agentscope_runtime-sandbox-filesystem:20260127", "agentscope/runtime-sandbox-gui": "serverless-registry.cn-hangzhou.cr.aliyuncs.com/functionai" # noqa: E501 - "/agentscope_runtime-sandbox-gui:20260106", + "/agentscope_runtime-sandbox-gui:20260127", "agentscope/runtime-sandbox-mobile": "serverless-registry.cn-hangzhou.cr.aliyuncs.com/functionai" # noqa: E501 - "/agentscope_runtime-sandbox-mobile:20251217", + "/agentscope_runtime-sandbox-mobile:20260206", } if ":" in image: diff --git a/src/agentscope_runtime/version.py b/src/agentscope_runtime/version.py index 07f7df1c0..50c621c6b 100644 --- a/src/agentscope_runtime/version.py +++ b/src/agentscope_runtime/version.py @@ -1,2 +1,2 @@ # -*- coding: utf-8 -*- -__version__ = "v1.1.0b3" +__version__ = "v1.1.0b4"