Skip to content

Commit 5be06a1

Browse files
authored
feat: #3001 add Modal sandbox idle timeout option (#3005)
1 parent 81c57c5 commit 5be06a1

4 files changed

Lines changed: 53 additions & 0 deletions

File tree

src/agents/extensions/sandbox/modal/sandbox.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ class ModalSandboxClientOptions(BaseSandboxClientOptions):
158158
timeout: int = 300 # Lifetime of a sandbox from creation in seconds, defaults to 5 minutes
159159
use_sleep_cmd: bool = True
160160
image_builder_version: str | None = _DEFAULT_IMAGE_BUILDER_VERSION
161+
idle_timeout: int | None = None
161162

162163
def __init__(
163164
self,
@@ -171,6 +172,7 @@ def __init__(
171172
timeout: int = 300, # 5 minutes
172173
use_sleep_cmd: bool = True,
173174
image_builder_version: str | None = _DEFAULT_IMAGE_BUILDER_VERSION,
175+
idle_timeout: int | None = None,
174176
*,
175177
type: Literal["modal"] = "modal",
176178
) -> None:
@@ -186,6 +188,7 @@ def __init__(
186188
timeout=timeout,
187189
use_sleep_cmd=use_sleep_cmd,
188190
image_builder_version=image_builder_version,
191+
idle_timeout=idle_timeout,
189192
)
190193

191194

@@ -319,6 +322,7 @@ class ModalSandboxSessionState(SandboxSessionState):
319322
timeout: int = 300 # 5 minutes
320323
use_sleep_cmd: bool = True
321324
image_builder_version: str | None = _DEFAULT_IMAGE_BUILDER_VERSION
325+
idle_timeout: int | None = None
322326

323327

324328
@dataclass
@@ -571,6 +575,7 @@ async def _ensure_sandbox(self) -> bool:
571575
volumes=volumes,
572576
gpu=self.state.gpu,
573577
timeout=self.state.timeout,
578+
idle_timeout=self.state.idle_timeout,
574579
)
575580
async with _override_modal_image_builder_version(self.state.image_builder_version):
576581
if self.state.sandbox_create_timeout_s is None:
@@ -1625,6 +1630,7 @@ async def _run_restore() -> None:
16251630
volumes=self._modal_cloud_bucket_mounts_for_manifest(),
16261631
gpu=self.state.gpu,
16271632
timeout=self.state.timeout,
1633+
idle_timeout=self.state.idle_timeout,
16281634
)
16291635
try:
16301636
mkdir_proc = await sb.exec.aio("mkdir", "-p", "--", root.as_posix(), text=False)
@@ -1839,6 +1845,7 @@ async def create(
18391845
- snapshot_filesystem_restore_timeout_s: float | None
18401846
(async timeout for snapshot restore call)
18411847
- timeout: int (maximum sandbox lifetime in seconds, default 300)
1848+
- idle_timeout: int | None (maximum sandbox inactivity in seconds, default None)
18421849
- image_builder_version: str | None (Modal image builder version, default "2025.06")
18431850
"""
18441851

@@ -1960,6 +1967,7 @@ async def create(
19601967
timeout=options.timeout,
19611968
use_sleep_cmd=options.use_sleep_cmd,
19621969
image_builder_version=image_builder_version,
1970+
idle_timeout=options.idle_timeout,
19631971
)
19641972
if sandbox_create_timeout_s is not None:
19651973
state.sandbox_create_timeout_s = float(sandbox_create_timeout_s)

tests/extensions/test_sandbox_modal.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,25 @@ async def test_modal_sandbox_create_passes_manifest_environment(
377377
assert os.environ.get("MODAL_IMAGE_BUILDER_VERSION") is None
378378

379379

380+
@pytest.mark.asyncio
381+
async def test_modal_sandbox_create_passes_idle_timeout(
382+
monkeypatch: pytest.MonkeyPatch,
383+
) -> None:
384+
modal_module, create_calls, _registry_tags = _load_modal_module(monkeypatch)
385+
386+
client = modal_module.ModalSandboxClient()
387+
session = await client.create(
388+
options=modal_module.ModalSandboxClientOptions(
389+
app_name="sandbox-tests",
390+
idle_timeout=60,
391+
),
392+
)
393+
394+
assert create_calls
395+
assert create_calls[0]["idle_timeout"] == 60
396+
assert session.state.idle_timeout == 60
397+
398+
380399
@pytest.mark.asyncio
381400
async def test_modal_sandbox_create_sets_default_cmd_for_custom_registry_image(
382401
monkeypatch: pytest.MonkeyPatch,
@@ -478,6 +497,27 @@ def test_modal_deserialize_session_state_defaults_missing_image_builder_version(
478497
assert restored.image_builder_version == "2025.06"
479498

480499

500+
def test_modal_deserialize_session_state_defaults_missing_idle_timeout(
501+
monkeypatch: pytest.MonkeyPatch,
502+
) -> None:
503+
modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch)
504+
505+
state = modal_module.ModalSandboxSessionState(
506+
manifest=Manifest(root="/workspace"),
507+
snapshot=modal_module.resolve_snapshot(None, "snapshot"),
508+
app_name="sandbox-tests",
509+
idle_timeout=60,
510+
)
511+
payload = state.model_dump(mode="json")
512+
payload.pop("idle_timeout")
513+
514+
restored = modal_module.ModalSandboxClient().deserialize_session_state(
515+
cast(dict[str, object], payload)
516+
)
517+
518+
assert restored.idle_timeout is None
519+
520+
481521
@pytest.mark.asyncio
482522
async def test_modal_sandbox_create_passes_modal_cloud_bucket_mounts(
483523
monkeypatch: pytest.MonkeyPatch,
@@ -2764,6 +2804,7 @@ async def test_modal_snapshot_filesystem_restore_preserves_exposed_ports(
27642804
app_name="sandbox-tests",
27652805
workspace_persistence="snapshot_filesystem",
27662806
exposed_ports=(8765,),
2807+
idle_timeout=60,
27672808
)
27682809
session = modal_module.ModalSandboxSession.from_state(state)
27692810
call_names: list[str] = []
@@ -2789,6 +2830,7 @@ async def _fake_call_modal(
27892830

27902831
assert create_calls
27912832
assert create_calls[0]["encrypted_ports"] == (8765,)
2833+
assert create_calls[0]["idle_timeout"] == 60
27922834
assert sys.modules["modal"].Image.from_id_calls == ["snap-123"]
27932835
assert call_names == []
27942836
assert call_timeouts == []

tests/sandbox/test_client_options.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def test_sandbox_client_options_exclude_unset_preserves_type_discriminator() ->
5757
"timeout": 300,
5858
"use_sleep_cmd": True,
5959
"image_builder_version": "2025.06",
60+
"idle_timeout": None,
6061
}
6162

6263

tests/sandbox/test_compatibility_guards.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,7 @@ def test_optional_sandbox_dataclass_constructor_field_order_is_stable(
440440
"timeout",
441441
"use_sleep_cmd",
442442
"image_builder_version",
443+
"idle_timeout",
443444
),
444445
),
445446
(
@@ -613,6 +614,7 @@ def test_optional_sandbox_client_options_positional_field_order_is_stable(
613614
"timeout",
614615
"use_sleep_cmd",
615616
"image_builder_version",
617+
"idle_timeout",
616618
),
617619
),
618620
(

0 commit comments

Comments
 (0)