diff --git a/src/agents/extensions/sandbox/modal/sandbox.py b/src/agents/extensions/sandbox/modal/sandbox.py index bb9fbc1c4b..a83e0f2895 100644 --- a/src/agents/extensions/sandbox/modal/sandbox.py +++ b/src/agents/extensions/sandbox/modal/sandbox.py @@ -158,6 +158,7 @@ class ModalSandboxClientOptions(BaseSandboxClientOptions): timeout: int = 300 # Lifetime of a sandbox from creation in seconds, defaults to 5 minutes use_sleep_cmd: bool = True image_builder_version: str | None = _DEFAULT_IMAGE_BUILDER_VERSION + idle_timeout: int | None = None def __init__( self, @@ -171,6 +172,7 @@ def __init__( timeout: int = 300, # 5 minutes use_sleep_cmd: bool = True, image_builder_version: str | None = _DEFAULT_IMAGE_BUILDER_VERSION, + idle_timeout: int | None = None, *, type: Literal["modal"] = "modal", ) -> None: @@ -186,6 +188,7 @@ def __init__( timeout=timeout, use_sleep_cmd=use_sleep_cmd, image_builder_version=image_builder_version, + idle_timeout=idle_timeout, ) @@ -319,6 +322,7 @@ class ModalSandboxSessionState(SandboxSessionState): timeout: int = 300 # 5 minutes use_sleep_cmd: bool = True image_builder_version: str | None = _DEFAULT_IMAGE_BUILDER_VERSION + idle_timeout: int | None = None @dataclass @@ -571,6 +575,7 @@ async def _ensure_sandbox(self) -> bool: volumes=volumes, gpu=self.state.gpu, timeout=self.state.timeout, + idle_timeout=self.state.idle_timeout, ) async with _override_modal_image_builder_version(self.state.image_builder_version): if self.state.sandbox_create_timeout_s is None: @@ -1625,6 +1630,7 @@ async def _run_restore() -> None: volumes=self._modal_cloud_bucket_mounts_for_manifest(), gpu=self.state.gpu, timeout=self.state.timeout, + idle_timeout=self.state.idle_timeout, ) try: mkdir_proc = await sb.exec.aio("mkdir", "-p", "--", root.as_posix(), text=False) @@ -1839,6 +1845,7 @@ async def create( - snapshot_filesystem_restore_timeout_s: float | None (async timeout for snapshot restore call) - timeout: int (maximum sandbox lifetime in seconds, default 300) + - idle_timeout: int | None (maximum sandbox inactivity in seconds, default None) - image_builder_version: str | None (Modal image builder version, default "2025.06") """ @@ -1960,6 +1967,7 @@ async def create( timeout=options.timeout, use_sleep_cmd=options.use_sleep_cmd, image_builder_version=image_builder_version, + idle_timeout=options.idle_timeout, ) if sandbox_create_timeout_s is not None: state.sandbox_create_timeout_s = float(sandbox_create_timeout_s) diff --git a/tests/extensions/test_sandbox_modal.py b/tests/extensions/test_sandbox_modal.py index 335cdffeda..ae12cd02bb 100644 --- a/tests/extensions/test_sandbox_modal.py +++ b/tests/extensions/test_sandbox_modal.py @@ -377,6 +377,25 @@ async def test_modal_sandbox_create_passes_manifest_environment( assert os.environ.get("MODAL_IMAGE_BUILDER_VERSION") is None +@pytest.mark.asyncio +async def test_modal_sandbox_create_passes_idle_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, create_calls, _registry_tags = _load_modal_module(monkeypatch) + + client = modal_module.ModalSandboxClient() + session = await client.create( + options=modal_module.ModalSandboxClientOptions( + app_name="sandbox-tests", + idle_timeout=60, + ), + ) + + assert create_calls + assert create_calls[0]["idle_timeout"] == 60 + assert session.state.idle_timeout == 60 + + @pytest.mark.asyncio async def test_modal_sandbox_create_sets_default_cmd_for_custom_registry_image( monkeypatch: pytest.MonkeyPatch, @@ -478,6 +497,27 @@ def test_modal_deserialize_session_state_defaults_missing_image_builder_version( assert restored.image_builder_version == "2025.06" +def test_modal_deserialize_session_state_defaults_missing_idle_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + + state = modal_module.ModalSandboxSessionState( + manifest=Manifest(root="/workspace"), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + idle_timeout=60, + ) + payload = state.model_dump(mode="json") + payload.pop("idle_timeout") + + restored = modal_module.ModalSandboxClient().deserialize_session_state( + cast(dict[str, object], payload) + ) + + assert restored.idle_timeout is None + + @pytest.mark.asyncio async def test_modal_sandbox_create_passes_modal_cloud_bucket_mounts( monkeypatch: pytest.MonkeyPatch, @@ -2764,6 +2804,7 @@ async def test_modal_snapshot_filesystem_restore_preserves_exposed_ports( app_name="sandbox-tests", workspace_persistence="snapshot_filesystem", exposed_ports=(8765,), + idle_timeout=60, ) session = modal_module.ModalSandboxSession.from_state(state) call_names: list[str] = [] @@ -2789,6 +2830,7 @@ async def _fake_call_modal( assert create_calls assert create_calls[0]["encrypted_ports"] == (8765,) + assert create_calls[0]["idle_timeout"] == 60 assert sys.modules["modal"].Image.from_id_calls == ["snap-123"] assert call_names == [] assert call_timeouts == [] diff --git a/tests/sandbox/test_client_options.py b/tests/sandbox/test_client_options.py index 106501a806..8c71dc4028 100644 --- a/tests/sandbox/test_client_options.py +++ b/tests/sandbox/test_client_options.py @@ -57,6 +57,7 @@ def test_sandbox_client_options_exclude_unset_preserves_type_discriminator() -> "timeout": 300, "use_sleep_cmd": True, "image_builder_version": "2025.06", + "idle_timeout": None, } diff --git a/tests/sandbox/test_compatibility_guards.py b/tests/sandbox/test_compatibility_guards.py index e3ecba3349..c80470360d 100644 --- a/tests/sandbox/test_compatibility_guards.py +++ b/tests/sandbox/test_compatibility_guards.py @@ -439,6 +439,7 @@ def test_optional_sandbox_dataclass_constructor_field_order_is_stable( "timeout", "use_sleep_cmd", "image_builder_version", + "idle_timeout", ), ), ( @@ -612,6 +613,7 @@ def test_optional_sandbox_client_options_positional_field_order_is_stable( "timeout", "use_sleep_cmd", "image_builder_version", + "idle_timeout", ), ), (