Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/agents/extensions/sandbox/modal/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -186,6 +188,7 @@ def __init__(
timeout=timeout,
use_sleep_cmd=use_sleep_cmd,
image_builder_version=image_builder_version,
idle_timeout=idle_timeout,
)


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
"""

Expand Down Expand Up @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions tests/extensions/test_sandbox_modal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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] = []
Expand All @@ -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 == []
Expand Down
1 change: 1 addition & 0 deletions tests/sandbox/test_client_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}


Expand Down
2 changes: 2 additions & 0 deletions tests/sandbox/test_compatibility_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@ def test_optional_sandbox_dataclass_constructor_field_order_is_stable(
"timeout",
"use_sleep_cmd",
"image_builder_version",
"idle_timeout",
),
),
(
Expand Down Expand Up @@ -612,6 +613,7 @@ def test_optional_sandbox_client_options_positional_field_order_is_stable(
"timeout",
"use_sleep_cmd",
"image_builder_version",
"idle_timeout",
),
),
(
Expand Down
Loading