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
123 changes: 122 additions & 1 deletion plugins/nemo-agents/src/nemo_agents_plugin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,11 @@
)
from nemo_agents_plugin.entities import (
CONTAINER_DEPLOYMENT_MODES,
MAX_AGENT_SPEC_STAGED_BYTES,
MAX_AGENT_SPEC_STAGED_FILES,
NAT_WORKFLOW_CONFIG_FORMAT,
NEMO_AGENTS_SPEC_CONFIG_FORMAT,
agent_spec_fileset_name,
)
from nemo_agents_plugin.leaderboard.cli import register_leaderboard_commands
from nemo_agents_plugin.usage.cli import register_usage_commands
Expand Down Expand Up @@ -784,6 +787,38 @@ def create(
"config_format": config_format,
}
resp = _api_request("POST", base_url, f"/apis/agents/v2/workspaces/{workspace}/agents", json_body=payload)
if config_format == NEMO_AGENTS_SPEC_CONFIG_FORMAT:
try:
_upload_agent_spec_fileset(
agent_name=name,
workspace=workspace,
agent_root=agent_config.parent,
base_url=base_url,
)
except Exception as exc:
typer.echo(
f"Error: failed to upload agent spec fileset for {name!r}: {exc}",
err=True,
)
try:
_delete_agent_entity(
agent_name=name,
workspace=workspace,
base_url=base_url,
)
# ``typer.Exit`` subclasses ``Exception``, so this also covers the
# exit raised by ``_api_request`` on an HTTP error.
except Exception:
logger.exception(
"Failed to roll back agent %r after fileset upload failure",
name,
)
typer.echo(
f"Error: failed to roll back agent {name!r}; it may still exist on the "
f"platform. Remove it with `nemo agents delete {name}`.",
err=True,
)
raise typer.Exit(code=1) from exc
Comment thread
coderabbitai[bot] marked this conversation as resolved.
typer.echo(json.dumps(resp, indent=2))

