From f72565f55264eac23f29b9e5f10927843e66e118 Mon Sep 17 00:00:00 2001 From: Can Ersoz Date: Thu, 15 Oct 2026 17:15:51 -0700 Subject: [PATCH 1/2] feat(deploy_sandbox): default an unsized sandbox to 1 vCPU A deploy_sandbox step that sets no cpu now reserves 1 vCPU instead of 2, matching A2AAgent.deploy and the beta_scale default for env and agent deploys. Steps that set cpu, which includes every stored step because to_dict writes it, keep their value. On Modal a scalar cpu is a request with bursting allowed, so the lower default cuts idle cost without capping boot. On beta_scale it is a hard cap; callers there that care about boot time should set cpu explicitly. Part of FD-4008. Co-Authored-By: Claude Opus 5.5 --- .../task_step/task_steps/deploy_sandbox.py | 4 +- .../deploy_sandbox_default_cpu_test.py | 46 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 tst/unit/task_step/deploy_sandbox_default_cpu_test.py diff --git a/src/agent_env/task_step/task_steps/deploy_sandbox.py b/src/agent_env/task_step/task_steps/deploy_sandbox.py index 581e15b3..ae00fc47 100644 --- a/src/agent_env/task_step/task_steps/deploy_sandbox.py +++ b/src/agent_env/task_step/task_steps/deploy_sandbox.py @@ -31,7 +31,7 @@ def __init__( sandbox_name: str, sandbox_mode: str, image: Optional[str] = None, - cpu: float = 2.0, + cpu: float = 1.0, memory_mb: int = 8192, disk_size_gb: float = 10, ttl_seconds: int = TaskStep.DEFAULT_TTL_SECONDS, @@ -88,7 +88,7 @@ def from_dict(cls, data: dict) -> DeploySandboxTaskStep: sandbox_name=data["sandbox_name"], sandbox_mode=data["sandbox_mode"], image=data.get("image"), - cpu=data.get("cpu", 2.0), + cpu=data.get("cpu", 1.0), memory_mb=data.get("memory_mb", 8192), disk_size_gb=data.get("disk_size_gb", 10), ttl_seconds=data.get("ttl_seconds", TaskStep.DEFAULT_TTL_SECONDS), diff --git a/tst/unit/task_step/deploy_sandbox_default_cpu_test.py b/tst/unit/task_step/deploy_sandbox_default_cpu_test.py new file mode 100644 index 00000000..65fcb0de --- /dev/null +++ b/tst/unit/task_step/deploy_sandbox_default_cpu_test.py @@ -0,0 +1,46 @@ +"""A deploy_sandbox step that sets no cpu reserves one vCPU, whether built in code or loaded from a stored dict, and +a stored cpu is kept as written.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.task_steps.deploy_sandbox import DeploySandboxTaskStep + + +def _provider(): + sandbox = AsyncMock() + sandbox.sandbox_id, sandbox.mode, sandbox.type = "sb-1", "vm", "modal_vm" + sandbox.tunnel_urls, sandbox.vnc_url, sandbox.network_policy = {}, None, NetworkPolicy() + provider = AsyncMock() + provider.create_vm.return_value = sandbox + return provider + + +def _stored(**fields) -> dict: + return {"id": "s", "version": 1, "type": "deploy_sandbox", "sandbox_name": "box", "sandbox_mode": "vm", **fields} + + +@pytest.mark.parametrize("step", [ + DeploySandboxTaskStep(id="s", version=1, sandbox_name="box", sandbox_mode="vm"), + DeploySandboxTaskStep.from_dict(_stored()), +], ids=["constructed", "from-dict"]) +def test_an_unsized_step_reserves_one_cpu(step): + assert step.cpu == 1.0 + + +def test_a_stored_cpu_is_kept(): + assert DeploySandboxTaskStep.from_dict(_stored(cpu=2.0)).cpu == 2.0 + + +@pytest.mark.asyncio +async def test_the_provider_is_asked_for_one_cpu(): + step = DeploySandboxTaskStep(id="s", version=1, sandbox_name="box", sandbox_mode="vm", sandbox_type="modal_vm") + provider = _provider() + + with patch("agent_env.providers.sandbox_providers.sandbox_provider.build_sandbox_provider", return_value=provider): + await step.execute(TaskStepContext()) + + assert provider.create_vm.call_args.kwargs["cpu"] == 1.0 From 99910b345aa568df9f14b95c286b1727953316d2 Mon Sep 17 00:00:00 2001 From: Can Ersoz Date: Fri, 16 Oct 2026 10:17:56 -0700 Subject: [PATCH 2/2] test(deploy_sandbox): cover the container path's cpu, unsized and sized Co-Authored-By: Claude Opus 5.5 --- .../deploy_sandbox_default_cpu_test.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tst/unit/task_step/deploy_sandbox_default_cpu_test.py b/tst/unit/task_step/deploy_sandbox_default_cpu_test.py index 65fcb0de..cffa512d 100644 --- a/tst/unit/task_step/deploy_sandbox_default_cpu_test.py +++ b/tst/unit/task_step/deploy_sandbox_default_cpu_test.py @@ -14,8 +14,10 @@ def _provider(): sandbox = AsyncMock() sandbox.sandbox_id, sandbox.mode, sandbox.type = "sb-1", "vm", "modal_vm" sandbox.tunnel_urls, sandbox.vnc_url, sandbox.network_policy = {}, None, NetworkPolicy() - provider = AsyncMock() + provider = AsyncMock(ON_THIS_MACHINE=False) + provider.links = (provider,) provider.create_vm.return_value = sandbox + provider.create_container.return_value = sandbox return provider @@ -36,11 +38,18 @@ def test_a_stored_cpu_is_kept(): @pytest.mark.asyncio -async def test_the_provider_is_asked_for_one_cpu(): - step = DeploySandboxTaskStep(id="s", version=1, sandbox_name="box", sandbox_mode="vm", sandbox_type="modal_vm") +@pytest.mark.parametrize("mode, create, extra", [ + ("vm", "create_vm", {}), + ("container", "create_container", {"image": "example.com/box:v1", "port": 80}), +], ids=["vm", "container"]) +@pytest.mark.parametrize("step_cpu, requested", [(None, 1.0), (4.0, 4.0)], ids=["unsized", "sized"]) +async def test_the_provider_is_asked_for_the_steps_cpu(mode, create, extra, step_cpu, requested): + sized = {} if step_cpu is None else {"cpu": step_cpu} + step = DeploySandboxTaskStep(id="s", version=1, sandbox_name="box", sandbox_mode=mode, sandbox_type="any", + **sized, **extra) provider = _provider() with patch("agent_env.providers.sandbox_providers.sandbox_provider.build_sandbox_provider", return_value=provider): await step.execute(TaskStepContext()) - assert provider.create_vm.call_args.kwargs["cpu"] == 1.0 + assert getattr(provider, create).call_args.kwargs["cpu"] == requested