Skip to content
Draft
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
355 changes: 351 additions & 4 deletions plugins/nemo-agents/openapi/openapi.yaml

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions plugins/nemo-agents/src/nemo_agents_plugin/api/v2/_perms.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,10 @@ class ComputeSpecPerms(PermissionSet, namespace="agents.compute-specs"):
LIST = perm("List agent compute specs")
READ = perm("Read an agent compute spec")
DELETE = perm("Delete an agent compute spec")


class SandboxSpecPerms(PermissionSet, namespace="agents.sandbox-specs"):
CREATE = perm("Create agent sandbox specs")
LIST = perm("List agent sandbox specs")
READ = perm("Read an agent sandbox spec")
DELETE = perm("Delete an agent sandbox spec")
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ async def create_deployment(
config=merged.config,
environment=body.environment,
compute=resolved_environment.compute_spec,
sandbox=resolved_environment.sandbox_spec,
secrets=merged.secrets,
status="pending",
deployment_mode=body.deployment_mode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,23 @@
from typing import TypeVar

from fastapi import APIRouter, Depends, HTTPException, Query
from nemo_agents_plugin.api.v2._perms import ComputeSpecPerms, EnvironmentPerms, EnvironmentSpecPerms
from nemo_agents_plugin.api.v2._perms import ComputeSpecPerms, EnvironmentPerms, EnvironmentSpecPerms, SandboxSpecPerms
from nemo_agents_plugin.api.v2.dependencies import get_entity_client
from nemo_agents_plugin.authz import scope
from nemo_agents_plugin.entities import AgentComputeSpec, AgentEnvironment, AgentEnvironmentSpec
from nemo_agents_plugin.entities import AgentComputeSpec, AgentEnvironment, AgentEnvironmentSpec, AgentSandboxSpec
from nemo_agents_plugin.schema import (
ComputeSpecFilter,
ComputeSpecPage,
CreateComputeSpecRequest,
CreateEnvironmentRequest,
CreateEnvironmentSpecRequest,
CreateSandboxSpecRequest,
EnvironmentFilter,
EnvironmentPage,
EnvironmentSpecFilter,
EnvironmentSpecPage,
SandboxSpecFilter,
SandboxSpecPage,
)
from nemo_platform_plugin.api.filters import make_filter_obj_dep
from nemo_platform_plugin.authz import CallerKind, path_rule
Expand All @@ -53,6 +56,7 @@
_environment_filter_dep = make_filter_obj_dep(EnvironmentFilter)
_environment_spec_filter_dep = make_filter_obj_dep(EnvironmentSpecFilter)
_compute_spec_filter_dep = make_filter_obj_dep(ComputeSpecFilter)
_sandbox_spec_filter_dep = make_filter_obj_dep(SandboxSpecFilter)


# ---------------------------------------------------------------------------
Expand All @@ -74,6 +78,7 @@ async def create_environment(
workspace=workspace,
description=body.description,
environment_spec=body.environment_spec,
sandbox_spec=body.sandbox_spec,
compute_spec=body.compute_spec,
)
return await _create_entity(entity_client, environment, kind="environment", name=body.name, workspace=workspace)
Expand Down Expand Up @@ -266,6 +271,73 @@ async def delete_compute_spec(
await _delete_entity(entity_client, AgentComputeSpec, name=name, workspace=workspace, kind="compute spec")


# ---------------------------------------------------------------------------
# AgentSandboxSpec
# ---------------------------------------------------------------------------


@router.post("/sandbox-specs", response_model=AgentSandboxSpec, status_code=201, tags=["Agent Sandbox Specs"])
@scope.write
@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[SandboxSpecPerms.CREATE])
async def create_sandbox_spec(
workspace: str,
body: CreateSandboxSpecRequest,
entity_client: NemoEntitiesClient = Depends(get_entity_client),
) -> AgentSandboxSpec:
"""Create a new AgentSandboxSpec."""
spec = AgentSandboxSpec(**body.model_dump(), workspace=workspace)
return await _create_entity(entity_client, spec, kind="sandbox spec", name=body.name, workspace=workspace)


@router.get("/sandbox-specs", response_model=SandboxSpecPage, tags=["Agent Sandbox Specs"])
@scope.read
@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[SandboxSpecPerms.LIST])
async def list_sandbox_specs(
workspace: str,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
sort: str = Query(default="-created_at"),
filter: SandboxSpecFilter = Depends(_sandbox_spec_filter_dep),
entity_client: NemoEntitiesClient = Depends(get_entity_client),
) -> SandboxSpecPage:
"""List AgentSandboxSpecs in the workspace."""
return await _list_entities(
entity_client,
AgentSandboxSpec,
SandboxSpecPage,
workspace=workspace,
page=page,
page_size=page_size,
sort=sort,
filter=filter,
kind="sandbox specs",
)


@router.get("/sandbox-specs/{name}", response_model=AgentSandboxSpec, tags=["Agent Sandbox Specs"])
@scope.read
@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[SandboxSpecPerms.READ])
async def get_sandbox_spec(
workspace: str,
name: str,
entity_client: NemoEntitiesClient = Depends(get_entity_client),
) -> AgentSandboxSpec:
"""Get an AgentSandboxSpec by name."""
return await _get_entity(entity_client, AgentSandboxSpec, name=name, workspace=workspace, kind="sandbox spec")