@app.command(name="list", rich_help_panel="Agent Resources (requires running cluster)")
Expand Down Expand Up @@ -840,7 +875,7 @@ def delete(
base_url = _resolve_base_url(base_url)
if not yes:
typer.confirm(f"Delete agent '{name}'?", abort=True)
_api_request("DELETE", base_url, f"/apis/agents/v2/workspaces/{workspace}/agents/{name}")
_delete_agent_entity(agent_name=name, workspace=workspace, base_url=base_url)
typer.echo(f"Agent '{name}' deleted.")

@app.command(rich_help_panel="Agent Resources (requires running cluster)")
Expand Down Expand Up @@ -1535,6 +1570,92 @@ def _api_request(method: str, base_url: str, path: str, *, json_body: dict[str,
raise typer.Exit(code=1)


def _platform_sdk(base_url: str) -> Any:
"""Return an auth-aware platform SDK client for fileset upload/delete."""
from nemo_platform import NeMoPlatform

headers = _resolve_context_headers()
if headers:
return NeMoPlatform(base_url=base_url, default_headers=headers)
return NeMoPlatform(base_url=base_url)


def _check_agent_root_bounds(agent_root: Path) -> None:
"""Reject an agent root too large to deliver into a container deployment.

The upload is recursive with no server-side filtering, so an ``agent.yaml``
sitting in a source checkout would ship the whole tree and then fail at
container start when the ConfigMap/env payload is built. Fail here instead,
naming the limit that was exceeded.

Symlinks are rejected rather than skipped: the upload enumerates them and
ships their target content, so skipping one here would both stage files from
outside *agent_root* and let them evade the limits below.
"""
total_bytes = 0
file_count = 0
for path in agent_root.rglob("*"):
if path.is_symlink():
raise ValueError(
f"agent directory {str(agent_root)!r} contains symlink "
f"{path.relative_to(agent_root).as_posix()!r}; the fileset upload follows "
"symlinks, which would stage content from outside the agent directory. "
"Replace it with a regular file or move the target inside the agent directory"
)
if not path.is_file():
continue
file_count += 1
total_bytes += path.stat().st_size
if file_count > MAX_AGENT_SPEC_STAGED_FILES:
raise ValueError(
f"agent directory {str(agent_root)!r} holds more than "
f"{MAX_AGENT_SPEC_STAGED_FILES} files; point --agent-config at a "
"directory containing only the agent's own artifacts"
)
if total_bytes > MAX_AGENT_SPEC_STAGED_BYTES:
raise ValueError(
f"agent directory {str(agent_root)!r} exceeds the "
f"{MAX_AGENT_SPEC_STAGED_BYTES} byte limit for container config delivery; "
"point --agent-config at a directory containing only the agent's own artifacts"
)


def _upload_agent_spec_fileset(
*,
agent_name: str,
workspace: str,
agent_root: Path,
base_url: str,
) -> None:
"""Upload *agent_root* into the conventional ``{agent}-spec`` fileset.

*agent_root* is ``agent.yaml``'s parent directory (Fabric ``base_dir``).
Agent YAML must live in a dedicated agent root so sibling artifacts
(skills, prompts) upload without shipping an unrelated checkout tree.
"""
from nemo_agents_plugin.jobs.fileset_io import upload_to_fileset

_check_agent_root_bounds(agent_root)
upload_to_fileset(
agent_root,
fileset=agent_spec_fileset_name(agent_name),
workspace=workspace,
sdk=_platform_sdk(base_url),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def _delete_agent_entity(*, agent_name: str, workspace: str, base_url: str) -> None:
"""Delete the agent entity, leaving the ``{agent}-spec`` fileset in place.

The fileset outlives the agent on purpose: it is the canonical home of
``AGENT-SPEC.md`` (see ``agent_spec_file_ref``), which ``nemo-spec`` writes
before the agent exists and ``nemo-build-agent`` reads on every rebuild.
Deleting the fileset here would destroy that durable contract, so the
executable artifacts it also carries are left behind instead.
"""
_api_request("DELETE", base_url, f"/apis/agents/v2/workspaces/{workspace}/agents/{agent_name}")


def _load_yaml(path: Path) -> dict:
with open(path, encoding="utf-8") as fh:
return yaml.safe_load(fh)
Expand Down
10 changes: 10 additions & 0 deletions plugins/nemo-agents/src/nemo_agents_plugin/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,16 @@ class Endpoint(BaseModel):
NEMO_AGENTS_SPEC_CONFIG_FORMAT = "nemo-agents-spec-v1"
"""Canonical format tag for the Platform-owned agent.yaml spec format."""

# Container deployments deliver the spec fileset through a ConfigMap (k8s) or a
# single env var (docker), both of which cap out around 1MiB. Bound the tree at
# both ends of the pipe so an agent root pointed at a whole checkout fails at
# upload time with a clear message instead of at container start.
MAX_AGENT_SPEC_STAGED_BYTES = 900_000
"""Maximum total bytes an agent spec fileset may contribute to a deployment."""

MAX_AGENT_SPEC_STAGED_FILES = 500
"""Maximum number of files an agent spec fileset may contain."""


def agent_spec_fileset_name(agent_name: str) -> str:
"""Return the conventional fileset name holding an agent's spec."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ async def create_deployment(
config: dict[str, Any],
port: int,
*,
agent: str = "",
image: str | None = None,
deployment_mode: DeploymentMode = "subprocess",
created_by: str | None = None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ async def _start_deployment(self, dep: AgentDeployment) -> None:
name=dep.name,
config=dep.config,
port=port,
agent=dep.agent,
image=dep.image or None,
deployment_mode=dep.deployment_mode,
created_by=dep.created_by,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
from __future__ import annotations

import asyncio
import base64
import copy
import json
import logging
import shlex
import time
Expand All @@ -34,6 +36,10 @@
)
from nemo_agents_plugin.fabric.gateway_credentials import platform_gateway_credential_env
from nemo_agents_plugin.runner.backend import DeploymentInfo, ExternalLog, LogLocation, RunnerBackend
from nemo_agents_plugin.runner.fabric_artifact_staging import (
FabricArtifactStagingError,
stage_fabric_spec_config_files,
)
from nemo_agents_plugin.utils import get_base_url, get_internal_base_url
from nemo_deployments_plugin.auth_proxy import auth_proxy_port
from nemo_deployments_plugin.entities import (
Expand Down Expand Up @@ -64,6 +70,7 @@
_NAT_CONFIG_YAML_ENV = "NAT_CONFIG_YAML"
_AGENT_CONFIG_YAML_ENV = "AGENT_CONFIG_YAML"
_AGENT_CONFIG_PATH_ENV = "AGENT_CONFIG_PATH"
_STAGED_CONFIG_FILES_ENV = "STAGED_CONFIG_FILES_B64_JSON"
_FABRIC_SERVER_MODULE = "nemo_agents_plugin.fabric.server"
_AUTH_PROXY_IDENTITY = "agents"

Expand Down Expand Up @@ -243,6 +250,18 @@ def _materialize_config_and_exec(*, config_path: str, yaml_env: str, argv: list[
return [f'mkdir -p "$(dirname {quoted_path})" && printf "%s" "${yaml_env}" > {quoted_path} && exec {quoted_argv}']


def _materialize_staged_config_files_and_exec(*, env_name: str, argv: list[str]) -> list[str]:
"""Return ``sh -c`` args that write staged ``config_files`` from *env_name*, then exec *argv*."""
quoted_argv = " ".join(shlex.quote(arg) for arg in argv)
inline_python = (
"import base64,json,os,pathlib;"
f"data=json.loads(os.environ[{json.dumps(env_name)}]);"
"[(pathlib.Path(p).parent.mkdir(parents=True,exist_ok=True),"
"pathlib.Path(p).write_bytes(base64.b64decode(b))) for p,b in data.items()]"
)
return [f"python -c {shlex.quote(inline_python)} && exec {quoted_argv}"]


def executor_for_mode(config: DeploymentsRunnerConfig, mode: DeploymentMode) -> str | None:
"""Resolve the named deployments-plugin executor for *mode*."""
if mode == "docker":
Expand Down Expand Up @@ -291,6 +310,7 @@ def build_deployment_config(
labels: dict[str, str] | None = None,
auth_proxy_identity: str | None = None,
auth_proxy_on_behalf_of: str | None = None,
config_files: list[ConfigFile] | None = None,
) -> DeploymentConfig:
"""Compile an agent into a long-running ``DeploymentConfig`` (Always).

Expand All @@ -314,6 +334,7 @@ def build_deployment_config(
is_fabric = _is_fabric_agent_config(agent_config)
config_yaml = yaml.safe_dump(agent_config, sort_keys=False)
config_path = _fabric_config_mount_path(config_mount_path) if is_fabric else config_mount_path
resolved_config_files = config_files or [ConfigFile(path=config_path, content=config_yaml)]
env = [
EnvVar(name="NMP_WORKSPACE", value=workspace),
EnvVar(name="NMP_AGENT_NAME", value=name),
Expand Down Expand Up @@ -371,14 +392,27 @@ def build_deployment_config(
]

if mode == "docker":
# Docker backend does not mount config_files; materialize the YAML from env.
env.append(EnvVar(name=config_yaml_env, value=config_yaml))
command = ["sh", "-c"]
args = _materialize_config_and_exec(
config_path=config_path,
yaml_env=config_yaml_env,
argv=[*server_command, *server_args],
)
# Docker backend does not mount config_files; materialize staged files from env.
if len(resolved_config_files) == 1:
single = resolved_config_files[0]
env.append(EnvVar(name=config_yaml_env, value=single.content))
command = ["sh", "-c"]
args = _materialize_config_and_exec(
config_path=single.path,
yaml_env=config_yaml_env,
argv=[*server_command, *server_args],
)
else:
payload = {
config_file.path: base64.b64encode(config_file.content.encode("utf-8")).decode("ascii")
for config_file in resolved_config_files
}
env.append(EnvVar(name=_STAGED_CONFIG_FILES_ENV, value=json.dumps(payload, separators=(",", ":"))))
command = ["sh", "-c"]
args = _materialize_staged_config_files_and_exec(
env_name=_STAGED_CONFIG_FILES_ENV,
argv=[*server_command, *server_args],
)
else:
command = server_command
args = server_args
Expand Down Expand Up @@ -412,9 +446,7 @@ def build_deployment_config(
).model_copy(
update={
"init_containers": init_containers,
"config_files": [
ConfigFile(path=config_path, content=config_yaml),
],
"config_files": resolved_config_files,
"restart_policy": "Always",
"auth_proxy_sidecar": auth_proxy_identity is not None,
"auth_proxy_sidecar_identity": auth_proxy_identity,
Expand Down Expand Up @@ -443,6 +475,7 @@ async def create_deployment(
config: dict[str, Any],
port: int,
*,
agent: str = "",
image: str | None = None,
deployment_mode: DeploymentMode = "docker",
created_by: str | None = None,
Expand Down Expand Up @@ -519,6 +552,22 @@ async def create_deployment(
if is_fabric:
deployment_labels["nemo.agents/runtime"] = "fabric"

staged_config_files: list[ConfigFile] | None = None
if is_fabric and agent:
agent_yaml_path = _fabric_config_mount_path(self._config.config_mount_path)
try:
sdk = get_async_platform_sdk(as_service="agents", internal=True)
staged_config_files = await stage_fabric_spec_config_files(
workspace=workspace,
agent_name=agent,
rewritten_agent_config=config,
agent_yaml_path=agent_yaml_path,
sdk=sdk.files,
)
except FabricArtifactStagingError as exc:
logger.error("Refusing to deploy Fabric agent %r: %s", name, exc)
return DeploymentInfo(name=name, status="failed", error=str(exc))

deployment_config = build_deployment_config(
name=name,
workspace=workspace,
Expand All @@ -532,6 +581,7 @@ async def create_deployment(
labels=deployment_labels,
auth_proxy_identity=auth_proxy_identity,
auth_proxy_on_behalf_of=auth_proxy_on_behalf_of,
config_files=staged_config_files,
)
await entities.create(deployment_config)
try:
Expand Down
Loading
Loading