fix(cli): support state deploy backend (#3790)

Updates the deploy CLI backend handling to match the current Managed
Deep Agents API shape. New scaffolded projects use `state`, and
sandbox-backed projects can use `backend.type: "sandbox"` with
`sandbox_config.scope`, policy IDs, and TTL fields. Existing `default`,
`thread_scoped_sandbox`, `agent_scoped_sandbox`, and `backend.sandbox`
configs continue to normalize for compatibility.

This also updates the CLI README and example deploy project so users see
the canonical `sandbox_config` form.

Tests run:
- `uv run --group test python -m pytest tests/unit_tests/`
- `uv run --group test ruff check deepagents_cli/deploy/project.py
deepagents_cli/deploy/commands.py
tests/unit_tests/deploy/test_project.py
tests/unit_tests/deploy/test_payload.py
tests/unit_tests/deploy/test_init_command.py
tests/unit_tests/deploy/test_deploy_command.py`
- `uv run --group test ty check deepagents_cli/deploy/project.py`

Live validation:
- Created a temporary Managed Deep Agent using `backend.type: "sandbox"`
and `sandbox_config.scope: "thread"`.
- Confirmed the API stored `type: "sandbox"` and `sandbox_config.scope:
"thread"`.
- Updated the same temporary agent with `idle_ttl_seconds` and
`delete_after_stop_seconds`; confirmed both fields persisted.
- Deleted the temporary remote agent and verified it returned 404
afterward.
This commit is contained in:
Victor Moreira
2026-06-07 14:05:02 -07:00
committed by GitHub
parent b476c8870d
commit 04b4bb946f
8 changed files with 233 additions and 40 deletions
+18 -4
View File
@@ -56,10 +56,24 @@ MCP server that is already registered in the workspace
(`deepagents mcp-servers add`) — so a freshly scaffolded project deploys
without first registering a server.
New agents default to the `default` backend — switch `agent.json`'s
`backend.type` to `thread_scoped_sandbox` (or `agent_scoped_sandbox`) to opt
into a managed sandbox. The CLI does not create or run sandboxes locally;
sandbox lifecycle is handled by the Managed Deep Agents platform.
New agents default to the `state` backend. To opt into a managed sandbox, set
`agent.json`'s `backend.type` to `sandbox` and configure
`backend.sandbox_config.scope` as `thread` or `agent`; `sandbox_config` can also
include sandbox policy IDs and TTL fields. The CLI does not create or run
sandboxes locally; sandbox lifecycle is handled by the Managed Deep Agents
platform.
```json
{
"backend": {
"type": "sandbox",
"sandbox_config": {
"scope": "thread",
"policy_ids": ["policy-id"]
}
}
}
```
### Project layout
+1 -1
View File
@@ -136,7 +136,7 @@ _STARTER_AGENT_JSON = """\
"description": "A managed deep agent.",
"model": "openai:gpt-5.5",
"backend": {{
"type": "default"
"type": "state"
}}
}}
"""
+61 -21
View File
@@ -30,21 +30,21 @@ _SKILLS_DIR = "skills"
_SUBAGENTS_DIR = "subagents"
_SKILL_FILE = "SKILL.md"
_BACKEND_TYPE_DEFAULT = "default"
_BACKEND_TYPE_STATE = "state"
_BACKEND_TYPE_SANDBOX = "sandbox"
_BACKEND_TYPE_THREAD_SCOPED_SANDBOX = "thread_scoped_sandbox"
_BACKEND_TYPE_AGENT_SCOPED_SANDBOX = "agent_scoped_sandbox"
_LEGACY_BACKEND_TYPE_SANDBOX = "sandbox"
_LEGACY_BACKEND_TYPE_DEFAULT = "default"
_VALID_BACKEND_TYPES = frozenset(
{
_BACKEND_TYPE_DEFAULT,
_BACKEND_TYPE_STATE,
_BACKEND_TYPE_SANDBOX,
_BACKEND_TYPE_THREAD_SCOPED_SANDBOX,
_BACKEND_TYPE_AGENT_SCOPED_SANDBOX,
}
)
_SANDBOX_BACKEND_TYPES = frozenset(
{_BACKEND_TYPE_THREAD_SCOPED_SANDBOX, _BACKEND_TYPE_AGENT_SCOPED_SANDBOX}
)
_SANDBOX_INTEGER_FIELDS = frozenset({"idle_ttl_seconds", "delete_after_stop_seconds"})
_VALID_SANDBOX_SCOPES = frozenset({"thread", "agent"})
_VALID_IDENTITY = frozenset({"personal", "shared"})
_VALID_VISIBILITY = frozenset({"tenant", "user"})
_VALID_TENANT_ACCESS = frozenset({"read", "run", "write"})
@@ -236,7 +236,8 @@ def _read_agent_json(root: Path) -> dict[str, Any]:
if backend_type is not None:
msg = (
f"{path}: `runtime.backend_type` is no longer supported. "
'Use top-level `backend`: {"type": "thread_scoped_sandbox"} instead.'
'Use top-level `backend`: {"type": "sandbox", '
'"sandbox_config": {"scope": "thread"}} instead.'
)
raise ProjectError(msg)
@@ -272,44 +273,83 @@ def _normalize_backend(backend: object, *, source: Path) -> dict[str, Any]:
raise ProjectError(msg)
data = dict(cast("dict[str, Any]", backend))
backend_type = data.get("type")
if backend_type == _LEGACY_BACKEND_TYPE_SANDBOX:
backend_type = _BACKEND_TYPE_THREAD_SCOPED_SANDBOX
if backend_type == _LEGACY_BACKEND_TYPE_DEFAULT:
backend_type = _BACKEND_TYPE_STATE
data["type"] = backend_type
if backend_type is not None and backend_type not in _VALID_BACKEND_TYPES:
msg = f"backend.type {backend_type!r} not in {sorted(_VALID_BACKEND_TYPES)}"
raise ProjectError(msg)
sandbox = data.get("sandbox")
if sandbox is None:
return data
if backend_type not in _SANDBOX_BACKEND_TYPES:
if backend_type == _BACKEND_TYPE_THREAD_SCOPED_SANDBOX:
return _normalize_sandbox_backend(data, source=source, default_scope="thread")
if backend_type == _BACKEND_TYPE_AGENT_SCOPED_SANDBOX:
return _normalize_sandbox_backend(data, source=source, default_scope="agent")
if backend_type == _BACKEND_TYPE_SANDBOX:
return _normalize_sandbox_backend(data, source=source, default_scope="thread")
if data.get("sandbox") is not None or data.get("sandbox_config") is not None:
msg = (
f"{source}: `backend.sandbox` requires `backend.type` to be "
"`thread_scoped_sandbox` or `agent_scoped_sandbox`."
f"{source}: sandbox settings require `backend.type` to be "
"`thread_scoped_sandbox`, `agent_scoped_sandbox`, or `sandbox`."
)
raise ProjectError(msg)
data["sandbox"] = _normalize_sandbox_config(sandbox, source=source)
return data
def _normalize_sandbox_config(sandbox: object, *, source: Path) -> dict[str, Any]:
def _normalize_sandbox_backend(
backend: dict[str, Any],
*,
source: Path,
default_scope: str,
) -> dict[str, Any]:
data = dict(backend)
sandbox = data.pop("sandbox", None)
sandbox_config = data.pop("sandbox_config", None)
config: dict[str, Any] = {}
if sandbox is not None:
config = _normalize_sandbox_config(sandbox, field="sandbox", source=source)
if sandbox_config is not None:
config = {
**config,
**_normalize_sandbox_config(
sandbox_config, field="sandbox_config", source=source
),
}
config.setdefault("scope", default_scope)
data["type"] = _BACKEND_TYPE_SANDBOX
data["sandbox_config"] = config
return data
def _normalize_sandbox_config(
sandbox: object,
*,
field: str,
source: Path,
) -> dict[str, Any]:
if not isinstance(sandbox, dict):
msg = f"{source}: `backend.sandbox` must be an object."
msg = f"{source}: `backend.{field}` must be an object."
raise ProjectError(msg)
data = dict(cast("dict[str, Any]", sandbox))
scope = data.get("scope")
if scope is not None and scope not in _VALID_SANDBOX_SCOPES:
msg = f"{source}: `backend.{field}.scope` must be `thread` or `agent`."
raise ProjectError(msg)
policies = data.get("policy_ids")
if policies is not None and (
not isinstance(policies, list)
or not all(isinstance(policy, str) for policy in policies)
):
msg = f"{source}: `backend.sandbox.policy_ids` must be an array of strings."
msg = f"{source}: `backend.{field}.policy_ids` must be an array of strings."
raise ProjectError(msg)
for key in _SANDBOX_INTEGER_FIELDS:
value = data.get(key)
if value is not None and (
not isinstance(value, int) or isinstance(value, bool)
):
msg = f"{source}: `backend.sandbox.{key}` must be an integer."
msg = f"{source}: `backend.{field}.{key}` must be an integer."
raise ProjectError(msg)
return data
@@ -543,7 +583,7 @@ expects the new layout. Quick mapping:
[agent] → agent.json (top-level keys: name, description)
[agent].model → agent.json model
[sandbox].scope → agent.json backend.type ("thread_scoped_sandbox")
[sandbox].scope → agent.json backend.sandbox_config.scope
[auth], [memories], [frontend]→ remove; managed by the platform now
Then run `deepagents init --force` to refresh scaffolding or migrate by hand.
@@ -3,6 +3,9 @@
"description": "Researches topics and drafts blog posts and social media content.",
"model": "anthropic:claude-sonnet-4-6",
"backend": {
"type": "thread_scoped_sandbox"
"type": "sandbox",
"sandbox_config": {
"scope": "thread"
}
}
}
@@ -102,7 +102,10 @@ def test_deploy_dry_run_normalizes_legacy_sandbox_backend(
execute_deploy_command(_ns(tmp_path, dry_run=True))
out = capsys.readouterr().out
payload = json.loads(_extract_json(out))
assert payload["agent_payload"]["backend"] == {"type": "thread_scoped_sandbox"}
assert payload["agent_payload"]["backend"] == {
"type": "sandbox",
"sandbox_config": {"scope": "thread"},
}
def test_deploy_creates_agent_and_writes_state(
@@ -28,7 +28,7 @@ def test_init_scaffolds_new_layout(
agent = json.loads((project / "agent.json").read_text())
assert agent["name"] == "my-agent"
assert agent["model"] == "openai:gpt-5.5"
assert agent["backend"] == {"type": "default"}
assert agent["backend"] == {"type": "state"}
assert "runtime" not in agent
assert (project / "AGENTS.md").is_file()
assert (project / ".gitignore").is_file()
@@ -55,7 +55,7 @@ def test_init_scaffold_loads_as_valid_project(
monkeypatch.chdir(tmp_path)
execute_init_command(_ns("my-agent"))
project = Project.load(tmp_path / "my-agent")
assert project.backend == {"type": "default"}
assert project.backend == {"type": "state"}
assert project.tools is not None
assert len(project.skills) == 1
assert project.skills[0].name == "example-skill"
@@ -54,7 +54,52 @@ def test_create_payload_normalizes_legacy_sandbox_backend(tmp_path: Path) -> Non
(tmp_path / "AGENTS.md").write_text("hi")
project = Project.load(tmp_path)
payload = build_payload(project, mode="create")
assert payload["backend"] == {"type": "thread_scoped_sandbox"}
assert payload["backend"] == {
"type": "sandbox",
"sandbox_config": {"scope": "thread"},
}
def test_create_payload_normalizes_legacy_default_backend(tmp_path: Path) -> None:
(tmp_path / "agent.json").write_text(
'{"name": "x", "backend": {"type": "default"}}'
)
(tmp_path / "AGENTS.md").write_text("hi")
project = Project.load(tmp_path)
payload = build_payload(project, mode="create")
assert payload["backend"] == {"type": "state"}
def test_create_payload_allows_state_backend(tmp_path: Path) -> None:
(tmp_path / "agent.json").write_text('{"name": "x", "backend": {"type": "state"}}')
(tmp_path / "AGENTS.md").write_text("hi")
project = Project.load(tmp_path)
payload = build_payload(project, mode="create")
assert payload["backend"] == {"type": "state"}
def test_create_payload_allows_sandbox_config(tmp_path: Path) -> None:
(tmp_path / "agent.json").write_text(
"""
{
"name": "x",
"backend": {
"type": "sandbox",
"sandbox_config": {
"scope": "thread",
"policy_ids": ["p-1"]
}
}
}
"""
)
(tmp_path / "AGENTS.md").write_text("hi")
project = Project.load(tmp_path)
payload = build_payload(project, mode="create")
assert payload["backend"] == {
"type": "sandbox",
"sandbox_config": {"scope": "thread", "policy_ids": ["p-1"]},
}
def test_create_payload_compiles_model_shorthand(tmp_path: Path) -> None:
@@ -100,8 +100,8 @@ def test_runtime_and_permissions_round_trip(tmp_path: Path) -> None:
proj = Project.load(tmp_path)
assert proj.runtime == {"model": {"model_id": "anthropic:claude-sonnet-4-6"}}
assert proj.backend == {
"type": "thread_scoped_sandbox",
"sandbox": {"policy_ids": ["p-1"]},
"type": "sandbox",
"sandbox_config": {"scope": "thread", "policy_ids": ["p-1"]},
}
assert proj.permissions == {
"identity": "personal",
@@ -169,10 +169,10 @@ def test_runtime_backend_type_raises_migration_error(tmp_path: Path) -> None:
(tmp_path / "AGENTS.md").write_text("hi")
with pytest.raises(ProjectError, match=r"runtime\.backend_type") as excinfo:
Project.load(tmp_path)
assert "thread_scoped_sandbox" in str(excinfo.value)
assert "sandbox_config" in str(excinfo.value)
def test_legacy_sandbox_backend_type_normalizes_to_thread_scoped(
def test_sandbox_backend_type_defaults_to_thread_scope(
tmp_path: Path,
) -> None:
(tmp_path / "agent.json").write_text(
@@ -189,18 +189,64 @@ def test_legacy_sandbox_backend_type_normalizes_to_thread_scoped(
(tmp_path / "AGENTS.md").write_text("hi")
proj = Project.load(tmp_path)
assert proj.backend == {
"type": "thread_scoped_sandbox",
"sandbox": {"policy_ids": ["p-1"]},
"type": "sandbox",
"sandbox_config": {"scope": "thread", "policy_ids": ["p-1"]},
}
def test_legacy_default_backend_type_normalizes_to_state(tmp_path: Path) -> None:
(tmp_path / "agent.json").write_text(
'{"name": "x", "backend": {"type": "default"}}'
)
(tmp_path / "AGENTS.md").write_text("hi")
proj = Project.load(tmp_path)
assert proj.backend == {"type": "state"}
def test_state_backend_type_is_allowed(tmp_path: Path) -> None:
(tmp_path / "agent.json").write_text('{"name": "x", "backend": {"type": "state"}}')
(tmp_path / "AGENTS.md").write_text("hi")
proj = Project.load(tmp_path)
assert proj.backend == {"type": "state"}
def test_agent_scoped_sandbox_backend_type_is_allowed(tmp_path: Path) -> None:
(tmp_path / "agent.json").write_text(
'{"name": "x", "backend": {"type": "agent_scoped_sandbox"}}'
)
(tmp_path / "AGENTS.md").write_text("hi")
proj = Project.load(tmp_path)
assert proj.backend == {"type": "agent_scoped_sandbox"}
assert proj.backend == {"type": "sandbox", "sandbox_config": {"scope": "agent"}}
def test_sandbox_backend_config_is_allowed(tmp_path: Path) -> None:
(tmp_path / "agent.json").write_text(
"""
{
"name": "x",
"backend": {
"type": "sandbox",
"sandbox_config": {
"scope": "agent",
"policy_ids": ["p-1"],
"idle_ttl_seconds": 900,
"delete_after_stop_seconds": 300
}
}
}
"""
)
(tmp_path / "AGENTS.md").write_text("hi")
proj = Project.load(tmp_path)
assert proj.backend == {
"type": "sandbox",
"sandbox_config": {
"scope": "agent",
"policy_ids": ["p-1"],
"idle_ttl_seconds": 900,
"delete_after_stop_seconds": 300,
},
}
def test_invalid_backend_type_raises(tmp_path: Path) -> None:
@@ -212,12 +258,12 @@ def test_invalid_backend_type_raises(tmp_path: Path) -> None:
Project.load(tmp_path)
def test_sandbox_config_with_default_backend_raises(tmp_path: Path) -> None:
def test_sandbox_settings_with_default_backend_raises(tmp_path: Path) -> None:
(tmp_path / "agent.json").write_text(
'{"name": "x", "backend": {"type": "default", "sandbox": {}}}'
)
(tmp_path / "AGENTS.md").write_text("hi")
with pytest.raises(ProjectError, match=r"backend\.sandbox"):
with pytest.raises(ProjectError, match=r"sandbox settings"):
Project.load(tmp_path)
@@ -255,6 +301,48 @@ def test_sandbox_ttl_fields_must_be_integers(tmp_path: Path) -> None:
Project.load(tmp_path)
def test_sandbox_config_scope_must_be_valid(tmp_path: Path) -> None:
(tmp_path / "agent.json").write_text(
"""
{
"name": "x",
"backend": {
"type": "sandbox",
"sandbox_config": {"scope": "workspace"}
}
}
"""
)
(tmp_path / "AGENTS.md").write_text("hi")
with pytest.raises(ProjectError, match=r"scope"):
Project.load(tmp_path)
def test_sandbox_config_overrides_legacy_sandbox_config(tmp_path: Path) -> None:
(tmp_path / "agent.json").write_text(
"""
{
"name": "x",
"backend": {
"type": "thread_scoped_sandbox",
"sandbox": {"policy_ids": ["old"], "idle_ttl_seconds": 900},
"sandbox_config": {"policy_ids": ["new"]}
}
}
"""
)
(tmp_path / "AGENTS.md").write_text("hi")
proj = Project.load(tmp_path)
assert proj.backend == {
"type": "sandbox",
"sandbox_config": {
"scope": "thread",
"policy_ids": ["new"],
"idle_ttl_seconds": 900,
},
}
def test_load_with_tools_reads_tools_json() -> None:
proj = Project.load(_FIXTURES / "with_tools")
assert proj.tools is not None