@router.delete("/sandbox-specs/{name}", status_code=204, tags=["Agent Sandbox Specs"])
@scope.write
@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[SandboxSpecPerms.DELETE])
async def delete_sandbox_spec(
workspace: str,
name: str,
entity_client: NemoEntitiesClient = Depends(get_entity_client),
) -> None:
"""Delete an AgentSandboxSpec by name."""
await _delete_entity(entity_client, AgentSandboxSpec, name=name, workspace=workspace, kind="sandbox spec")


# ---------------------------------------------------------------------------
# Shared CRUD helpers
# ---------------------------------------------------------------------------
Expand Down
34 changes: 28 additions & 6 deletions plugins/nemo-agents/src/nemo_agents_plugin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -949,10 +949,9 @@ def deploy(
"--environment",
"-e",
help=(
"AgentEnvironment to deploy under, as a 'workspace/name' ref "
"(e.g. 'default/repo-research-ben'). Its EnvironmentSpec is merged "
"into the agent config and its ComputeSpec/secret refs are "
"snapshotted onto the deployment at create time."
'AgentEnvironment for this deployment: a "workspace/name" ref string, or inline JSON '
'(e.g. \'{"environment_spec": ..., "sandbox_spec": ..., "compute_spec": ...}\'). '
"Resolved and snapshotted at create time."
),
),
wait: bool = typer.Option(
Expand Down Expand Up @@ -1008,8 +1007,8 @@ def deploy(
payload["name"] = name
if image:
payload["image"] = image
if environment is not None:
payload["environment"] = environment
if environment:
payload["environment"] = _parse_environment_arg(environment)
resp = _api_request("POST", base_url, f"/apis/agents/v2/workspaces/{workspace}/deployments", json_body=payload)
if not wait:
typer.echo(json.dumps(resp, indent=2))
Expand Down Expand Up @@ -1923,6 +1922,29 @@ def _resolve_timestamp_format(ctx: typer.Context) -> str | None:
return None


def _parse_environment_arg(raw: str) -> str | dict[str, Any]:
"""Parse ``--environment`` into a ref string or inline dict.

If *raw* looks like a qualified name (contains '/'), treat it as an
AgentEnvironment ref. Otherwise attempt JSON decode; if that fails,
treat it as a bare environment name (single-word ref in the default
workspace).
"""
stripped = raw.strip()
if not stripped:
return None
# "workspace/name" → ref string
if "/" in stripped:
return stripped
# Try inline JSON first
try:
return json.loads(stripped)
except json.JSONDecodeError:
pass
# Fallback: bare name → ref in default workspace
return stripped


def _api_request(method: str, base_url: str, path: str, *, json_body: dict[str, Any] | None = None) -> Any:
url = base_url.rstrip("/") + path
request_kwargs: dict[str, Any] = {}
Expand Down
42 changes: 39 additions & 3 deletions plugins/nemo-agents/src/nemo_agents_plugin/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,19 +197,39 @@ class EnvironmentSpecInline(BaseModel):
)


class SandboxSpecInline(BaseModel):
"""Inline sandbox spec - the isolation posture around an agent run.

Provider-specific in its extension fields. ``provider`` names a registered
sandbox provider (open set, discovered via entry points); ``provider_config``
carries provider-specific fields the platform does not interpret.
"""

description: str = Field(default="", description="Human-readable description.")
provider: str = Field(description="Sandbox provider name (e.g. 'openshell', 'opensandbox').")
provider_config: dict[str, Any] = Field(
default_factory=dict,
description="Provider-specific sandbox configuration; the platform does not interpret these fields.",
)


class AgentEnvironmentInline(BaseModel):
"""Inline AgentEnvironment - a composition of environment + compute specs.
"""Inline AgentEnvironment - a composition of environment, sandbox, and compute specs.

Each part is a ``ref | inline | None`` union: a ``"workspace/name"`` string
references a stored spec entity, an object provides the spec inline, and
``None`` omits it. (A ``sandbox_spec`` is out of scope for now and omitted.)
``None`` omits it.
"""

description: str = Field(default="", description="Human-readable description.")
environment_spec: str | EnvironmentSpecInline | None = Field(
default=None,
description='"workspace/name" ref to an AgentEnvironmentSpec, an inline spec, or None.',
)
sandbox_spec: str | SandboxSpecInline | None = Field(
default=None,
description='"workspace/name" ref to an AgentSandboxSpec, an inline spec, or None.',
)
compute_spec: str | ComputeSpecInline | None = Field(
default=None,
description='"workspace/name" ref to an AgentComputeSpec, an inline spec, or None.',
Expand Down Expand Up @@ -319,8 +339,16 @@ class AgentEnvironmentSpec(NemoEntity, EnvironmentSpecInline, entity_type="agent
"""


class AgentSandboxSpec(NemoEntity, SandboxSpecInline, entity_type="agent_sandbox_spec"):
"""A reusable sandbox spec (the isolation posture around an agent run).

Entity type: ``agent_sandbox_spec``
Referenced by an AgentEnvironment's ``sandbox_spec`` (by name or inline).
"""


class AgentEnvironment(NemoEntity, AgentEnvironmentInline, entity_type="agent_environment"):
"""A composition of an environment spec and a compute spec.
"""A composition of an environment spec, a sandbox spec, and a compute spec.

Entity type: ``agent_environment``
The single thing an AgentDeployment references. Each part is a
Expand Down Expand Up @@ -403,6 +431,14 @@ class AgentDeployment(NemoEntity, entity_type="agent_deployment"):
"vars (never plaintext) for docker/k8s modes; ignored for subprocess."
),
)
sandbox: SandboxSpecInline | None = Field(
default=None,
description=(
"Resolved sandbox spec snapshot from the referenced environment. "
"Records the isolation posture (provider + provider_config) for the deployment; "
"the runner uses it when the sandbox provider is wired."
),
)
status: DeploymentStatus = Field(
default="pending",
description="Lifecycle status: pending | starting | running | failed | deleting.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@
AgentEnvironment,
AgentEnvironmentInline,
AgentEnvironmentSpec,
AgentSandboxSpec,
ComputeSpecInline,
EnvironmentSpecInline,
SandboxSpecInline,
)
from nemo_platform_plugin.entities.base import parse_qualified_name
from nemo_platform_plugin.entity_client import NemoEntitiesClient, NemoEntityNotFoundError
Expand All @@ -59,9 +61,10 @@ class EnvironmentResolutionError(ValueError):

