mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
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:
+18
-4
@@ -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
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ _STARTER_AGENT_JSON = """\
|
||||
"description": "A managed deep agent.",
|
||||
"model": "openai:gpt-5.5",
|
||||
"backend": {{
|
||||
"type": "default"
|
||||
"type": "state"
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user