@dataclass(frozen=True)
class ResolvedEnvironment:
"""Concrete environment/compute specs resolved from an AgentEnvironment."""
"""Concrete environment/sandbox/compute specs resolved from an AgentEnvironment."""

environment_spec: EnvironmentSpecInline | None = None
sandbox_spec: SandboxSpecInline | None = None
compute_spec: ComputeSpecInline | None = None


Expand Down Expand Up @@ -101,10 +104,17 @@ async def resolve_environment(
environment_spec = await _resolve_environment_spec(
resolved_env.environment_spec, workspace=workspace, entity_client=entity_client
)
sandbox_spec = await _resolve_sandbox_spec(
resolved_env.sandbox_spec, workspace=workspace, entity_client=entity_client
)
compute_spec = await _resolve_compute_spec(
resolved_env.compute_spec, workspace=workspace, entity_client=entity_client
)
return ResolvedEnvironment(environment_spec=environment_spec, compute_spec=compute_spec)
return ResolvedEnvironment(
environment_spec=environment_spec,
sandbox_spec=sandbox_spec,
compute_spec=compute_spec,
)


async def _resolve_agent_environment(
Expand Down Expand Up @@ -162,6 +172,25 @@ async def _resolve_compute_spec(
return spec


async def _resolve_sandbox_spec(
spec: str | SandboxSpecInline | None,
*,
workspace: str,
entity_client: NemoEntitiesClient,
) -> SandboxSpecInline | None:
if spec is None:
return None
if isinstance(spec, str):
ref_workspace, name = parse_qualified_name(spec, default_workspace=workspace)
try:
return await entity_client.get(AgentSandboxSpec, name=name, workspace=ref_workspace)
except NemoEntityNotFoundError as exc:
raise EnvironmentResolutionError(
f"AgentSandboxSpec '{name}' not found in workspace '{ref_workspace}'."
) from exc
return spec


def merge_environment_spec_into_agent_config(
config: dict[str, Any],
env_spec: EnvironmentSpecInline | None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from pathlib import Path
from typing import Any, Literal

from nemo_agents_plugin.entities import ComputeResources, DeploymentMode, DeploymentStatus, Endpoint
from nemo_agents_plugin.entities import ComputeResources, DeploymentMode, DeploymentStatus, Endpoint, SandboxSpecInline


@dataclass(frozen=True)
Expand Down Expand Up @@ -99,6 +99,7 @@ async def create_deployment(
created_by: str | None = None,
resources: ComputeResources | None = None,
secrets: dict[str, str] | None = None,
sandbox: SandboxSpecInline | None = None,
) -> DeploymentInfo:
"""Start the agent process; returns status="starting".

Expand All @@ -117,6 +118,11 @@ async def create_deployment(
resolved environment. Container backends compile these into secret-backed
container env vars (never plaintext); the deployments-plugin substrate
materializes/mounts them. Subprocess mode ignores it.

``sandbox`` is the resolved sandbox spec (provider + provider_config)
from the deployment's snapshotted environment. Container backends compile
it into the DeploymentConfig's backend_config for sandbox providers
(e.g. openshell). Subprocess mode ignores it.
"""
...

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ async def _start_deployment(self, dep: AgentDeployment) -> None:
created_by=dep.created_by,
resources=dep.compute.resources if dep.compute is not None else None,
secrets=dep.secrets or None,
sandbox=dep.sandbox,
)
except Exception as exc:
logger.exception("Failed to start agent for deployment '%s'", dep.name)
Expand Down
Loading
Loading