From 093268387cd2a75ab12caa2b1e7966410d00e69c Mon Sep 17 00:00:00 2001 From: Ben McCown Date: Tue, 18 Aug 2026 13:29:13 -0600 Subject: [PATCH 1/5] feat(agents): add AgentEnvironment / EnvironmentSpec / ComputeSpec entities Introduce the RFC-122 environment composition for agent deployments. An AgentDeployment can now reference an AgentEnvironment (ref | inline | None) composed of an EnvironmentSpec (the dependencies an agent reaches - env vars, secrets, model provider, MCP fulfillment, Fabric environment mirror) and a ComputeSpec (k8s-style resource requests/limits). Entities (all first-class with CRUD APIs): - agent_compute_spec, agent_environment_spec, agent_environment. - Inline BaseModels are shared so a field accepts a 'workspace/name' ref or the inline spec. Compile / snapshot at deploy time: - resolve_environment dereferences the environment + its specs. - merge_environment_spec_into_agent_config merges the EnvironmentSpec into the nemo-agents-spec-v1 config with Agent-config-wins precedence (spec is the fulfillment base; the Agent's explicit values are preserved). env, Fabric mirror fields, model_provider_override, and MCP fulfillment merge in; the harness workspace path is carried as workspace_path to avoid colliding with the entity's tenant workspace field. - AgentDeployment snapshots the raw environment (provenance) and the resolved compute; content is merged into config. A deployment is not kept in sync with the underlying entities after creation. - The translator forwards environment.env + mirror fields into FabricConfig.environment; the container backend compiles the compute snapshot into Container.resources (k8s passes both requests+limits, docker consolidates to limits). Subprocess ignores compute. Backward compatible: all new fields default to None/empty, so agent configs authored without an environment behave identically. Secret env vars declared on an EnvironmentSpec ride the generalized deployments-plugin secret injection from the parent PR. Signed-off-by: Ben McCown --- .../src/nemo_agents_plugin/agent_config.py | 8 + .../src/nemo_agents_plugin/api/v2/_perms.py | 21 ++ .../nemo_agents_plugin/api/v2/deployments.py | 31 +- .../nemo_agents_plugin/api/v2/environments.py | 326 ++++++++++++++++++ .../src/nemo_agents_plugin/entities.py | 206 ++++++++++- .../environment_resolution.py | 241 +++++++++++++ .../nemo_agents_plugin/fabric/translator.py | 38 +- .../src/nemo_agents_plugin/runner/backend.py | 8 +- .../nemo_agents_plugin/runner/controller.py | 1 + .../runner/deployments_backend.py | 22 ++ .../nemo_agents_plugin/runner/in_memory.py | 12 +- .../src/nemo_agents_plugin/schema.py | 46 +++ .../src/nemo_agents_plugin/service.py | 7 + .../tests/unit/test_deployments_api.py | 96 +++++- .../nemo-agents/tests/unit/test_entities.py | 117 +++++++ .../tests/unit/test_environment_resolution.py | 194 +++++++++++ .../tests/unit/test_environments_api.py | 151 ++++++++ .../tests/unit/test_fabric_translator.py | 35 ++ .../tests/unit/test_runner_deployments.py | 73 +++- 19 files changed, 1619 insertions(+), 14 deletions(-) create mode 100644 plugins/nemo-agents/src/nemo_agents_plugin/api/v2/environments.py create mode 100644 plugins/nemo-agents/src/nemo_agents_plugin/environment_resolution.py create mode 100644 plugins/nemo-agents/tests/unit/test_environment_resolution.py create mode 100644 plugins/nemo-agents/tests/unit/test_environments_api.py diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py b/plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py index c75a683d8d..e7f8c061ca 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py @@ -50,6 +50,14 @@ class EnvironmentConfig(BaseModel): workspace: str = "./workspace" artifacts: str = "./artifacts" settings: dict[str, Any] = Field(default_factory=dict) + # Fabric environment mirror fields (RFC-122). Additive with backward-compatible + # defaults; populated when an AgentEnvironmentSpec is merged at deploy time and + # forwarded into FabricConfig.environment by the translator. + env: dict[str, str] = Field(default_factory=dict) + control_location: str | None = None + ownership: str | None = None + connection: dict[str, Any] = Field(default_factory=dict) + metadata: dict[str, Any] = Field(default_factory=dict) class TelemetryConfig(BaseModel): diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/_perms.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/_perms.py index 413ce91274..60b863fc02 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/_perms.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/_perms.py @@ -31,3 +31,24 @@ class DeploymentPerms(PermissionSet, namespace="agents.deployments"): class GatewayPerms(PermissionSet, namespace="agents.gateway"): INVOKE = perm("Invoke a deployed agent through the gateway proxy") + + +class EnvironmentPerms(PermissionSet, namespace="agents.environments"): + CREATE = perm("Create agent environments") + LIST = perm("List agent environments") + READ = perm("Read an agent environment") + DELETE = perm("Delete an agent environment") + + +class EnvironmentSpecPerms(PermissionSet, namespace="agents.environment_specs"): + CREATE = perm("Create agent environment specs") + LIST = perm("List agent environment specs") + READ = perm("Read an agent environment spec") + DELETE = perm("Delete an agent environment spec") + + +class ComputeSpecPerms(PermissionSet, namespace="agents.compute_specs"): + CREATE = perm("Create agent compute specs") + LIST = perm("List agent compute specs") + READ = perm("Read an agent compute spec") + DELETE = perm("Delete an agent compute spec") diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py index ab82944f13..45e6dbd86f 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py @@ -30,6 +30,12 @@ AgentDeployment, is_container_deployment_mode, ) +from nemo_agents_plugin.environment_resolution import ( + EnvironmentResolutionError, + ResolvedEnvironment, + merge_environment_spec_into_agent_config, + resolve_environment, +) from nemo_agents_plugin.schema import ( CreateDeploymentRequest, DeploymentFilter, @@ -83,12 +89,23 @@ async def create_deployment( # Platform-owned agent specs stay strict and are translated by the runner. resolved_config = _resolve_deployment_config(agent, workspace=workspace) - # 4. Create the entity with status "pending" + # 4. Resolve and snapshot the referenced AgentEnvironment. The environment + # spec is merged into the resolved config (Agent-config-wins precedence) and + # the compute spec is snapshotted for the container backend. Once created, a + # deployment is not kept in sync with the underlying environment entities. + resolved_environment = await _resolve_deployment_environment( + body.environment, workspace=workspace, entity_client=entity_client + ) + resolved_config = merge_environment_spec_into_agent_config(resolved_config, resolved_environment.environment_spec) + + # 5. Create the entity with status "pending" deployment = AgentDeployment( name=deployment_name, workspace=workspace, agent=body.agent, config=resolved_config, + environment=body.environment, + compute=resolved_environment.compute_spec, status="pending", deployment_mode=body.deployment_mode, image=body.image, @@ -120,6 +137,18 @@ def _resolve_deployment_config(agent: Agent, *, workspace: str) -> dict[str, Any raise HTTPException(status_code=400, detail=str(exc)) from exc +async def _resolve_deployment_environment( + environment: Any, + *, + workspace: str, + entity_client: NemoEntitiesClient, +) -> ResolvedEnvironment: + try: + return await resolve_environment(environment, workspace=workspace, entity_client=entity_client) + except EnvironmentResolutionError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.get("/deployments", response_model=DeploymentPage, tags=["Agent Deployments"]) @scope.read @path_rule( diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/environments.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/environments.py new file mode 100644 index 0000000000..6bf6f7d7f2 --- /dev/null +++ b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/environments.py @@ -0,0 +1,326 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""CRUD routes for AgentEnvironment, AgentEnvironmentSpec, and AgentComputeSpec. + +Mounted under ``/apis/agents/v2/workspaces/{workspace}`` at: +- ``/environments`` (AgentEnvironment) +- ``/environment-specs`` (AgentEnvironmentSpec) +- ``/compute-specs`` (AgentComputeSpec) + +Each collection is a thin CRUD surface over the generic entity client, matching +the Agent/Deployment route conventions (mandatory ``@path_rule`` authz, generic +NemoEntitiesClient, 404/409 mapping). +""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query +from nemo_agents_plugin.api.v2._perms import ComputeSpecPerms, EnvironmentPerms, EnvironmentSpecPerms +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.schema import ( + ComputeSpecFilter, + ComputeSpecPage, + CreateComputeSpecRequest, + CreateEnvironmentRequest, + CreateEnvironmentSpecRequest, + EnvironmentFilter, + EnvironmentPage, + EnvironmentSpecFilter, + EnvironmentSpecPage, +) +from nemo_platform_plugin.api.filters import make_filter_obj_dep +from nemo_platform_plugin.authz import CallerKind, path_rule +from nemo_platform_plugin.entity_client import NemoEntitiesClient, NemoEntityConflictError, NemoEntityNotFoundError +from nemo_platform_plugin.schema import PaginationData + +logger = logging.getLogger(__name__) + +router = APIRouter() + +_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) + + +# --------------------------------------------------------------------------- +# AgentEnvironment +# --------------------------------------------------------------------------- + + +@router.post("/environments", response_model=AgentEnvironment, status_code=201, tags=["Agent Environments"]) +@scope.write +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[EnvironmentPerms.CREATE]) +async def create_environment( + workspace: str, + body: CreateEnvironmentRequest, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> AgentEnvironment: + """Create a new AgentEnvironment.""" + environment = AgentEnvironment( + name=body.name, + workspace=workspace, + description=body.description, + environment_spec=body.environment_spec, + compute_spec=body.compute_spec, + ) + return await _create_entity(entity_client, environment, kind="environment", name=body.name, workspace=workspace) + + +@router.get("/environments", response_model=EnvironmentPage, tags=["Agent Environments"]) +@scope.read +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[EnvironmentPerms.LIST]) +async def list_environments( + 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: EnvironmentFilter = Depends(_environment_filter_dep), + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> EnvironmentPage: + """List AgentEnvironments in the workspace.""" + return await _list_entities( + entity_client, + AgentEnvironment, + EnvironmentPage, + workspace=workspace, + page=page, + page_size=page_size, + sort=sort, + filter=filter, + kind="environments", + ) + + +@router.get("/environments/{name}", response_model=AgentEnvironment, tags=["Agent Environments"]) +@scope.read +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[EnvironmentPerms.READ]) +async def get_environment( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> AgentEnvironment: + """Get an AgentEnvironment by name.""" + return await _get_entity(entity_client, AgentEnvironment, name=name, workspace=workspace, kind="environment") + + +@router.delete("/environments/{name}", status_code=204, tags=["Agent Environments"]) +@scope.write +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[EnvironmentPerms.DELETE]) +async def delete_environment( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> None: + """Delete an AgentEnvironment by name.""" + await _delete_entity(entity_client, AgentEnvironment, name=name, workspace=workspace, kind="environment") + + +# --------------------------------------------------------------------------- +# AgentEnvironmentSpec +# --------------------------------------------------------------------------- + + +@router.post( + "/environment-specs", response_model=AgentEnvironmentSpec, status_code=201, tags=["Agent Environment Specs"] +) +@scope.write +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[EnvironmentSpecPerms.CREATE]) +async def create_environment_spec( + workspace: str, + body: CreateEnvironmentSpecRequest, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> AgentEnvironmentSpec: + """Create a new AgentEnvironmentSpec.""" + spec = AgentEnvironmentSpec(**body.model_dump(), workspace=workspace) + return await _create_entity(entity_client, spec, kind="environment spec", name=body.name, workspace=workspace) + + +@router.get("/environment-specs", response_model=EnvironmentSpecPage, tags=["Agent Environment Specs"]) +@scope.read +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[EnvironmentSpecPerms.LIST]) +async def list_environment_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: EnvironmentSpecFilter = Depends(_environment_spec_filter_dep), + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> EnvironmentSpecPage: + """List AgentEnvironmentSpecs in the workspace.""" + return await _list_entities( + entity_client, + AgentEnvironmentSpec, + EnvironmentSpecPage, + workspace=workspace, + page=page, + page_size=page_size, + sort=sort, + filter=filter, + kind="environment specs", + ) + + +@router.get("/environment-specs/{name}", response_model=AgentEnvironmentSpec, tags=["Agent Environment Specs"]) +@scope.read +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[EnvironmentSpecPerms.READ]) +async def get_environment_spec( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> AgentEnvironmentSpec: + """Get an AgentEnvironmentSpec by name.""" + return await _get_entity( + entity_client, AgentEnvironmentSpec, name=name, workspace=workspace, kind="environment spec" + ) + + +@router.delete("/environment-specs/{name}", status_code=204, tags=["Agent Environment Specs"]) +@scope.write +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[EnvironmentSpecPerms.DELETE]) +async def delete_environment_spec( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> None: + """Delete an AgentEnvironmentSpec by name.""" + await _delete_entity(entity_client, AgentEnvironmentSpec, name=name, workspace=workspace, kind="environment spec") + + +# --------------------------------------------------------------------------- +# AgentComputeSpec +# --------------------------------------------------------------------------- + + +@router.post("/compute-specs", response_model=AgentComputeSpec, status_code=201, tags=["Agent Compute Specs"]) +@scope.write +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[ComputeSpecPerms.CREATE]) +async def create_compute_spec( + workspace: str, + body: CreateComputeSpecRequest, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> AgentComputeSpec: + """Create a new AgentComputeSpec.""" + spec = AgentComputeSpec(**body.model_dump(), workspace=workspace) + return await _create_entity(entity_client, spec, kind="compute spec", name=body.name, workspace=workspace) + + +@router.get("/compute-specs", response_model=ComputeSpecPage, tags=["Agent Compute Specs"]) +@scope.read +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[ComputeSpecPerms.LIST]) +async def list_compute_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: ComputeSpecFilter = Depends(_compute_spec_filter_dep), + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> ComputeSpecPage: + """List AgentComputeSpecs in the workspace.""" + return await _list_entities( + entity_client, + AgentComputeSpec, + ComputeSpecPage, + workspace=workspace, + page=page, + page_size=page_size, + sort=sort, + filter=filter, + kind="compute specs", + ) + + +@router.get("/compute-specs/{name}", response_model=AgentComputeSpec, tags=["Agent Compute Specs"]) +@scope.read +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[ComputeSpecPerms.READ]) +async def get_compute_spec( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> AgentComputeSpec: + """Get an AgentComputeSpec by name.""" + return await _get_entity(entity_client, AgentComputeSpec, name=name, workspace=workspace, kind="compute spec") + + +@router.delete("/compute-specs/{name}", status_code=204, tags=["Agent Compute Specs"]) +@scope.write +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[ComputeSpecPerms.DELETE]) +async def delete_compute_spec( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> None: + """Delete an AgentComputeSpec by name.""" + await _delete_entity(entity_client, AgentComputeSpec, name=name, workspace=workspace, kind="compute spec") + + +# --------------------------------------------------------------------------- +# Shared CRUD helpers +# --------------------------------------------------------------------------- + + +async def _create_entity(entity_client, entity, *, kind: str, name: str, workspace: str): + try: + return await entity_client.create(entity) + except NemoEntityConflictError as exc: + raise HTTPException( + status_code=409, + detail=f"Agent {kind} '{name}' already exists in workspace '{workspace}'.", + ) from exc + except Exception as exc: + logger.exception("Failed to create agent %s '%s'", kind, name) + raise HTTPException(status_code=500, detail=f"Failed to create agent {kind}.") from exc + + +async def _list_entities(entity_client, entity_type, page_type, *, workspace, page, page_size, sort, filter, kind): + filter_dict = filter if isinstance(filter, dict) else filter.model_dump(exclude_none=True) + try: + result = await entity_client.list( + entity_type, + workspace=workspace, + page=page, + page_size=page_size, + sort=sort, + filter_obj=filter_dict or None, + ) + except Exception as exc: + logger.exception("Failed to list agent %s in workspace '%s'", kind, workspace) + raise HTTPException(status_code=500, detail=f"Failed to list agent {kind}.") from exc + + pagination = PaginationData.model_validate(result.pagination.model_dump()) if result.pagination else None + return page_type(data=result.data, pagination=pagination, sort=sort, filter=filter) + + +async def _get_entity(entity_client, entity_type, *, name: str, workspace: str, kind: str): + try: + return await entity_client.get(entity_type, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"Agent {kind} '{name}' not found in workspace '{workspace}'.", + ) from exc + except Exception as exc: + logger.exception("Failed to get agent %s '%s'", kind, name) + raise HTTPException(status_code=500, detail=f"Failed to get agent {kind}.") from exc + + +async def _delete_entity(entity_client, entity_type, *, name: str, workspace: str, kind: str) -> None: + try: + await entity_client.delete(entity_type, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"Agent {kind} '{name}' not found in workspace '{workspace}'.", + ) from exc + except NemoEntityConflictError as exc: + raise HTTPException( + status_code=409, + detail=f"Agent {kind} '{name}' was modified by another request in workspace '{workspace}'.", + ) from exc + except Exception as exc: + logger.exception("Failed to delete agent %s '%s'", kind, name) + raise HTTPException(status_code=500, detail=f"Failed to delete agent {kind}.") from exc diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/entities.py b/plugins/nemo-agents/src/nemo_agents_plugin/entities.py index d3eab6ccfe..cdd658e6f3 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/entities.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/entities.py @@ -48,6 +48,163 @@ class Endpoint(BaseModel): protocol: Literal["http", "https", "grpc", "tcp"] = "http" +# --------------------------------------------------------------------------- +# AgentEnvironment composition (RFC-122) +# --------------------------------------------------------------------------- +# +# An AgentDeployment (and, later, an AgentInvocationJob) runs against an +# AgentEnvironment: a composition of an EnvironmentSpec (the dependencies an +# agent reaches - model endpoints, secrets, env vars, MCP servers) and a +# ComputeSpec (k8s-style resource requests/limits). Each part varies +# independently and is referenced by ``ref | inline | None`` so a spec can be +# authored once and reused across many Environments. +# +# The specs are also first-class entities (``agent_environment_spec``, +# ``agent_compute_spec``, ``agent_environment``) with their own CRUD APIs. The +# inline BaseModels below are the shared shape: an entity embeds the inline +# fields, and an AgentEnvironment field accepts either a ``"workspace/name"`` +# ref string or the inline model. +# +# Environment values compile into two targets at deploy time: the on-disk +# agent.yaml / FabricConfig (env vars, MCP, model provider) and, for container +# modes, the deployments-plugin Container.resources (compute). See +# :mod:`nemo_agents_plugin.environment_resolution` for the merge + snapshot. + + +class ComputeResources(BaseModel): + """Kubernetes-style resource requests/limits. + + Mirrors ``nemo_deployments_plugin.entities.ResourceRequirements`` so the + agents entity schema does not depend on the deployments plugin. Compiled + into the execute container's resources for container deployment modes. + """ + + limits: dict[str, str] = Field( + default_factory=dict, + description="k8s resource limits (e.g. cpu, memory, nvidia.com/gpu).", + ) + requests: dict[str, str] = Field( + default_factory=dict, + description="k8s resource requests.", + ) + + +class ComputeSpecInline(BaseModel): + """Inline compute spec - the resources an invocation runs with.""" + + description: str = Field(default="", description="Human-readable description.") + resources: ComputeResources = Field( + default_factory=ComputeResources, + description="k8s-style resource requests/limits for the execute container.", + ) + + +class ModelProviderOverride(BaseModel): + """Exceptional external model-provider override. + + Null in the normal case: model selection is on the Agent and the provider + URL is the Inference Gateway (auto-injected). Set ONLY to point the agent at + a non-IGW external provider endpoint. + """ + + base_url: str = Field(description="External model-provider endpoint.") + api_key: str | None = Field( + default=None, + description="Secrets-service ref for the provider API key (only needed for external providers).", + ) + provider: str | None = Field( + default=None, + description='Provider selector (e.g. "openai", "anthropic").', + ) + + +class McpFulfillment(BaseModel): + """EnvironmentSpec-side fulfillment for one MCP server the Agent declares. + + The Agent DECLARES an MCP dependency by name; the EnvironmentSpec PROVIDES + the url + env + secrets for that same name. Matched by server-name key at + compile time; ``secrets`` are merged into the server's ``env``. + """ + + url: str = Field(description="Endpoint the environment provides for this MCP server.") + env: dict[str, str] = Field(default_factory=dict, description="Non-secret env for the MCP server.") + secrets: dict[str, str] = Field( + default_factory=dict, + description="ENV_NAME -> Secrets-service ref, merged into the MCP server env at compile.", + ) + + +class EnvironmentSpecInline(BaseModel): + """Inline environment spec - the dependencies and configuration an agent reaches. + + This is the fulfillment half of a request/fulfill split: the Agent declares + the dependencies it needs; the EnvironmentSpec provides concrete endpoints + and secret references. It compiles into the agent.yaml / FabricConfig and + the injected process environment. + """ + + description: str = Field(default="", description="Human-readable description.") + + # Env vars -> injected into the runtime process env (not authored on disk). + env: dict[str, str] = Field(default_factory=dict, description="Plaintext, non-secret env vars.") + + # Secrets -> Secrets-service refs, injected as secret-backed env vars. + secrets: dict[str, str] = Field( + default_factory=dict, + description="ENV_VAR_NAME -> Secrets-service/plugin ref.", + ) + + # External model-provider override (exceptional; null in the normal IGW case). + model_provider_override: ModelProviderOverride | None = Field( + default=None, + description="Set only to point at a non-IGW external model provider.", + ) + + # Fabric environment mirror -> compiles into FabricConfig.environment. + # NOTE: ``workspace_path`` (the harness workspace path) is deliberately named + # to avoid colliding with the NeMo entity ``workspace`` (tenant) field that + # AgentEnvironmentSpec inherits from EntityBase. + provider: str = Field(default="local", description="local | docker | opensandbox | k8s.") + workspace_path: str | None = Field(default=None, description="Workspace path visible to the harness.") + artifacts: str | None = Field(default=None, description="Provider-specific artifact output location.") + control_location: str | None = Field( + default=None, + description="external_control | in_env_control.", + ) + ownership: str | None = Field(default=None, description="caller_owned | fabric_owned.") + connection: dict[str, Any] = Field( + default_factory=dict, + description="Provider connection metadata (server url, cred ref, namespace).", + ) + metadata: dict[str, Any] = Field(default_factory=dict, description="Consumer-provided passthrough metadata.") + settings: dict[str, Any] = Field(default_factory=dict, description="Provider-specific settings.") + + # MCP fulfillment -> merged into FabricConfig.mcp.servers. by server-name key. + mcp: dict[str, McpFulfillment] = Field( + default_factory=dict, + description="server-name -> fulfillment (url/env/secrets) for an Agent-declared MCP dependency.", + ) + + +class AgentEnvironmentInline(BaseModel): + """Inline AgentEnvironment - a composition of environment + 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. (``sandbox_spec`` is out of scope for RFC-122 and omitted.) + """ + + 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.', + ) + compute_spec: str | ComputeSpecInline | None = Field( + default=None, + description='"workspace/name" ref to an AgentComputeSpec, an inline spec, or None.', + ) + + # --------------------------------------------------------------------------- # Canonical spec storage convention # --------------------------------------------------------------------------- @@ -132,6 +289,31 @@ def agent_config_file_ref(workspace: str, agent_name: str) -> FilesetRef: return FilesetRef(f"{workspace}/{agent_spec_fileset_name(agent_name)}#{AGENT_CONFIG_FILENAME}") +class AgentComputeSpec(NemoEntity, ComputeSpecInline, entity_type="agent_compute_spec"): + """A reusable compute spec (k8s-style resource requests/limits). + + Entity type: ``agent_compute_spec`` + Referenced by an AgentEnvironment's ``compute_spec`` (by name or inline). + """ + + +class AgentEnvironmentSpec(NemoEntity, EnvironmentSpecInline, entity_type="agent_environment_spec"): + """A reusable environment spec (the dependencies an agent reaches). + + Entity type: ``agent_environment_spec`` + Referenced by an AgentEnvironment's ``environment_spec`` (by name or inline). + """ + + +class AgentEnvironment(NemoEntity, AgentEnvironmentInline, entity_type="agent_environment"): + """A composition of an environment spec and a compute spec. + + Entity type: ``agent_environment`` + The single thing an AgentDeployment references. Each part is a + ``ref | inline | None`` union so specs can be authored once and reused. + """ + + # TODO: RFC-122 will add specs for environment, sandbox, and harness. Add those # specs to this object once finalized. class Agent(NemoEntity, entity_type="agent"): @@ -174,7 +356,29 @@ class AgentDeployment(NemoEntity, entity_type="agent_deployment"): agent: str = Field(default="", description="Name of the Agent entity this deployment is for.") config: dict[str, Any] = Field( default_factory=dict, - description="Resolved agent config with IGW URL injected, written when the deployment is created.", + description=( + "Resolved agent config with IGW URL injected and any referenced environment spec merged in, " + "written when the deployment is created." + ), + ) + # AgentEnvironment is snapshotted at create time: ``environment`` records the + # raw request input for provenance, environment-spec content is merged into + # ``config``, and ``compute`` holds the resolved compute snapshot threaded to + # the container backend. A deployment is not kept in sync with the underlying + # AgentEnvironment/spec entities after creation. + environment: str | AgentEnvironmentInline | None = Field( + default=None, + description=( + '"workspace/name" ref to an AgentEnvironment, an inline environment, or None. ' + "Snapshotted at create time for provenance; the resolved values live in config/compute." + ), + ) + compute: ComputeSpecInline | None = Field( + default=None, + description=( + "Resolved compute snapshot from the referenced environment. Compiled into the container " + "resources for docker/k8s modes; ignored for subprocess." + ), ) status: DeploymentStatus = Field( default="pending", diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/environment_resolution.py b/plugins/nemo-agents/src/nemo_agents_plugin/environment_resolution.py new file mode 100644 index 0000000000..d85c8e7b66 --- /dev/null +++ b/plugins/nemo-agents/src/nemo_agents_plugin/environment_resolution.py @@ -0,0 +1,241 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Resolve an AgentEnvironment and merge its EnvironmentSpec into agent config. + +An AgentDeployment references an :class:`AgentEnvironment` (by name or inline). +At create time the deployment route: + +1. resolves the environment - dereferencing any ``"workspace/name"`` refs for + the environment and its two specs into concrete inline specs; +2. merges the resolved EnvironmentSpec into the Platform-owned agent config + (``nemo-agents-spec-v1``) so the Fabric translator sees a single config; and +3. snapshots the resolved ComputeSpec onto the deployment for the container + backend. + +Merge precedence is **Agent config wins over EnvironmentSpec**: the +EnvironmentSpec is the fulfillment/base layer, and any value the Agent set +explicitly is preserved. This keeps existing agent configs (authored without an +environment) behaving identically. +""" + +from __future__ import annotations + +import copy +from dataclasses import dataclass +from typing import Any + +from nemo_agents_plugin.entities import ( + NEMO_AGENTS_SPEC_CONFIG_FORMAT, + AgentComputeSpec, + AgentEnvironment, + AgentEnvironmentInline, + AgentEnvironmentSpec, + ComputeSpecInline, + EnvironmentSpecInline, +) +from nemo_platform_plugin.entities.base import parse_qualified_name +from nemo_platform_plugin.entity_client import NemoEntitiesClient, NemoEntityNotFoundError + + +class EnvironmentResolutionError(ValueError): + """Raised when an AgentEnvironment or one of its specs cannot be resolved.""" + + +@dataclass(frozen=True) +class ResolvedEnvironment: + """Concrete environment/compute specs resolved from an AgentEnvironment.""" + + environment_spec: EnvironmentSpecInline | None = None + compute_spec: ComputeSpecInline | None = None + + +async def resolve_environment( + environment: str | AgentEnvironmentInline | None, + *, + workspace: str, + entity_client: NemoEntitiesClient, +) -> ResolvedEnvironment: + """Resolve an AgentEnvironment (ref | inline | None) into concrete specs. + + Dereferences the environment and its ``environment_spec`` / ``compute_spec`` + refs. Missing refs raise :class:`EnvironmentResolutionError`. ``None`` yields + an empty :class:`ResolvedEnvironment` (no environment configured). + """ + if environment is None: + return ResolvedEnvironment() + + resolved_env = await _resolve_agent_environment(environment, workspace=workspace, entity_client=entity_client) + environment_spec = await _resolve_environment_spec( + resolved_env.environment_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) + + +async def _resolve_agent_environment( + environment: str | AgentEnvironmentInline, + *, + workspace: str, + entity_client: NemoEntitiesClient, +) -> AgentEnvironmentInline: + if isinstance(environment, str): + ref_workspace, name = parse_qualified_name(environment, default_workspace=workspace) + try: + return await entity_client.get(AgentEnvironment, name=name, workspace=ref_workspace) + except NemoEntityNotFoundError as exc: + raise EnvironmentResolutionError( + f"AgentEnvironment '{name}' not found in workspace '{ref_workspace}'." + ) from exc + return environment + + +async def _resolve_environment_spec( + spec: str | EnvironmentSpecInline | None, + *, + workspace: str, + entity_client: NemoEntitiesClient, +) -> EnvironmentSpecInline | 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(AgentEnvironmentSpec, name=name, workspace=ref_workspace) + except NemoEntityNotFoundError as exc: + raise EnvironmentResolutionError( + f"AgentEnvironmentSpec '{name}' not found in workspace '{ref_workspace}'." + ) from exc + return spec + + +async def _resolve_compute_spec( + spec: str | ComputeSpecInline | None, + *, + workspace: str, + entity_client: NemoEntitiesClient, +) -> ComputeSpecInline | 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(AgentComputeSpec, name=name, workspace=ref_workspace) + except NemoEntityNotFoundError as exc: + raise EnvironmentResolutionError( + f"AgentComputeSpec '{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, +) -> dict[str, Any]: + """Merge a resolved EnvironmentSpec into a ``nemo-agents-spec-v1`` config. + + Returns a deep-copied config with the spec merged in. Merge precedence is + Agent-config-wins: the Agent's explicitly-set values are preserved and the + EnvironmentSpec only fills gaps or contributes additive keys. + + Only ``nemo-agents-spec-v1`` (Fabric) configs are merged; other formats are + returned unchanged (they have no environment concept to fulfill). + """ + if env_spec is None: + return config + if config.get("config_format") != NEMO_AGENTS_SPEC_CONFIG_FORMAT: + return config + + merged = copy.deepcopy(config) + _merge_environment_block(merged, env_spec) + _merge_process_env(merged, env_spec) + _merge_model_provider_override(merged, env_spec) + _merge_mcp(merged, env_spec) + return merged + + +def _merge_environment_block(config: dict[str, Any], env_spec: EnvironmentSpecInline) -> None: + """Fill FabricConfig.environment mirror fields where the Agent left them unset.""" + environment = config.setdefault("environment", {}) + if not isinstance(environment, dict): + return + + # Scalar mirror fields: only fill when the Agent did not set them. The spec's + # ``workspace_path`` maps onto the config's ``workspace`` (the harness path); + # the entity/tenant ``workspace`` is unrelated and never merged here. + scalar_fields = { + "provider": "provider", + "workspace_path": "workspace", + "artifacts": "artifacts", + "control_location": "control_location", + "ownership": "ownership", + } + for spec_attr, config_key in scalar_fields.items(): + value = getattr(env_spec, spec_attr) + if value is not None and config_key not in environment: + environment[config_key] = value + + # Dict mirror fields: EnvironmentSpec is the base, Agent keys win on collision. + for field in ("connection", "metadata", "settings"): + spec_value = getattr(env_spec, field) + if spec_value: + environment[field] = {**spec_value, **environment.get(field, {})} + + +def _merge_process_env(config: dict[str, Any], env_spec: EnvironmentSpecInline) -> None: + """Merge plaintext env vars into environment.env (Agent keys win).""" + if not env_spec.env: + return + environment = config.setdefault("environment", {}) + if not isinstance(environment, dict): + return + existing = environment.get("env") + existing = existing if isinstance(existing, dict) else {} + environment["env"] = {**env_spec.env, **existing} + + +def _merge_model_provider_override(config: dict[str, Any], env_spec: EnvironmentSpecInline) -> None: + """Apply an external model-provider override to models.default where unset.""" + override = env_spec.model_provider_override + if override is None: + return + models = config.setdefault("models", {}) + if not isinstance(models, dict): + return + model = models.setdefault("default", {}) + if not isinstance(model, dict): + return + if "base_url" not in model: + model["base_url"] = override.base_url + if override.provider is not None and "provider" not in model: + model["provider"] = override.provider + if override.api_key is not None and "api_key_env" not in model: + model["api_key_env"] = override.api_key + + +def _merge_mcp(config: dict[str, Any], env_spec: EnvironmentSpecInline) -> None: + """Fulfill Agent-declared MCP servers by name (Agent keys win).""" + if not env_spec.mcp: + return + mcp = config.setdefault("mcp", {}) + if not isinstance(mcp, dict): + return + servers = mcp.setdefault("servers", {}) + if not isinstance(servers, dict): + return + + for name, fulfillment in env_spec.mcp.items(): + server = servers.get(name) + server = server if isinstance(server, dict) else {} + # url: fill only when the Agent did not provide one. + if "url" not in server: + server["url"] = fulfillment.url + # env + secrets merge into the server env; Agent-authored env wins. + merged_env = {**fulfillment.env, **fulfillment.secrets} + if merged_env: + existing_env = server.get("env") + existing_env = existing_env if isinstance(existing_env, dict) else {} + server["env"] = {**merged_env, **existing_env} + servers[name] = server diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py index e510ad6bc5..743d1d9104 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py @@ -52,13 +52,7 @@ def translate_agent_config(config: AgentConfig, harness_name: str | None = None) "default": fabric.ModelConfig(**model_payload), }, instructions=_instructions_config(config), - environment=fabric.EnvironmentConfig( - provider=config.environment.provider, - workspace=config.environment.workspace, - artifacts=config.environment.artifacts, - env=runtime_env, - settings=config.environment.settings, - ), + environment=_environment_config(config, runtime_env), skills=_skills_config(config), mcp=_mcp_config(config), tools=_tools_config(config), @@ -73,6 +67,36 @@ def _platform_runtime_env() -> dict[str, str]: return {name: value for name in PLATFORM_RUNTIME_ENV_VARS if (value := os.environ.get(name))} +def _environment_config(config: AgentConfig, runtime_env: dict[str, str]) -> Any: + """Build FabricConfig.environment, merging spec env + mirror fields. + + ``runtime_env`` carries platform-injected values (base URLs, gateway + credential). The EnvironmentSpec's plaintext ``env`` (merged onto + ``config.environment.env`` at deploy time) is layered underneath so + platform-injected values win on key collision. + """ + environment = config.environment + env = {**environment.env, **runtime_env} + kwargs: dict[str, Any] = { + "provider": environment.provider, + "workspace": environment.workspace, + "artifacts": environment.artifacts, + "env": env, + "settings": environment.settings, + } + # Forward optional Fabric mirror fields only when set, so defaults stay with + # Fabric rather than being pinned by the platform config. + if environment.control_location is not None: + kwargs["control_location"] = environment.control_location + if environment.ownership is not None: + kwargs["ownership"] = environment.ownership + if environment.connection: + kwargs["connection"] = environment.connection + if environment.metadata: + kwargs["metadata"] = environment.metadata + return fabric.EnvironmentConfig(**kwargs) + + def _select_harness(config: AgentConfig, harness_name: str | None) -> tuple[str, HarnessConfig]: selected_harness_name = harness_name or config.default_harness harness = config.harnesses.get(selected_harness_name) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/runner/backend.py b/plugins/nemo-agents/src/nemo_agents_plugin/runner/backend.py index e8a78e0ddb..a4133fa35c 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/runner/backend.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/runner/backend.py @@ -17,7 +17,7 @@ from pathlib import Path from typing import Any, Literal -from nemo_agents_plugin.entities import DeploymentMode, DeploymentStatus, Endpoint +from nemo_agents_plugin.entities import ComputeResources, DeploymentMode, DeploymentStatus, Endpoint @dataclass(frozen=True) @@ -97,6 +97,7 @@ async def create_deployment( image: str | None = None, deployment_mode: DeploymentMode = "subprocess", created_by: str | None = None, + resources: ComputeResources | None = None, ) -> DeploymentInfo: """Start the agent process; returns status="starting". @@ -105,6 +106,11 @@ async def create_deployment( agent's platform calls to this principal (on-behalf-of) so its access is scoped to what the creator can reach rather than the agents service principal's full reach. + + ``resources`` is the snapshotted compute spec's k8s-style + requests/limits. Container backends compile it into the execute + container's resources (k8s passes both; docker consolidates to limits). + Subprocess mode ignores it. """ ... diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/runner/controller.py b/plugins/nemo-agents/src/nemo_agents_plugin/runner/controller.py index ad7cf15969..0487d547c7 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/runner/controller.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/runner/controller.py @@ -190,6 +190,7 @@ async def _start_deployment(self, dep: AgentDeployment) -> None: image=dep.image or None, deployment_mode=dep.deployment_mode, created_by=dep.created_by, + resources=dep.compute.resources if dep.compute is not None else None, ) except Exception as exc: logger.exception("Failed to start agent for deployment '%s'", dep.name) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py b/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py index af49b2ac74..721243332c 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py @@ -27,6 +27,7 @@ AGENT_CONFIG_FILENAME, CONTAINER_DEPLOYMENT_MODES, NEMO_AGENTS_SPEC_CONFIG_FORMAT, + ComputeResources, DeploymentMode, DeploymentStatus, Endpoint, @@ -48,6 +49,7 @@ EnvVar, HTTPGetAction, Probe, + ResourceRequirements, VolumeMount, ) from nemo_platform_plugin.auth import platform_auth_enabled @@ -230,6 +232,22 @@ def _is_fabric_agent_config(agent_config: dict[str, Any]) -> bool: return agent_config.get("config_format") == NEMO_AGENTS_SPEC_CONFIG_FORMAT +def build_container_resources(resources: ComputeResources | None, *, mode: DeploymentMode) -> ResourceRequirements: + """Compile a snapshotted compute spec into the container's k8s resources. + + k8s passes both requests and limits through. Docker has no notion of + scheduling requests, so requests are consolidated into limits (limits win on + key collision). Returns an empty ``ResourceRequirements`` when no compute + spec was snapshotted (platform default). + """ + if resources is None: + return ResourceRequirements() + if mode == "docker": + consolidated = {**resources.requests, **resources.limits} + return ResourceRequirements(limits=consolidated, requests={}) + return ResourceRequirements(limits=dict(resources.limits), requests=dict(resources.requests)) + + def _fabric_config_mount_path(config_mount_path: str) -> str: parent = str(PurePosixPath(config_mount_path).parent) if parent in ("", "."): @@ -299,6 +317,7 @@ def build_deployment_config( auth_proxy_identity: str | None = None, auth_proxy_on_behalf_of: str | None = None, config_files: list[ConfigFile] | None = None, + resources: ComputeResources | None = None, ) -> DeploymentConfig: """Compile an agent into a long-running ``DeploymentConfig`` (Always). @@ -390,6 +409,7 @@ def build_deployment_config( ).model_copy( update={ "volume_mounts": volume_mounts, + "resources": build_container_resources(resources, mode=mode), "readiness_probe": Probe( httpGet=HTTPGetAction(path="/health", port=port), initialDelaySeconds=2, @@ -442,6 +462,7 @@ async def create_deployment( image: str | None = None, deployment_mode: DeploymentMode = "docker", created_by: str | None = None, + resources: ComputeResources | None = None, ) -> DeploymentInfo: """Create DeploymentConfig + Deployment entities for the agent container.""" del port # Host port is allocated by the deployments executor, not agents. @@ -545,6 +566,7 @@ async def create_deployment( auth_proxy_identity=auth_proxy_identity, auth_proxy_on_behalf_of=auth_proxy_on_behalf_of, config_files=staged_config_files, + resources=resources, ) await entities.create(deployment_config) try: diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/runner/in_memory.py b/plugins/nemo-agents/src/nemo_agents_plugin/runner/in_memory.py index cb14f429de..ab66764ec4 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/runner/in_memory.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/runner/in_memory.py @@ -37,7 +37,12 @@ import httpx import yaml from nemo_agents_plugin.config import AgentsConfig, ControllerConfig -from nemo_agents_plugin.entities import AGENT_CONFIG_FILENAME, NEMO_AGENTS_SPEC_CONFIG_FORMAT, DeploymentMode +from nemo_agents_plugin.entities import ( + AGENT_CONFIG_FILENAME, + NEMO_AGENTS_SPEC_CONFIG_FORMAT, + ComputeResources, + DeploymentMode, +) from nemo_agents_plugin.fabric.gateway_credentials import platform_gateway_credential_env from nemo_agents_plugin.runner.backend import DeploymentInfo, LocalLog, LogLocation, NotYetAvailable, RunnerBackend @@ -223,12 +228,15 @@ async def create_deployment( image: str | None = None, deployment_mode: DeploymentMode = "subprocess", created_by: str | None = None, + resources: ComputeResources | None = None, ) -> DeploymentInfo: """Start a local deployment for NAT workflows or Platform-owned agent specs.""" # created_by drives on-behalf-of delegation only for container modes (via # the auth-proxy sidecar). Subprocess deployments run in-process on the # platform host and do not use the sidecar, so it does not apply here. - del agent, image, deployment_mode, created_by + # resources (compute spec) only apply to container modes; subprocess runs + # in-process on the platform host with no resource isolation. + del agent, image, deployment_mode, created_by, resources if config.get("config_format") == NEMO_AGENTS_SPEC_CONFIG_FORMAT: return await self._create_fabric_deployment(workspace, name, config, port) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/schema.py b/plugins/nemo-agents/src/nemo_agents_plugin/schema.py index 6955181c15..8e636fd4d3 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/schema.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/schema.py @@ -25,9 +25,15 @@ from nemo_agents_plugin.entities import ( NAT_WORKFLOW_CONFIG_FORMAT, Agent, + AgentComputeSpec, AgentDeployment, + AgentEnvironment, + AgentEnvironmentInline, + AgentEnvironmentSpec, + ComputeSpecInline, DeploymentMode, DeploymentStatus, + EnvironmentSpecInline, ) from nemo_platform_plugin.schema import NemoFilter, NemoListResponse from pydantic import BaseModel, Field @@ -62,6 +68,13 @@ class CreateDeploymentRequest(BaseModel): default="", description="Container image for docker/k8s modes. Ignored for subprocess.", ) + environment: str | AgentEnvironmentInline | None = Field( + default=None, + description=( + 'Optional AgentEnvironment: a "workspace/name" ref, an inline environment, or None. ' + "Resolved and snapshotted onto the deployment at create time." + ), + ) # --------------------------------------------------------------------------- @@ -91,9 +104,42 @@ class DeploymentFilter(NemoFilter): ) +class CreateEnvironmentRequest(AgentEnvironmentInline): + """Request body for ``POST /v2/workspaces/{workspace}/environments``.""" + + name: str = Field(description="Unique environment name within the workspace.") + + +class CreateEnvironmentSpecRequest(EnvironmentSpecInline): + """Request body for ``POST /v2/workspaces/{workspace}/environment-specs``.""" + + name: str = Field(description="Unique environment-spec name within the workspace.") + + +class CreateComputeSpecRequest(ComputeSpecInline): + """Request body for ``POST /v2/workspaces/{workspace}/compute-specs``.""" + + name: str = Field(description="Unique compute-spec name within the workspace.") + + +class EnvironmentFilter(NemoFilter): + """Query filter for ``GET /v2/workspaces/{workspace}/environments``.""" + + +class EnvironmentSpecFilter(NemoFilter): + """Query filter for ``GET /v2/workspaces/{workspace}/environment-specs``.""" + + +class ComputeSpecFilter(NemoFilter): + """Query filter for ``GET /v2/workspaces/{workspace}/compute-specs``.""" + + # --------------------------------------------------------------------------- # List response type aliases # --------------------------------------------------------------------------- AgentPage = NemoListResponse[Agent] DeploymentPage = NemoListResponse[AgentDeployment] +EnvironmentPage = NemoListResponse[AgentEnvironment] +EnvironmentSpecPage = NemoListResponse[AgentEnvironmentSpec] +ComputeSpecPage = NemoListResponse[AgentComputeSpec] diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/service.py b/plugins/nemo-agents/src/nemo_agents_plugin/service.py index 4b0a82cf95..868ca1be77 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/service.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/service.py @@ -94,6 +94,7 @@ def get_routers(self) -> list[RouterSpec]: agents, deployment_logs, deployments, + environments, gateway, ) @@ -101,6 +102,12 @@ def get_routers(self) -> list[RouterSpec]: specs: list[RouterSpec] = [ RouterSpec(agents.router, tag="Agents", description="Agent CRUD", prefix=_prefix), RouterSpec(deployments.router, tag="Agent Deployments", description="Deployment lifecycle", prefix=_prefix), + RouterSpec( + environments.router, + tag="Agent Environments", + description="AgentEnvironment, EnvironmentSpec, and ComputeSpec CRUD", + prefix=_prefix, + ), RouterSpec( deployment_logs.router, tag="Agent Deployments", diff --git a/plugins/nemo-agents/tests/unit/test_deployments_api.py b/plugins/nemo-agents/tests/unit/test_deployments_api.py index 5d78119226..4ef6bfbdf2 100644 --- a/plugins/nemo-agents/tests/unit/test_deployments_api.py +++ b/plugins/nemo-agents/tests/unit/test_deployments_api.py @@ -13,7 +13,15 @@ from fastapi.testclient import TestClient from nemo_agents_plugin.api.v2 import deployments as deployments_router_module from nemo_agents_plugin.api.v2.dependencies import get_entity_client -from nemo_agents_plugin.entities import NEMO_AGENTS_SPEC_CONFIG_FORMAT, Agent, AgentDeployment, DeploymentStatus +from nemo_agents_plugin.entities import ( + NEMO_AGENTS_SPEC_CONFIG_FORMAT, + Agent, + AgentComputeSpec, + AgentDeployment, + AgentEnvironment, + AgentEnvironmentSpec, + DeploymentStatus, +) from nemo_platform_plugin.entity_client import NemoEntityConflictError, NemoEntityNotFoundError NOW = datetime.now(timezone.utc) @@ -108,6 +116,92 @@ async def _save_deployment(deployment: AgentDeployment) -> AgentDeployment: assert "functions" not in created_deployment.config assert "workflow" not in created_deployment.config + def test_create_with_environment_ref_snapshots_config_and_compute(self) -> None: + agent = _make_agent() + environment = AgentEnvironment( + name="env1", + workspace="default", + environment_spec="default/espec", + compute_spec="default/cspec", + ) + espec = AgentEnvironmentSpec(name="espec", workspace="default", env={"CUSTOM": "from-spec"}) + cspec = AgentComputeSpec(name="cspec", workspace="default", resources={"limits": {"cpu": "2"}}) + + mock_entity_client = AsyncMock() + # get order: agent (route), then AgentEnvironment, env spec, compute spec (resolver). + mock_entity_client.get = AsyncMock(side_effect=[agent, environment, espec, cspec]) + + async def _save_deployment(deployment: AgentDeployment) -> AgentDeployment: + deployment._id = f"deployment-{deployment.name}-id" + deployment._created_at = NOW + return deployment + + mock_entity_client.create = AsyncMock(side_effect=_save_deployment) + client = _test_client(mock_entity_client) + + resp = client.post( + "/apis/agents/v2/workspaces/default/deployments", + json={"agent": "fabric-agent", "name": "fabric-dep", "environment": "default/env1"}, + ) + + assert resp.status_code == 201 + created: AgentDeployment = mock_entity_client.create.call_args[0][0] + # Raw environment ref is snapshotted for provenance. + assert created.environment == "default/env1" + # Environment spec env merged into the resolved config. + assert created.config["environment"]["env"]["CUSTOM"] == "from-spec" + # Compute spec snapshotted onto the deployment. + assert created.compute is not None + assert created.compute.resources.limits == {"cpu": "2"} + + def test_create_with_inline_environment(self) -> None: + agent = _make_agent() + mock_entity_client = AsyncMock() + mock_entity_client.get = AsyncMock(return_value=agent) + + async def _save_deployment(deployment: AgentDeployment) -> AgentDeployment: + deployment._id = f"deployment-{deployment.name}-id" + deployment._created_at = NOW + return deployment + + mock_entity_client.create = AsyncMock(side_effect=_save_deployment) + client = _test_client(mock_entity_client) + + resp = client.post( + "/apis/agents/v2/workspaces/default/deployments", + json={ + "agent": "fabric-agent", + "name": "fabric-dep", + "environment": { + "environment_spec": {"env": {"INLINE": "yes"}}, + "compute_spec": {"resources": {"requests": {"cpu": "1"}}}, + }, + }, + ) + + assert resp.status_code == 201 + created: AgentDeployment = mock_entity_client.create.call_args[0][0] + assert created.config["environment"]["env"]["INLINE"] == "yes" + assert created.compute is not None + assert created.compute.resources.requests == {"cpu": "1"} + # Only the agent lookup hit the entity store; inline specs need no deref. + assert mock_entity_client.get.await_count == 1 + + def test_create_rejects_missing_environment_ref(self) -> None: + agent = _make_agent() + mock_entity_client = AsyncMock() + mock_entity_client.get = AsyncMock(side_effect=[agent, NemoEntityNotFoundError("gone")]) + client = _test_client(mock_entity_client) + + resp = client.post( + "/apis/agents/v2/workspaces/default/deployments", + json={"agent": "fabric-agent", "name": "fabric-dep", "environment": "default/missing"}, + ) + + assert resp.status_code == 400 + assert "AgentEnvironment 'missing' not found" in resp.json()["detail"] + mock_entity_client.create.assert_not_called() + def test_create_rejects_invalid_platform_agent_config(self) -> None: config = _fabric_agent_config() config["default_harness"] = "missing" diff --git a/plugins/nemo-agents/tests/unit/test_entities.py b/plugins/nemo-agents/tests/unit/test_entities.py index 7eef5bf9e2..ff18da8ed2 100644 --- a/plugins/nemo-agents/tests/unit/test_entities.py +++ b/plugins/nemo-agents/tests/unit/test_entities.py @@ -19,7 +19,13 @@ from nemo_agents_plugin.entities import ( NAT_WORKFLOW_CONFIG_FORMAT, Agent, + AgentComputeSpec, AgentDeployment, + AgentEnvironment, + AgentEnvironmentInline, + AgentEnvironmentSpec, + ComputeSpecInline, + EnvironmentSpecInline, agent_config_file_ref, agent_spec_file_ref, agent_spec_fileset_name, @@ -27,7 +33,10 @@ ) from nemo_agents_plugin.schema import ( CreateAgentRequest, + CreateComputeSpecRequest, CreateDeploymentRequest, + CreateEnvironmentRequest, + CreateEnvironmentSpecRequest, ) from pydantic import ValidationError @@ -220,3 +229,111 @@ def test_required_agent(self) -> None: def test_optional_name(self) -> None: req = CreateDeploymentRequest(agent="calc", name="calc-abc1") assert req.name == "calc-abc1" + + def test_environment_defaults_none(self) -> None: + req = CreateDeploymentRequest(agent="calc") + assert req.environment is None + + def test_environment_ref_string(self) -> None: + req = CreateDeploymentRequest(agent="calc", environment="default/env1") + assert req.environment == "default/env1" + + def test_environment_inline(self) -> None: + req = CreateDeploymentRequest( + agent="calc", + environment={"compute_spec": {"resources": {"limits": {"cpu": "2"}}}}, + ) + assert isinstance(req.environment, AgentEnvironmentInline) + assert isinstance(req.environment.compute_spec, ComputeSpecInline) + + +# --------------------------------------------------------------------------- +# Entities: AgentEnvironment / AgentEnvironmentSpec / AgentComputeSpec +# --------------------------------------------------------------------------- + + +class TestEnvironmentEntities: + def test_entity_types(self) -> None: + assert AgentEnvironment.__entity_type__ == "agent_environment" + assert AgentEnvironmentSpec.__entity_type__ == "agent_environment_spec" + assert AgentComputeSpec.__entity_type__ == "agent_compute_spec" + + def test_compute_spec_resources(self) -> None: + cs = AgentComputeSpec( + name="c1", + workspace="default", + resources={"limits": {"cpu": "2", "nvidia.com/gpu": "1"}, "requests": {"cpu": "1"}}, + ) + assert cs.resources.limits == {"cpu": "2", "nvidia.com/gpu": "1"} + assert cs.resources.requests == {"cpu": "1"} + + def test_environment_spec_fields(self) -> None: + es = AgentEnvironmentSpec( + name="e1", + workspace="default", + env={"FOO": "bar"}, + secrets={"TOKEN": "default/token"}, + mcp={"search": {"url": "http://x", "secrets": {"KEY": "default/key"}}}, + ) + assert es.env == {"FOO": "bar"} + assert es.secrets == {"TOKEN": "default/token"} + assert es.mcp["search"].url == "http://x" + assert es.mcp["search"].secrets == {"KEY": "default/key"} + assert es.provider == "local" + + def test_environment_ref_and_inline_unions(self) -> None: + by_ref = AgentEnvironment(name="env1", workspace="default", environment_spec="default/e1") + assert by_ref.environment_spec == "default/e1" + assert by_ref.compute_spec is None + + inline = AgentEnvironment( + name="env2", + workspace="default", + environment_spec={"env": {"A": "1"}}, + compute_spec={"resources": {"limits": {"cpu": "1"}}}, + ) + assert isinstance(inline.environment_spec, EnvironmentSpecInline) + assert isinstance(inline.compute_spec, ComputeSpecInline) + + def test_data_fields_include_domain_fields(self) -> None: + es = AgentEnvironmentSpec(name="e1", workspace="default", env={"FOO": "bar"}) + data = es._get_data_fields() + assert "env" in data + assert "provider" in data + assert "name" not in data + + +class TestAgentDeploymentEnvironmentSnapshot: + def test_environment_and_compute_default_none(self) -> None: + d = AgentDeployment(name="dep", workspace="default") + assert d.environment is None + assert d.compute is None + + def test_environment_ref_and_compute_snapshot(self) -> None: + d = AgentDeployment( + name="dep", + workspace="default", + agent="calc", + environment="default/env1", + compute=ComputeSpecInline(resources={"limits": {"cpu": "2"}}), + ) + assert d.environment == "default/env1" + assert isinstance(d.compute, ComputeSpecInline) + assert d.compute.resources.limits == {"cpu": "2"} + + +class TestCreateEnvironmentRequests: + def test_create_environment_request(self) -> None: + req = CreateEnvironmentRequest(name="env1", environment_spec="default/e1") + assert req.name == "env1" + assert req.environment_spec == "default/e1" + + def test_create_environment_spec_request(self) -> None: + req = CreateEnvironmentSpecRequest(name="e1", env={"FOO": "bar"}) + assert req.name == "e1" + assert req.env == {"FOO": "bar"} + + def test_create_compute_spec_request(self) -> None: + req = CreateComputeSpecRequest(name="c1", resources={"limits": {"cpu": "2"}}) + assert req.name == "c1" + assert req.resources.limits == {"cpu": "2"} diff --git a/plugins/nemo-agents/tests/unit/test_environment_resolution.py b/plugins/nemo-agents/tests/unit/test_environment_resolution.py new file mode 100644 index 0000000000..1bdf49aafd --- /dev/null +++ b/plugins/nemo-agents/tests/unit/test_environment_resolution.py @@ -0,0 +1,194 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for AgentEnvironment resolution and config merge.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from nemo_agents_plugin.entities import ( + NEMO_AGENTS_SPEC_CONFIG_FORMAT, + AgentComputeSpec, + AgentEnvironment, + AgentEnvironmentInline, + AgentEnvironmentSpec, + ComputeSpecInline, + EnvironmentSpecInline, + McpFulfillment, + ModelProviderOverride, +) +from nemo_agents_plugin.environment_resolution import ( + EnvironmentResolutionError, + merge_environment_spec_into_agent_config, + resolve_environment, +) +from nemo_platform_plugin.entity_client import NemoEntityNotFoundError + + +def _agent_config(**overrides: Any) -> dict[str, Any]: + config: dict[str, Any] = { + "config_format": NEMO_AGENTS_SPEC_CONFIG_FORMAT, + "name": "fabric-agent", + "default_harness": "hermes", + "harnesses": {"hermes": {"kind": "hermes"}}, + "models": {"default": {"provider": "openai", "model": "openai/gpt-5.4"}}, + "environment": {"provider": "local"}, + } + config.update(overrides) + return config + + +# --------------------------------------------------------------------------- +# resolve_environment +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_resolve_none_returns_empty() -> None: + resolved = await resolve_environment(None, workspace="default", entity_client=AsyncMock()) + assert resolved.environment_spec is None + assert resolved.compute_spec is None + + +@pytest.mark.asyncio +async def test_resolve_inline_environment_with_inline_specs() -> None: + environment = AgentEnvironmentInline( + environment_spec=EnvironmentSpecInline(env={"FOO": "bar"}), + compute_spec=ComputeSpecInline(resources={"limits": {"cpu": "2"}}), + ) + resolved = await resolve_environment(environment, workspace="default", entity_client=AsyncMock()) + assert resolved.environment_spec is not None + assert resolved.environment_spec.env == {"FOO": "bar"} + assert resolved.compute_spec is not None + assert resolved.compute_spec.resources.limits == {"cpu": "2"} + + +@pytest.mark.asyncio +async def test_resolve_environment_ref_dereferences_all_entities() -> None: + env_entity = AgentEnvironment( + name="env1", + workspace="default", + environment_spec="default/espec", + compute_spec="default/cspec", + ) + espec = AgentEnvironmentSpec(name="espec", workspace="default", env={"A": "1"}) + cspec = AgentComputeSpec(name="cspec", workspace="default", resources={"requests": {"cpu": "1"}}) + + entity_client = AsyncMock() + entity_client.get = AsyncMock(side_effect=[env_entity, espec, cspec]) + + resolved = await resolve_environment("default/env1", workspace="default", entity_client=entity_client) + + assert resolved.environment_spec is not None + assert resolved.environment_spec.env == {"A": "1"} + assert resolved.compute_spec is not None + assert resolved.compute_spec.resources.requests == {"cpu": "1"} + # AgentEnvironment, then its two specs. + assert entity_client.get.await_count == 3 + + +@pytest.mark.asyncio +async def test_resolve_missing_environment_ref_raises() -> None: + entity_client = AsyncMock() + entity_client.get = AsyncMock(side_effect=NemoEntityNotFoundError("gone")) + with pytest.raises(EnvironmentResolutionError, match="AgentEnvironment 'env1' not found"): + await resolve_environment("default/env1", workspace="default", entity_client=entity_client) + + +@pytest.mark.asyncio +async def test_resolve_missing_spec_ref_raises() -> None: + env_entity = AgentEnvironment(name="env1", workspace="default", environment_spec="default/missing") + entity_client = AsyncMock() + entity_client.get = AsyncMock(side_effect=[env_entity, NemoEntityNotFoundError("gone")]) + with pytest.raises(EnvironmentResolutionError, match="AgentEnvironmentSpec 'missing' not found"): + await resolve_environment("default/env1", workspace="default", entity_client=entity_client) + + +# --------------------------------------------------------------------------- +# merge_environment_spec_into_agent_config +# --------------------------------------------------------------------------- + + +def test_merge_none_spec_returns_config_unchanged() -> None: + config = _agent_config() + assert merge_environment_spec_into_agent_config(config, None) is config + + +def test_merge_ignores_non_fabric_config() -> None: + config = {"config_format": "nat-workflow-v1", "functions": {}} + spec = EnvironmentSpecInline(env={"FOO": "bar"}) + assert merge_environment_spec_into_agent_config(config, spec) is config + + +def test_merge_env_agent_wins_on_collision() -> None: + config = _agent_config(environment={"provider": "local", "env": {"SHARED": "agent"}}) + spec = EnvironmentSpecInline(env={"SHARED": "spec", "ONLY_SPEC": "spec"}) + merged = merge_environment_spec_into_agent_config(config, spec) + assert merged["environment"]["env"] == {"SHARED": "agent", "ONLY_SPEC": "spec"} + # Original is not mutated (deep copy). + assert config["environment"]["env"] == {"SHARED": "agent"} + + +def test_merge_environment_mirror_fields_fill_only_when_unset() -> None: + config = _agent_config(environment={"provider": "docker"}) + spec = EnvironmentSpecInline( + provider="k8s", + workspace_path="/ws", + artifacts="/artifacts", + control_location="in_env_control", + ownership="fabric_owned", + connection={"url": "http://x"}, + ) + merged = merge_environment_spec_into_agent_config(config, spec) + env = merged["environment"] + # Agent explicitly set provider -> preserved. + assert env["provider"] == "docker" + # Agent left these unset -> filled from spec (workspace_path -> workspace). + assert env["workspace"] == "/ws" + assert env["artifacts"] == "/artifacts" + assert env["control_location"] == "in_env_control" + assert env["ownership"] == "fabric_owned" + assert env["connection"] == {"url": "http://x"} + + +def test_merge_model_provider_override_applies_when_unset() -> None: + config = _agent_config() + spec = EnvironmentSpecInline( + model_provider_override=ModelProviderOverride( + base_url="https://api.example.com", + provider="anthropic", + api_key="MY_SECRET", + ) + ) + merged = merge_environment_spec_into_agent_config(config, spec) + model = merged["models"]["default"] + assert model["base_url"] == "https://api.example.com" + assert model["provider"] == "openai" # Agent's explicit provider wins. + assert model["api_key_env"] == "MY_SECRET" + + +def test_merge_mcp_fulfills_by_name_agent_url_wins() -> None: + config = _agent_config(mcp={"servers": {"search": {"transport": "streamable-http", "url": "http://agent-url"}}}) + spec = EnvironmentSpecInline( + mcp={ + "search": McpFulfillment(url="http://env-url", env={"E": "1"}, secrets={"TOKEN": "secret-ref"}), + "new": McpFulfillment(url="http://new-url"), + } + ) + merged = merge_environment_spec_into_agent_config(config, spec) + servers = merged["mcp"]["servers"] + # Agent-provided url wins; env + secrets merged in. + assert servers["search"]["url"] == "http://agent-url" + assert servers["search"]["env"] == {"E": "1", "TOKEN": "secret-ref"} + # New server contributed entirely by the spec. + assert servers["new"]["url"] == "http://new-url" + + +def test_merge_no_environment_reference_is_identical_to_today() -> None: + # Baseline agent config with no environment merged behaves unchanged. + config = _agent_config() + merged = merge_environment_spec_into_agent_config(config, None) + assert merged == config diff --git a/plugins/nemo-agents/tests/unit/test_environments_api.py b/plugins/nemo-agents/tests/unit/test_environments_api.py new file mode 100644 index 0000000000..7d3889ee11 --- /dev/null +++ b/plugins/nemo-agents/tests/unit/test_environments_api.py @@ -0,0 +1,151 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for AgentEnvironment / EnvironmentSpec / ComputeSpec CRUD routes.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import AsyncMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from nemo_agents_plugin.api.v2 import environments as environments_router_module +from nemo_agents_plugin.api.v2.dependencies import get_entity_client +from nemo_agents_plugin.entities import AgentComputeSpec, AgentEnvironment, AgentEnvironmentSpec +from nemo_platform_plugin.entity_client import NemoEntityConflictError, NemoEntityNotFoundError + +NOW = datetime.now(timezone.utc) + + +def _stamp(entity): + entity._id = f"{entity.__entity_type__}-{entity.name}-id" + entity._created_at = NOW + return entity + + +def _test_client(mock_entity_client: AsyncMock) -> TestClient: + app = FastAPI() + app.include_router( + environments_router_module.router, + prefix="/apis/agents/v2/workspaces/{workspace}", + ) + app.dependency_overrides[get_entity_client] = lambda: mock_entity_client + return TestClient(app, raise_server_exceptions=False) + + +class TestComputeSpecRoutes: + def test_create(self) -> None: + client_mock = AsyncMock() + client_mock.create = AsyncMock(side_effect=lambda e: _stamp(e)) + client = _test_client(client_mock) + + resp = client.post( + "/apis/agents/v2/workspaces/default/compute-specs", + json={"name": "c1", "resources": {"limits": {"cpu": "2"}}}, + ) + + assert resp.status_code == 201 + created: AgentComputeSpec = client_mock.create.call_args[0][0] + assert created.name == "c1" + assert created.resources.limits == {"cpu": "2"} + + def test_create_conflict(self) -> None: + client_mock = AsyncMock() + client_mock.create = AsyncMock(side_effect=NemoEntityConflictError("exists")) + client = _test_client(client_mock) + + resp = client.post( + "/apis/agents/v2/workspaces/default/compute-specs", + json={"name": "c1", "resources": {}}, + ) + assert resp.status_code == 409 + + def test_get_not_found(self) -> None: + client_mock = AsyncMock() + client_mock.get = AsyncMock(side_effect=NemoEntityNotFoundError("gone")) + client = _test_client(client_mock) + + resp = client.get("/apis/agents/v2/workspaces/default/compute-specs/c1") + assert resp.status_code == 404 + + +class TestEnvironmentSpecRoutes: + def test_create(self) -> None: + client_mock = AsyncMock() + client_mock.create = AsyncMock(side_effect=lambda e: _stamp(e)) + client = _test_client(client_mock) + + resp = client.post( + "/apis/agents/v2/workspaces/default/environment-specs", + json={"name": "e1", "env": {"FOO": "bar"}, "mcp": {"search": {"url": "http://x"}}}, + ) + + assert resp.status_code == 201 + created: AgentEnvironmentSpec = client_mock.create.call_args[0][0] + assert created.name == "e1" + assert created.env == {"FOO": "bar"} + assert created.mcp["search"].url == "http://x" + + def test_delete(self) -> None: + client_mock = AsyncMock() + client_mock.delete = AsyncMock(return_value=None) + client = _test_client(client_mock) + + resp = client.delete("/apis/agents/v2/workspaces/default/environment-specs/e1") + assert resp.status_code == 204 + + +class TestEnvironmentRoutes: + def test_create_with_refs(self) -> None: + client_mock = AsyncMock() + client_mock.create = AsyncMock(side_effect=lambda e: _stamp(e)) + client = _test_client(client_mock) + + resp = client.post( + "/apis/agents/v2/workspaces/default/environments", + json={"name": "env1", "environment_spec": "default/e1", "compute_spec": "default/c1"}, + ) + + assert resp.status_code == 201 + created: AgentEnvironment = client_mock.create.call_args[0][0] + assert created.name == "env1" + assert created.environment_spec == "default/e1" + assert created.compute_spec == "default/c1" + + def test_create_with_inline(self) -> None: + client_mock = AsyncMock() + client_mock.create = AsyncMock(side_effect=lambda e: _stamp(e)) + client = _test_client(client_mock) + + resp = client.post( + "/apis/agents/v2/workspaces/default/environments", + json={"name": "env2", "environment_spec": {"env": {"A": "1"}}}, + ) + + assert resp.status_code == 201 + created: AgentEnvironment = client_mock.create.call_args[0][0] + assert created.environment_spec.env == {"A": "1"} + + def test_get(self) -> None: + env = _stamp(AgentEnvironment(name="env1", workspace="default", environment_spec="default/e1")) + client_mock = AsyncMock() + client_mock.get = AsyncMock(return_value=env) + client = _test_client(client_mock) + + resp = client.get("/apis/agents/v2/workspaces/default/environments/env1") + assert resp.status_code == 200 + assert resp.json()["environment_spec"] == "default/e1" + + def test_list(self) -> None: + env = _stamp(AgentEnvironment(name="env1", workspace="default")) + result = AsyncMock() + result.data = [env] + result.pagination = None + client_mock = AsyncMock() + client_mock.list = AsyncMock(return_value=result) + client = _test_client(client_mock) + + resp = client.get("/apis/agents/v2/workspaces/default/environments") + assert resp.status_code == 200 + assert resp.json()["data"][0]["name"] == "env1" diff --git a/plugins/nemo-agents/tests/unit/test_fabric_translator.py b/plugins/nemo-agents/tests/unit/test_fabric_translator.py index fe0e3f0ee0..20ec26c3ec 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_translator.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_translator.py @@ -196,6 +196,41 @@ def test_top_level_prompts_rejected_until_shared_prompt_contract_exists(self) -> with pytest.raises(FabricTranslationError, match="Top-level prompts are not translated yet"): translate_agent_config(config) + def test_environment_spec_env_forwarded_platform_values_win(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Simulates a config after an EnvironmentSpec merge: environment.env holds + # the spec's plaintext vars. Platform-injected runtime values win on key + # collision. + monkeypatch.setenv("NMP_WORKSPACE", "runtime-ws") + monkeypatch.delenv("NEMO_BASE_URL", raising=False) + monkeypatch.delenv("NMP_BASE_URL", raising=False) + payload = copy.deepcopy(_example_yaml_config()) + payload["environment"]["env"] = {"CUSTOM": "from-spec", "NMP_WORKSPACE": "spec-should-lose"} + config = AgentConfig.model_validate(payload) + + fabric_config = translate_agent_config(config, harness_name="codex") + + assert fabric_config.environment.env["CUSTOM"] == "from-spec" + assert fabric_config.environment.env["NMP_WORKSPACE"] == "runtime-ws" + + def test_environment_mirror_fields_forwarded(self) -> None: + payload = copy.deepcopy(_example_yaml_config()) + payload["environment"].update( + { + "control_location": "in_env_control", + "ownership": "fabric_owned", + "connection": {"url": "http://sandbox"}, + "metadata": {"team": "platform"}, + } + ) + config = AgentConfig.model_validate(payload) + + fabric_config = translate_agent_config(config, harness_name="codex") + + assert fabric_config.environment.control_location == "in_env_control" + assert fabric_config.environment.ownership == "fabric_owned" + assert fabric_config.environment.connection == {"url": "http://sandbox"} + assert fabric_config.environment.metadata == {"team": "platform"} + @pytest.mark.parametrize( ("kind", "adapter_id"), [ diff --git a/plugins/nemo-agents/tests/unit/test_runner_deployments.py b/plugins/nemo-agents/tests/unit/test_runner_deployments.py index 045e40bd42..bdda456752 100644 --- a/plugins/nemo-agents/tests/unit/test_runner_deployments.py +++ b/plugins/nemo-agents/tests/unit/test_runner_deployments.py @@ -11,11 +11,12 @@ import pytest import yaml from nemo_agents_plugin.config import AgentsConfig, DeploymentsRunnerConfig -from nemo_agents_plugin.entities import Endpoint +from nemo_agents_plugin.entities import ComputeResources, Endpoint from nemo_agents_plugin.fabric.gateway_credentials import PLATFORM_IGW_API_KEY_ENV, PLATFORM_IGW_API_KEY_PLACEHOLDER from nemo_agents_plugin.runner.deployments_backend import ( DeploymentsRunnerBackend, UnreachableGatewayURLError, + build_container_resources, build_deployment_config, executor_for_mode, map_status, @@ -246,6 +247,76 @@ def test_build_deployment_config_always_single_container() -> None: assert loaded["llms"]["nim"]["_type"] == "nim" +def test_build_container_resources_none_is_empty() -> None: + resources = build_container_resources(None, mode="k8s") + assert resources.limits == {} + assert resources.requests == {} + + +def test_build_container_resources_k8s_passes_both() -> None: + compute = ComputeResources(limits={"cpu": "2", "nvidia.com/gpu": "1"}, requests={"cpu": "1"}) + resources = build_container_resources(compute, mode="k8s") + assert resources.limits == {"cpu": "2", "nvidia.com/gpu": "1"} + assert resources.requests == {"cpu": "1"} + + +def test_build_container_resources_docker_consolidates_to_limits() -> None: + compute = ComputeResources(limits={"cpu": "2"}, requests={"cpu": "1", "memory": "1Gi"}) + resources = build_container_resources(compute, mode="docker") + # Docker has no scheduling requests: requests fold into limits, limits win on collision. + assert resources.limits == {"cpu": "2", "memory": "1Gi"} + assert resources.requests == {} + + +def test_build_deployment_config_applies_k8s_resources() -> None: + cfg = build_deployment_config( + name="hello-dep", + workspace="default", + image="nat-runtime:latest", + port=8000, + agent_config={}, + platform_base_url="http://nmp-api:8080", + config_mount_path="/workspace/config.yaml", + mode="k8s", + resources=ComputeResources(limits={"cpu": "2"}, requests={"cpu": "1"}), + ) + container = cfg.containers[0] + assert container.resources.limits == {"cpu": "2"} + assert container.resources.requests == {"cpu": "1"} + + +def test_build_deployment_config_docker_resources_limits_only() -> None: + cfg = build_deployment_config( + name="hello-dep", + workspace="default", + image="nat-runtime:latest", + port=8000, + agent_config={}, + platform_base_url="http://host.docker.internal:8080", + config_mount_path="/tmp/nemo/config.yaml", + mode="docker", + resources=ComputeResources(limits={"cpu": "2"}, requests={"memory": "1Gi"}), + ) + container = cfg.containers[0] + assert container.resources.limits == {"cpu": "2", "memory": "1Gi"} + assert container.resources.requests == {} + + +def test_build_deployment_config_no_resources_is_empty() -> None: + cfg = build_deployment_config( + name="hello-dep", + workspace="default", + image="nat-runtime:latest", + port=8000, + agent_config={}, + platform_base_url="http://nmp-api:8080", + config_mount_path="/workspace/config.yaml", + mode="k8s", + ) + assert cfg.containers[0].resources.limits == {} + assert cfg.containers[0].resources.requests == {} + + def test_build_deployment_config_k8s_uses_nat_entrypoint() -> None: cfg = build_deployment_config( name="hello-dep", From 3f2f496b5b4f23ba94d2f90c2f178c621425c105 Mon Sep 17 00:00:00 2001 From: Ben McCown Date: Wed, 19 Aug 2026 11:23:03 -0600 Subject: [PATCH 2/5] chore(agents): address PR review nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deployments.py: return 422 (not 400) for EnvironmentResolutionError — the request is syntactically valid but references an environment/spec that cannot be resolved (semantic error). - entities.py/agent_config.py: drop internal RFC-122/RFC122 identifiers from shipped source comments/docstrings; keep the descriptive text. - entities.py: rename EnvironmentSpec field artifacts -> artifacts_path for symmetry with workspace_path; environment_resolution.py maps it onto the config's environment.artifacts (mirroring the workspace_path -> workspace mapping), so the Fabric-facing field name is unchanged. - Regenerate the agents plugin OpenAPI spec: adds the AgentEnvironment / EnvironmentSpec / ComputeSpec schemas + routes (previously not regenerated) and reflects the artifacts_path rename. Signed-off-by: Ben McCown --- plugins/nemo-agents/openapi/openapi.yaml | 2500 ++++++++++++----- .../src/nemo_agents_plugin/agent_config.py | 10 +- .../nemo_agents_plugin/api/v2/deployments.py | 4 +- .../src/nemo_agents_plugin/entities.py | 13 +- .../environment_resolution.py | 7 +- .../tests/unit/test_deployments_api.py | 2 +- .../tests/unit/test_environment_resolution.py | 5 +- 7 files changed, 1866 insertions(+), 675 deletions(-) diff --git a/plugins/nemo-agents/openapi/openapi.yaml b/plugins/nemo-agents/openapi/openapi.yaml index ee018f54fc..3ce4ce96b7 100644 --- a/plugins/nemo-agents/openapi/openapi.yaml +++ b/plugins/nemo-agents/openapi/openapi.yaml @@ -154,6 +154,150 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /apis/agents/v2/workspaces/{workspace}/compute-specs: + post: + tags: + - Agent Compute Specs + summary: Create Compute Spec + description: Create a new AgentComputeSpec. + operationId: create_compute_spec_apis_agents_v2_workspaces__workspace__compute_specs_post + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateComputeSpecRequest' + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AgentComputeSpec' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Agent Compute Specs + summary: List Compute Specs + description: List AgentComputeSpecs in the workspace. + operationId: list_compute_specs_apis_agents_v2_workspaces__workspace__compute_specs_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: page + in: query + required: false + schema: + type: integer + minimum: 1 + default: 1 + title: Page + - name: page_size + in: query + required: false + schema: + type: integer + maximum: 100 + minimum: 1 + default: 20 + title: Page Size + - name: sort + in: query + required: false + schema: + type: string + default: -created_at + title: Sort + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/NemoListResponse_AgentComputeSpec_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/agents/v2/workspaces/{workspace}/compute-specs/{name}: + get: + tags: + - Agent Compute Specs + summary: Get Compute Spec + description: Get an AgentComputeSpec by name. + operationId: get_compute_spec_apis_agents_v2_workspaces__workspace__compute_specs__name__get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AgentComputeSpec' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Agent Compute Specs + summary: Delete Compute Spec + description: Delete an AgentComputeSpec by name. + operationId: delete_compute_spec_apis_agents_v2_workspaces__workspace__compute_specs__name__delete + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '204': + description: Successful Response + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /apis/agents/v2/workspaces/{workspace}/deployments: post: tags: @@ -383,12 +527,13 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/analyze: + /apis/agents/v2/workspaces/{workspace}/environment-specs: post: tags: - - Agents - summary: Create Job - operationId: create_job_apis_agents_v2_workspaces__workspace__jobs_analyze_post + - Agent Environment Specs + summary: Create Environment Spec + description: Create a new AgentEnvironmentSpec. + operationId: create_environment_spec_apis_agents_v2_workspaces__workspace__environment_specs_post parameters: - name: workspace in: path @@ -401,14 +546,14 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/AnalyzeJobRequest' + $ref: '#/components/schemas/CreateEnvironmentSpecRequest' responses: '201': description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/AnalyzeJob' + $ref: '#/components/schemas/AgentEnvironmentSpec' '422': description: Validation Error content: @@ -417,9 +562,10 @@ paths: $ref: '#/components/schemas/HTTPValidationError' get: tags: - - Agents - summary: List Jobs - operationId: list_jobs_apis_agents_v2_workspaces__workspace__jobs_analyze_get + - Agent Environment Specs + summary: List Environment Specs + description: List AgentEnvironmentSpecs in the workspace. + operationId: list_environment_specs_apis_agents_v2_workspaces__workspace__environment_specs_get parameters: - name: workspace in: path @@ -432,59 +578,45 @@ paths: required: false schema: type: integer - exclusiveMinimum: 0 - description: Page number. + minimum: 1 default: 1 title: Page - description: Page number. - name: page_size in: query required: false schema: type: integer - exclusiveMinimum: 0 - description: Page size. - default: 10 + maximum: 100 + minimum: 1 + default: 20 title: Page Size - description: Page size. - name: sort in: query required: false schema: - allOf: - - $ref: '#/components/schemas/AnalyzeJobsSortField' - description: The field to sort by. To sort in decreasing order, use `-` - in front of the field name. + type: string default: -created_at - description: The field to sort by. To sort in decreasing order, use `-` in - front of the field name. - - in: query - name: filter - style: deepObject - required: false - explode: true - schema: - $ref: '#/components/schemas/AnalyzeJobsListFilter' - description: Filter jobs on various criteria. + title: Sort responses: '200': description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/AnalyzeJobsPage' + $ref: '#/components/schemas/NemoListResponse_AgentEnvironmentSpec_' '422': description: Validation Error content: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/analyze/{job}/results/{name}: + /apis/agents/v2/workspaces/{workspace}/environment-specs/{name}: get: tags: - - Agents - summary: Get Job Result - operationId: get_job_result_apis_agents_v2_workspaces__workspace__jobs_analyze__job__results__name__get + - Agent Environment Specs + summary: Get Environment Spec + description: Get an AgentEnvironmentSpec by name. + operationId: get_environment_spec_apis_agents_v2_workspaces__workspace__environment_specs__name__get parameters: - name: workspace in: path @@ -492,12 +624,6 @@ paths: schema: type: string title: Workspace - - name: job - in: path - required: true - schema: - type: string - title: Job - name: name in: path required: true @@ -510,19 +636,19 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PlatformJobResultResponse' + $ref: '#/components/schemas/AgentEnvironmentSpec' '422': description: Validation Error content: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/analyze/{job}/results/{name}/download: - get: + delete: tags: - - Agents - summary: Download Job Result - operationId: download_job_result_apis_agents_v2_workspaces__workspace__jobs_analyze__job__results__name__download_get + - Agent Environment Specs + summary: Delete Environment Spec + description: Delete an AgentEnvironmentSpec by name. + operationId: delete_environment_spec_apis_agents_v2_workspaces__workspace__environment_specs__name__delete parameters: - name: workspace in: path @@ -530,12 +656,6 @@ paths: schema: type: string title: Workspace - - name: job - in: path - required: true - schema: - type: string - title: Job - name: name in: path required: true @@ -543,27 +663,21 @@ paths: type: string title: Name responses: - '200': + '204': description: Successful Response - content: - application/octet-stream: - schema: - type: string - format: binary - '404': - description: Not Found '422': description: Validation Error content: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/analyze/{name}: - get: + /apis/agents/v2/workspaces/{workspace}/environments: + post: tags: - - Agents - summary: Get Job - operationId: get_job_apis_agents_v2_workspaces__workspace__jobs_analyze__name__get + - Agent Environments + summary: Create Environment + description: Create a new AgentEnvironment. + operationId: create_environment_apis_agents_v2_workspaces__workspace__environments_post parameters: - name: workspace in: path @@ -571,30 +685,31 @@ paths: schema: type: string title: Workspace - - name: name - in: path + requestBody: required: true - schema: - type: string - title: Name + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEnvironmentRequest' responses: - '200': + '201': description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/AnalyzeJob' + $ref: '#/components/schemas/AgentEnvironment' '422': description: Validation Error content: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - delete: + get: tags: - - Agents - summary: Delete Job - operationId: delete_job_apis_agents_v2_workspaces__workspace__jobs_analyze__name__delete + - Agent Environments + summary: List Environments + description: List AgentEnvironments in the workspace. + operationId: list_environments_apis_agents_v2_workspaces__workspace__environments_get parameters: - name: workspace in: path @@ -602,27 +717,50 @@ paths: schema: type: string title: Workspace - - name: name - in: path - required: true + - name: page + in: query + required: false + schema: + type: integer + minimum: 1 + default: 1 + title: Page + - name: page_size + in: query + required: false + schema: + type: integer + maximum: 100 + minimum: 1 + default: 20 + title: Page Size + - name: sort + in: query + required: false schema: type: string - title: Name + default: -created_at + title: Sort responses: - '204': + '200': description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/NemoListResponse_AgentEnvironment_' '422': description: Validation Error content: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/analyze/{name}/cancel: - post: + /apis/agents/v2/workspaces/{workspace}/environments/{name}: + get: tags: - - Agents - summary: Cancel Job - operationId: cancel_job_apis_agents_v2_workspaces__workspace__jobs_analyze__name__cancel_post + - Agent Environments + summary: Get Environment + description: Get an AgentEnvironment by name. + operationId: get_environment_apis_agents_v2_workspaces__workspace__environments__name__get parameters: - name: workspace in: path @@ -642,95 +780,19 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/AnalyzeJob' + $ref: '#/components/schemas/AgentEnvironment' '422': description: Validation Error content: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/analyze/{name}/logs: - get: + delete: tags: - - Agents - summary: Get Job Logs - operationId: get_job_logs_apis_agents_v2_workspaces__workspace__jobs_analyze__name__logs_get - parameters: - - name: workspace - in: path - required: true - schema: - type: string - title: Workspace - - name: name - in: path - required: true - schema: - type: string - title: Name - - name: limit - in: query - required: false - schema: - title: Limit - type: integer - - name: page_cursor - in: query - required: false - schema: - title: Page Cursor - type: string - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/PlatformJobLogPage' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/analyze/{name}/results: - get: - tags: - - Agents - summary: List Job Results - operationId: list_job_results_apis_agents_v2_workspaces__workspace__jobs_analyze__name__results_get - parameters: - - name: workspace - in: path - required: true - schema: - type: string - title: Workspace - - name: name - in: path - required: true - schema: - type: string - title: Name - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/PlatformJobListResultResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/analyze/{name}/status: - get: - tags: - - Agents - summary: Get Job Status - operationId: get_job_status_apis_agents_v2_workspaces__workspace__jobs_analyze__name__status_get + - Agent Environments + summary: Delete Environment + description: Delete an AgentEnvironment by name. + operationId: delete_environment_apis_agents_v2_workspaces__workspace__environments__name__delete parameters: - name: workspace in: path @@ -745,120 +807,20 @@ paths: type: string title: Name responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/PlatformJobStatusResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/evaluate: - post: - tags: - - Agents - summary: Create Job - operationId: create_job_apis_agents_v2_workspaces__workspace__jobs_evaluate_post - parameters: - - name: workspace - in: path - required: true - schema: - type: string - title: Workspace - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/EvaluateJobRequest' - responses: - '201': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/EvaluateJob' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - get: - tags: - - Agents - summary: List Jobs - operationId: list_jobs_apis_agents_v2_workspaces__workspace__jobs_evaluate_get - parameters: - - name: workspace - in: path - required: true - schema: - type: string - title: Workspace - - name: page - in: query - required: false - schema: - type: integer - exclusiveMinimum: 0 - description: Page number. - default: 1 - title: Page - description: Page number. - - name: page_size - in: query - required: false - schema: - type: integer - exclusiveMinimum: 0 - description: Page size. - default: 10 - title: Page Size - description: Page size. - - name: sort - in: query - required: false - schema: - allOf: - - $ref: '#/components/schemas/EvaluateJobsSortField' - description: The field to sort by. To sort in decreasing order, use `-` - in front of the field name. - default: -created_at - description: The field to sort by. To sort in decreasing order, use `-` in - front of the field name. - - in: query - name: filter - style: deepObject - required: false - explode: true - schema: - $ref: '#/components/schemas/EvaluateJobsListFilter' - description: Filter jobs on various criteria. - responses: - '200': + '204': description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/EvaluateJobsPage' '422': description: Validation Error content: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/evaluate-suite: + /apis/agents/v2/workspaces/{workspace}/jobs/analyze: post: tags: - Agents summary: Create Job - operationId: create_job_apis_agents_v2_workspaces__workspace__jobs_evaluate_suite_post + operationId: create_job_apis_agents_v2_workspaces__workspace__jobs_analyze_post parameters: - name: workspace in: path @@ -871,14 +833,14 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/EvaluateSuiteJobRequest' + $ref: '#/components/schemas/AnalyzeJobRequest' responses: '201': description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/EvaluateSuiteJob' + $ref: '#/components/schemas/AnalyzeJob' '422': description: Validation Error content: @@ -889,7 +851,7 @@ paths: tags: - Agents summary: List Jobs - operationId: list_jobs_apis_agents_v2_workspaces__workspace__jobs_evaluate_suite_get + operationId: list_jobs_apis_agents_v2_workspaces__workspace__jobs_analyze_get parameters: - name: workspace in: path @@ -922,7 +884,7 @@ paths: required: false schema: allOf: - - $ref: '#/components/schemas/EvaluateSuiteJobsSortField' + - $ref: '#/components/schemas/AnalyzeJobsSortField' description: The field to sort by. To sort in decreasing order, use `-` in front of the field name. default: -created_at @@ -934,7 +896,7 @@ paths: required: false explode: true schema: - $ref: '#/components/schemas/EvaluateSuiteJobsListFilter' + $ref: '#/components/schemas/AnalyzeJobsListFilter' description: Filter jobs on various criteria. responses: '200': @@ -942,19 +904,19 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/EvaluateSuiteJobsPage' + $ref: '#/components/schemas/AnalyzeJobsPage' '422': description: Validation Error content: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/evaluate-suite/{job}/results/{name}: + /apis/agents/v2/workspaces/{workspace}/jobs/analyze/{job}/results/{name}: get: tags: - Agents summary: Get Job Result - operationId: get_job_result_apis_agents_v2_workspaces__workspace__jobs_evaluate_suite__job__results__name__get + operationId: get_job_result_apis_agents_v2_workspaces__workspace__jobs_analyze__job__results__name__get parameters: - name: workspace in: path @@ -987,12 +949,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/evaluate-suite/{job}/results/{name}/download: + /apis/agents/v2/workspaces/{workspace}/jobs/analyze/{job}/results/{name}/download: get: tags: - Agents summary: Download Job Result - operationId: download_job_result_apis_agents_v2_workspaces__workspace__jobs_evaluate_suite__job__results__name__download_get + operationId: download_job_result_apis_agents_v2_workspaces__workspace__jobs_analyze__job__results__name__download_get parameters: - name: workspace in: path @@ -1028,12 +990,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/evaluate-suite/{name}: + /apis/agents/v2/workspaces/{workspace}/jobs/analyze/{name}: get: tags: - Agents summary: Get Job - operationId: get_job_apis_agents_v2_workspaces__workspace__jobs_evaluate_suite__name__get + operationId: get_job_apis_agents_v2_workspaces__workspace__jobs_analyze__name__get parameters: - name: workspace in: path @@ -1053,7 +1015,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/EvaluateSuiteJob' + $ref: '#/components/schemas/AnalyzeJob' '422': description: Validation Error content: @@ -1064,7 +1026,7 @@ paths: tags: - Agents summary: Delete Job - operationId: delete_job_apis_agents_v2_workspaces__workspace__jobs_evaluate_suite__name__delete + operationId: delete_job_apis_agents_v2_workspaces__workspace__jobs_analyze__name__delete parameters: - name: workspace in: path @@ -1087,12 +1049,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/evaluate-suite/{name}/cancel: + /apis/agents/v2/workspaces/{workspace}/jobs/analyze/{name}/cancel: post: tags: - Agents summary: Cancel Job - operationId: cancel_job_apis_agents_v2_workspaces__workspace__jobs_evaluate_suite__name__cancel_post + operationId: cancel_job_apis_agents_v2_workspaces__workspace__jobs_analyze__name__cancel_post parameters: - name: workspace in: path @@ -1112,19 +1074,19 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/EvaluateSuiteJob' + $ref: '#/components/schemas/AnalyzeJob' '422': description: Validation Error content: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/evaluate-suite/{name}/logs: + /apis/agents/v2/workspaces/{workspace}/jobs/analyze/{name}/logs: get: tags: - Agents summary: Get Job Logs - operationId: get_job_logs_apis_agents_v2_workspaces__workspace__jobs_evaluate_suite__name__logs_get + operationId: get_job_logs_apis_agents_v2_workspaces__workspace__jobs_analyze__name__logs_get parameters: - name: workspace in: path @@ -1163,12 +1125,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/evaluate-suite/{name}/results: + /apis/agents/v2/workspaces/{workspace}/jobs/analyze/{name}/results: get: tags: - Agents summary: List Job Results - operationId: list_job_results_apis_agents_v2_workspaces__workspace__jobs_evaluate_suite__name__results_get + operationId: list_job_results_apis_agents_v2_workspaces__workspace__jobs_analyze__name__results_get parameters: - name: workspace in: path @@ -1195,12 +1157,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/evaluate-suite/{name}/status: + /apis/agents/v2/workspaces/{workspace}/jobs/analyze/{name}/status: get: tags: - Agents summary: Get Job Status - operationId: get_job_status_apis_agents_v2_workspaces__workspace__jobs_evaluate_suite__name__status_get + operationId: get_job_status_apis_agents_v2_workspaces__workspace__jobs_analyze__name__status_get parameters: - name: workspace in: path @@ -1227,12 +1189,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/evaluate/{job}/results/{name}: - get: + /apis/agents/v2/workspaces/{workspace}/jobs/evaluate: + post: tags: - Agents - summary: Get Job Result - operationId: get_job_result_apis_agents_v2_workspaces__workspace__jobs_evaluate__job__results__name__get + summary: Create Job + operationId: create_job_apis_agents_v2_workspaces__workspace__jobs_evaluate_post parameters: - name: workspace in: path @@ -1240,20 +1202,212 @@ paths: schema: type: string title: Workspace - - name: job - in: path - required: true - schema: - type: string - title: Job - - name: name - in: path + requestBody: required: true - schema: - type: string - title: Name + content: + application/json: + schema: + $ref: '#/components/schemas/EvaluateJobRequest' responses: - '200': + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/EvaluateJob' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Agents + summary: List Jobs + operationId: list_jobs_apis_agents_v2_workspaces__workspace__jobs_evaluate_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: page + in: query + required: false + schema: + type: integer + exclusiveMinimum: 0 + description: Page number. + default: 1 + title: Page + description: Page number. + - name: page_size + in: query + required: false + schema: + type: integer + exclusiveMinimum: 0 + description: Page size. + default: 10 + title: Page Size + description: Page size. + - name: sort + in: query + required: false + schema: + allOf: + - $ref: '#/components/schemas/EvaluateJobsSortField' + description: The field to sort by. To sort in decreasing order, use `-` + in front of the field name. + default: -created_at + description: The field to sort by. To sort in decreasing order, use `-` in + front of the field name. + - in: query + name: filter + style: deepObject + required: false + explode: true + schema: + $ref: '#/components/schemas/EvaluateJobsListFilter' + description: Filter jobs on various criteria. + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/EvaluateJobsPage' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/agents/v2/workspaces/{workspace}/jobs/evaluate-suite: + post: + tags: + - Agents + summary: Create Job + operationId: create_job_apis_agents_v2_workspaces__workspace__jobs_evaluate_suite_post + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EvaluateSuiteJobRequest' + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/EvaluateSuiteJob' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Agents + summary: List Jobs + operationId: list_jobs_apis_agents_v2_workspaces__workspace__jobs_evaluate_suite_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: page + in: query + required: false + schema: + type: integer + exclusiveMinimum: 0 + description: Page number. + default: 1 + title: Page + description: Page number. + - name: page_size + in: query + required: false + schema: + type: integer + exclusiveMinimum: 0 + description: Page size. + default: 10 + title: Page Size + description: Page size. + - name: sort + in: query + required: false + schema: + allOf: + - $ref: '#/components/schemas/EvaluateSuiteJobsSortField' + description: The field to sort by. To sort in decreasing order, use `-` + in front of the field name. + default: -created_at + description: The field to sort by. To sort in decreasing order, use `-` in + front of the field name. + - in: query + name: filter + style: deepObject + required: false + explode: true + schema: + $ref: '#/components/schemas/EvaluateSuiteJobsListFilter' + description: Filter jobs on various criteria. + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/EvaluateSuiteJobsPage' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/agents/v2/workspaces/{workspace}/jobs/evaluate-suite/{job}/results/{name}: + get: + tags: + - Agents + summary: Get Job Result + operationId: get_job_result_apis_agents_v2_workspaces__workspace__jobs_evaluate_suite__job__results__name__get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: job + in: path + required: true + schema: + type: string + title: Job + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': description: Successful Response content: application/json: @@ -1265,12 +1419,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/evaluate/{job}/results/{name}/download: + /apis/agents/v2/workspaces/{workspace}/jobs/evaluate-suite/{job}/results/{name}/download: get: tags: - Agents summary: Download Job Result - operationId: download_job_result_apis_agents_v2_workspaces__workspace__jobs_evaluate__job__results__name__download_get + operationId: download_job_result_apis_agents_v2_workspaces__workspace__jobs_evaluate_suite__job__results__name__download_get parameters: - name: workspace in: path @@ -1306,12 +1460,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/evaluate/{name}: + /apis/agents/v2/workspaces/{workspace}/jobs/evaluate-suite/{name}: get: tags: - Agents summary: Get Job - operationId: get_job_apis_agents_v2_workspaces__workspace__jobs_evaluate__name__get + operationId: get_job_apis_agents_v2_workspaces__workspace__jobs_evaluate_suite__name__get parameters: - name: workspace in: path @@ -1331,7 +1485,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/EvaluateJob' + $ref: '#/components/schemas/EvaluateSuiteJob' '422': description: Validation Error content: @@ -1342,7 +1496,7 @@ paths: tags: - Agents summary: Delete Job - operationId: delete_job_apis_agents_v2_workspaces__workspace__jobs_evaluate__name__delete + operationId: delete_job_apis_agents_v2_workspaces__workspace__jobs_evaluate_suite__name__delete parameters: - name: workspace in: path @@ -1365,12 +1519,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/evaluate/{name}/cancel: + /apis/agents/v2/workspaces/{workspace}/jobs/evaluate-suite/{name}/cancel: post: tags: - Agents summary: Cancel Job - operationId: cancel_job_apis_agents_v2_workspaces__workspace__jobs_evaluate__name__cancel_post + operationId: cancel_job_apis_agents_v2_workspaces__workspace__jobs_evaluate_suite__name__cancel_post parameters: - name: workspace in: path @@ -1390,19 +1544,19 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/EvaluateJob' + $ref: '#/components/schemas/EvaluateSuiteJob' '422': description: Validation Error content: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/evaluate/{name}/logs: + /apis/agents/v2/workspaces/{workspace}/jobs/evaluate-suite/{name}/logs: get: tags: - Agents summary: Get Job Logs - operationId: get_job_logs_apis_agents_v2_workspaces__workspace__jobs_evaluate__name__logs_get + operationId: get_job_logs_apis_agents_v2_workspaces__workspace__jobs_evaluate_suite__name__logs_get parameters: - name: workspace in: path @@ -1441,12 +1595,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/evaluate/{name}/results: + /apis/agents/v2/workspaces/{workspace}/jobs/evaluate-suite/{name}/results: get: tags: - Agents summary: List Job Results - operationId: list_job_results_apis_agents_v2_workspaces__workspace__jobs_evaluate__name__results_get + operationId: list_job_results_apis_agents_v2_workspaces__workspace__jobs_evaluate_suite__name__results_get parameters: - name: workspace in: path @@ -1473,12 +1627,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/evaluate/{name}/status: + /apis/agents/v2/workspaces/{workspace}/jobs/evaluate-suite/{name}/status: get: tags: - Agents summary: Get Job Status - operationId: get_job_status_apis_agents_v2_workspaces__workspace__jobs_evaluate__name__status_get + operationId: get_job_status_apis_agents_v2_workspaces__workspace__jobs_evaluate_suite__name__status_get parameters: - name: workspace in: path @@ -1505,12 +1659,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/optimize: - post: + /apis/agents/v2/workspaces/{workspace}/jobs/evaluate/{job}/results/{name}: + get: tags: - Agents - summary: Create Job - operationId: create_job_apis_agents_v2_workspaces__workspace__jobs_optimize_post + summary: Get Job Result + operationId: get_job_result_apis_agents_v2_workspaces__workspace__jobs_evaluate__job__results__name__get parameters: - name: workspace in: path @@ -1518,229 +1672,37 @@ paths: schema: type: string title: Workspace - requestBody: + - name: job + in: path required: true - content: - application/json: - schema: - $ref: '#/components/schemas/OptimizeJobRequest' + schema: + type: string + title: Job + - name: name + in: path + required: true + schema: + type: string + title: Name responses: - '201': + '200': description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/OptimizeJob' + $ref: '#/components/schemas/PlatformJobResultResponse' '422': description: Validation Error content: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - get: - tags: - - Agents - summary: List Jobs - operationId: list_jobs_apis_agents_v2_workspaces__workspace__jobs_optimize_get - parameters: - - name: workspace - in: path - required: true - schema: - type: string - title: Workspace - - name: page - in: query - required: false - schema: - type: integer - exclusiveMinimum: 0 - description: Page number. - default: 1 - title: Page - description: Page number. - - name: page_size - in: query - required: false - schema: - type: integer - exclusiveMinimum: 0 - description: Page size. - default: 10 - title: Page Size - description: Page size. - - name: sort - in: query - required: false - schema: - allOf: - - $ref: '#/components/schemas/OptimizeJobsSortField' - description: The field to sort by. To sort in decreasing order, use `-` - in front of the field name. - default: -created_at - description: The field to sort by. To sort in decreasing order, use `-` in - front of the field name. - - in: query - name: filter - style: deepObject - required: false - explode: true - schema: - $ref: '#/components/schemas/OptimizeJobsListFilter' - description: Filter jobs on various criteria. - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/OptimizeJobsPage' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/optimize-skills: - post: - tags: - - Agents - summary: Create Job - operationId: create_job_apis_agents_v2_workspaces__workspace__jobs_optimize_skills_post - parameters: - - name: workspace - in: path - required: true - schema: - type: string - title: Workspace - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/OptimizeSkillsJobRequest' - responses: - '201': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/OptimizeSkillsJob' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - get: - tags: - - Agents - summary: List Jobs - operationId: list_jobs_apis_agents_v2_workspaces__workspace__jobs_optimize_skills_get - parameters: - - name: workspace - in: path - required: true - schema: - type: string - title: Workspace - - name: page - in: query - required: false - schema: - type: integer - exclusiveMinimum: 0 - description: Page number. - default: 1 - title: Page - description: Page number. - - name: page_size - in: query - required: false - schema: - type: integer - exclusiveMinimum: 0 - description: Page size. - default: 10 - title: Page Size - description: Page size. - - name: sort - in: query - required: false - schema: - allOf: - - $ref: '#/components/schemas/OptimizeSkillsJobsSortField' - description: The field to sort by. To sort in decreasing order, use `-` - in front of the field name. - default: -created_at - description: The field to sort by. To sort in decreasing order, use `-` in - front of the field name. - - in: query - name: filter - style: deepObject - required: false - explode: true - schema: - $ref: '#/components/schemas/OptimizeSkillsJobsListFilter' - description: Filter jobs on various criteria. - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/OptimizeSkillsJobsPage' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/optimize-skills/{job}/results/{name}: - get: - tags: - - Agents - summary: Get Job Result - operationId: get_job_result_apis_agents_v2_workspaces__workspace__jobs_optimize_skills__job__results__name__get - parameters: - - name: workspace - in: path - required: true - schema: - type: string - title: Workspace - - name: job - in: path - required: true - schema: - type: string - title: Job - - name: name - in: path - required: true - schema: - type: string - title: Name - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/PlatformJobResultResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/optimize-skills/{job}/results/{name}/download: + /apis/agents/v2/workspaces/{workspace}/jobs/evaluate/{job}/results/{name}/download: get: tags: - Agents summary: Download Job Result - operationId: download_job_result_apis_agents_v2_workspaces__workspace__jobs_optimize_skills__job__results__name__download_get + operationId: download_job_result_apis_agents_v2_workspaces__workspace__jobs_evaluate__job__results__name__download_get parameters: - name: workspace in: path @@ -1776,12 +1738,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/optimize-skills/{name}: + /apis/agents/v2/workspaces/{workspace}/jobs/evaluate/{name}: get: tags: - Agents summary: Get Job - operationId: get_job_apis_agents_v2_workspaces__workspace__jobs_optimize_skills__name__get + operationId: get_job_apis_agents_v2_workspaces__workspace__jobs_evaluate__name__get parameters: - name: workspace in: path @@ -1801,7 +1763,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/OptimizeSkillsJob' + $ref: '#/components/schemas/EvaluateJob' '422': description: Validation Error content: @@ -1812,7 +1774,7 @@ paths: tags: - Agents summary: Delete Job - operationId: delete_job_apis_agents_v2_workspaces__workspace__jobs_optimize_skills__name__delete + operationId: delete_job_apis_agents_v2_workspaces__workspace__jobs_evaluate__name__delete parameters: - name: workspace in: path @@ -1835,12 +1797,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/optimize-skills/{name}/cancel: + /apis/agents/v2/workspaces/{workspace}/jobs/evaluate/{name}/cancel: post: tags: - Agents summary: Cancel Job - operationId: cancel_job_apis_agents_v2_workspaces__workspace__jobs_optimize_skills__name__cancel_post + operationId: cancel_job_apis_agents_v2_workspaces__workspace__jobs_evaluate__name__cancel_post parameters: - name: workspace in: path @@ -1860,19 +1822,19 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/OptimizeSkillsJob' + $ref: '#/components/schemas/EvaluateJob' '422': description: Validation Error content: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/optimize-skills/{name}/logs: + /apis/agents/v2/workspaces/{workspace}/jobs/evaluate/{name}/logs: get: tags: - Agents summary: Get Job Logs - operationId: get_job_logs_apis_agents_v2_workspaces__workspace__jobs_optimize_skills__name__logs_get + operationId: get_job_logs_apis_agents_v2_workspaces__workspace__jobs_evaluate__name__logs_get parameters: - name: workspace in: path @@ -1911,12 +1873,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/optimize-skills/{name}/results: + /apis/agents/v2/workspaces/{workspace}/jobs/evaluate/{name}/results: get: tags: - Agents summary: List Job Results - operationId: list_job_results_apis_agents_v2_workspaces__workspace__jobs_optimize_skills__name__results_get + operationId: list_job_results_apis_agents_v2_workspaces__workspace__jobs_evaluate__name__results_get parameters: - name: workspace in: path @@ -1943,12 +1905,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/optimize-skills/{name}/status: + /apis/agents/v2/workspaces/{workspace}/jobs/evaluate/{name}/status: get: tags: - Agents summary: Get Job Status - operationId: get_job_status_apis_agents_v2_workspaces__workspace__jobs_optimize_skills__name__status_get + operationId: get_job_status_apis_agents_v2_workspaces__workspace__jobs_evaluate__name__status_get parameters: - name: workspace in: path @@ -1975,12 +1937,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/optimize/{job}/results/{name}: - get: + /apis/agents/v2/workspaces/{workspace}/jobs/optimize: + post: tags: - Agents - summary: Get Job Result - operationId: get_job_result_apis_agents_v2_workspaces__workspace__jobs_optimize__job__results__name__get + summary: Create Job + operationId: create_job_apis_agents_v2_workspaces__workspace__jobs_optimize_post parameters: - name: workspace in: path @@ -1988,37 +1950,229 @@ paths: schema: type: string title: Workspace - - name: job - in: path - required: true - schema: - type: string - title: Job - - name: name - in: path + requestBody: required: true - schema: - type: string - title: Name + content: + application/json: + schema: + $ref: '#/components/schemas/OptimizeJobRequest' responses: - '200': + '201': description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/PlatformJobResultResponse' + $ref: '#/components/schemas/OptimizeJob' '422': description: Validation Error content: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/optimize/{job}/results/{name}/download: + get: + tags: + - Agents + summary: List Jobs + operationId: list_jobs_apis_agents_v2_workspaces__workspace__jobs_optimize_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: page + in: query + required: false + schema: + type: integer + exclusiveMinimum: 0 + description: Page number. + default: 1 + title: Page + description: Page number. + - name: page_size + in: query + required: false + schema: + type: integer + exclusiveMinimum: 0 + description: Page size. + default: 10 + title: Page Size + description: Page size. + - name: sort + in: query + required: false + schema: + allOf: + - $ref: '#/components/schemas/OptimizeJobsSortField' + description: The field to sort by. To sort in decreasing order, use `-` + in front of the field name. + default: -created_at + description: The field to sort by. To sort in decreasing order, use `-` in + front of the field name. + - in: query + name: filter + style: deepObject + required: false + explode: true + schema: + $ref: '#/components/schemas/OptimizeJobsListFilter' + description: Filter jobs on various criteria. + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/OptimizeJobsPage' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/agents/v2/workspaces/{workspace}/jobs/optimize-skills: + post: + tags: + - Agents + summary: Create Job + operationId: create_job_apis_agents_v2_workspaces__workspace__jobs_optimize_skills_post + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OptimizeSkillsJobRequest' + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/OptimizeSkillsJob' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Agents + summary: List Jobs + operationId: list_jobs_apis_agents_v2_workspaces__workspace__jobs_optimize_skills_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: page + in: query + required: false + schema: + type: integer + exclusiveMinimum: 0 + description: Page number. + default: 1 + title: Page + description: Page number. + - name: page_size + in: query + required: false + schema: + type: integer + exclusiveMinimum: 0 + description: Page size. + default: 10 + title: Page Size + description: Page size. + - name: sort + in: query + required: false + schema: + allOf: + - $ref: '#/components/schemas/OptimizeSkillsJobsSortField' + description: The field to sort by. To sort in decreasing order, use `-` + in front of the field name. + default: -created_at + description: The field to sort by. To sort in decreasing order, use `-` in + front of the field name. + - in: query + name: filter + style: deepObject + required: false + explode: true + schema: + $ref: '#/components/schemas/OptimizeSkillsJobsListFilter' + description: Filter jobs on various criteria. + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/OptimizeSkillsJobsPage' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/agents/v2/workspaces/{workspace}/jobs/optimize-skills/{job}/results/{name}: + get: + tags: + - Agents + summary: Get Job Result + operationId: get_job_result_apis_agents_v2_workspaces__workspace__jobs_optimize_skills__job__results__name__get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: job + in: path + required: true + schema: + type: string + title: Job + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformJobResultResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/agents/v2/workspaces/{workspace}/jobs/optimize-skills/{job}/results/{name}/download: get: tags: - Agents summary: Download Job Result - operationId: download_job_result_apis_agents_v2_workspaces__workspace__jobs_optimize__job__results__name__download_get + operationId: download_job_result_apis_agents_v2_workspaces__workspace__jobs_optimize_skills__job__results__name__download_get parameters: - name: workspace in: path @@ -2054,12 +2208,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/optimize/{name}: + /apis/agents/v2/workspaces/{workspace}/jobs/optimize-skills/{name}: get: tags: - Agents summary: Get Job - operationId: get_job_apis_agents_v2_workspaces__workspace__jobs_optimize__name__get + operationId: get_job_apis_agents_v2_workspaces__workspace__jobs_optimize_skills__name__get parameters: - name: workspace in: path @@ -2079,7 +2233,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/OptimizeJob' + $ref: '#/components/schemas/OptimizeSkillsJob' '422': description: Validation Error content: @@ -2090,7 +2244,7 @@ paths: tags: - Agents summary: Delete Job - operationId: delete_job_apis_agents_v2_workspaces__workspace__jobs_optimize__name__delete + operationId: delete_job_apis_agents_v2_workspaces__workspace__jobs_optimize_skills__name__delete parameters: - name: workspace in: path @@ -2113,12 +2267,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/optimize/{name}/cancel: + /apis/agents/v2/workspaces/{workspace}/jobs/optimize-skills/{name}/cancel: post: tags: - Agents summary: Cancel Job - operationId: cancel_job_apis_agents_v2_workspaces__workspace__jobs_optimize__name__cancel_post + operationId: cancel_job_apis_agents_v2_workspaces__workspace__jobs_optimize_skills__name__cancel_post parameters: - name: workspace in: path @@ -2138,19 +2292,19 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/OptimizeJob' + $ref: '#/components/schemas/OptimizeSkillsJob' '422': description: Validation Error content: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/optimize/{name}/logs: + /apis/agents/v2/workspaces/{workspace}/jobs/optimize-skills/{name}/logs: get: tags: - Agents summary: Get Job Logs - operationId: get_job_logs_apis_agents_v2_workspaces__workspace__jobs_optimize__name__logs_get + operationId: get_job_logs_apis_agents_v2_workspaces__workspace__jobs_optimize_skills__name__logs_get parameters: - name: workspace in: path @@ -2189,12 +2343,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/optimize/{name}/results: + /apis/agents/v2/workspaces/{workspace}/jobs/optimize-skills/{name}/results: get: tags: - Agents summary: List Job Results - operationId: list_job_results_apis_agents_v2_workspaces__workspace__jobs_optimize__name__results_get + operationId: list_job_results_apis_agents_v2_workspaces__workspace__jobs_optimize_skills__name__results_get parameters: - name: workspace in: path @@ -2221,12 +2375,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /apis/agents/v2/workspaces/{workspace}/jobs/optimize/{name}/status: + /apis/agents/v2/workspaces/{workspace}/jobs/optimize-skills/{name}/status: get: tags: - Agents summary: Get Job Status - operationId: get_job_status_apis_agents_v2_workspaces__workspace__jobs_optimize__name__status_get + operationId: get_job_status_apis_agents_v2_workspaces__workspace__jobs_optimize_skills__name__status_get parameters: - name: workspace in: path @@ -2253,41 +2407,400 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' -components: - schemas: - Agent: - properties: - name: - type: string - title: Name - description: Entity name within the workspace - default: '' - workspace: + /apis/agents/v2/workspaces/{workspace}/jobs/optimize/{job}/results/{name}: + get: + tags: + - Agents + summary: Get Job Result + operationId: get_job_result_apis_agents_v2_workspaces__workspace__jobs_optimize__job__results__name__get + parameters: + - name: workspace + in: path + required: true + schema: type: string - pattern: ^[\w\-\+.@:]+$ title: Workspace - description: Workspace identifier - project: - title: Project - description: The name of the project associated with this entity. - type: string - description: + - name: job + in: path + required: true + schema: + type: string + title: Job + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformJobResultResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/agents/v2/workspaces/{workspace}/jobs/optimize/{job}/results/{name}/download: + get: + tags: + - Agents + summary: Download Job Result + operationId: download_job_result_apis_agents_v2_workspaces__workspace__jobs_optimize__job__results__name__download_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: job + in: path + required: true + schema: + type: string + title: Job + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/octet-stream: + schema: + type: string + format: binary + '404': + description: Not Found + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/agents/v2/workspaces/{workspace}/jobs/optimize/{name}: + get: + tags: + - Agents + summary: Get Job + operationId: get_job_apis_agents_v2_workspaces__workspace__jobs_optimize__name__get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/OptimizeJob' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Agents + summary: Delete Job + operationId: delete_job_apis_agents_v2_workspaces__workspace__jobs_optimize__name__delete + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '204': + description: Successful Response + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/agents/v2/workspaces/{workspace}/jobs/optimize/{name}/cancel: + post: + tags: + - Agents + summary: Cancel Job + operationId: cancel_job_apis_agents_v2_workspaces__workspace__jobs_optimize__name__cancel_post + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/OptimizeJob' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/agents/v2/workspaces/{workspace}/jobs/optimize/{name}/logs: + get: + tags: + - Agents + summary: Get Job Logs + operationId: get_job_logs_apis_agents_v2_workspaces__workspace__jobs_optimize__name__logs_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + - name: limit + in: query + required: false + schema: + title: Limit + type: integer + - name: page_cursor + in: query + required: false + schema: + title: Page Cursor + type: string + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformJobLogPage' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/agents/v2/workspaces/{workspace}/jobs/optimize/{name}/results: + get: + tags: + - Agents + summary: List Job Results + operationId: list_job_results_apis_agents_v2_workspaces__workspace__jobs_optimize__name__results_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformJobListResultResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/agents/v2/workspaces/{workspace}/jobs/optimize/{name}/status: + get: + tags: + - Agents + summary: Get Job Status + operationId: get_job_status_apis_agents_v2_workspaces__workspace__jobs_optimize__name__status_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformJobStatusResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' +components: + schemas: + Agent: + properties: + name: + type: string + title: Name + description: Entity name within the workspace + default: '' + workspace: + type: string + pattern: ^[\w\-\+.@:]+$ + title: Workspace + description: Workspace identifier + project: + title: Project + description: The name of the project associated with this entity. + type: string + description: + type: string + title: Description + description: Human-readable description of the agent. + default: '' + config: + additionalProperties: true + type: object + title: Config + description: Agent config dict interpreted according to config_format. + config_format: + type: string + title: Config Format + description: platform-internal schema version tag for the agent config dict. + `nat-workflow-v1` is the default legacy NAT workflow format; `nemo-agents-spec-v1` + identifies the Platform-owned agent.yaml spec format. + default: nat-workflow-v1 + id: + type: string + title: Id + readOnly: true + created_at: + title: Created At + readOnly: true + type: string + format: date-time + created_by: + title: Created By + readOnly: true + nullable: true + type: string + updated_at: + title: Updated At + readOnly: true + type: string + format: date-time + updated_by: + title: Updated By + readOnly: true + nullable: true + type: string + entity_id: + type: string + title: Entity Id + description: Alias for id for backwards compatibility. + readOnly: true + parent: + title: Parent + description: Parent entity ID for nested entities. + readOnly: true + type: string + db_version: + type: integer + title: Db Version + description: Database version of the entity for optimistic locking. + readOnly: true + type: object + required: + - workspace + - id + - created_at + - created_by + - updated_at + - updated_by + - entity_id + - parent + - db_version + title: Agent + description: "An agent definition \u2014 stores agent config and metadata.\n\ + \nEntity type: ``agent``\nPrimary lookup: by ``name`` within a ``workspace``.\n\ + \nThe agent's spec files live at the locations returned by\n:func:`agent_spec_file_ref`\ + \ and :func:`agent_config_file_ref` \u2014 they\nare **not** stored on the\ + \ entity because the paths are fully derivable\nfrom ``(workspace, name)``." + AgentComputeSpec: + properties: + description: type: string title: Description - description: Human-readable description of the agent. + description: Human-readable description. default: '' - config: - additionalProperties: true - type: object - title: Config - description: Agent config dict interpreted according to config_format. - config_format: + resources: + allOf: + - $ref: '#/components/schemas/ComputeResources' + description: k8s-style resource requests/limits for the execute container. + name: + type: string + title: Name + description: Entity name within the workspace + default: '' + workspace: + type: string + pattern: ^[\w\-\+.@:]+$ + title: Workspace + description: Workspace identifier + project: + title: Project + description: The name of the project associated with this entity. type: string - title: Config Format - description: platform-internal schema version tag for the agent config dict. - `nat-workflow-v1` is the default legacy NAT workflow format; `nemo-agents-spec-v1` - identifies the Platform-owned agent.yaml spec format. - default: nat-workflow-v1 id: type: string title: Id @@ -2338,12 +2851,13 @@ components: - entity_id - parent - db_version - title: Agent - description: "An agent definition \u2014 stores agent config and metadata.\n\ - \nEntity type: ``agent``\nPrimary lookup: by ``name`` within a ``workspace``.\n\ - \nThe agent's spec files live at the locations returned by\n:func:`agent_spec_file_ref`\ - \ and :func:`agent_config_file_ref` \u2014 they\nare **not** stored on the\ - \ entity because the paths are fully derivable\nfrom ``(workspace, name)``." + title: AgentComputeSpec + description: 'A reusable compute spec (k8s-style resource requests/limits). + + + Entity type: ``agent_compute_spec`` + + Referenced by an AgentEnvironment''s ``compute_spec`` (by name or inline).' AgentDeployment: properties: name: @@ -2369,8 +2883,24 @@ components: additionalProperties: true type: object title: Config - description: Resolved agent config with IGW URL injected, written when the - deployment is created. + description: Resolved agent config with IGW URL injected and any referenced + environment spec merged in, written when the deployment is created. + environment: + anyOf: + - type: string + title: Reference + description: A reference to AgentEnvironmentInline. + - $ref: '#/components/schemas/AgentEnvironmentInline' + title: Environment + description: '"workspace/name" ref to an AgentEnvironment, an inline environment, + or None. Snapshotted at create time for provenance; the resolved values + live in config/compute.' + compute: + allOf: + - $ref: '#/components/schemas/ComputeSpecInline' + description: Resolved compute snapshot from the referenced environment. + Compiled into the container resources for docker/k8s modes; ignored for + subprocess. status: type: string enum: @@ -2395,43 +2925,312 @@ components: default: subprocess endpoint: type: string - title: Endpoint - description: Subprocess loopback endpoint of the agent process (e.g. http://localhost:9001). - default: '' - endpoints: - items: - $ref: '#/components/schemas/Endpoint' - type: array - title: Endpoints - description: Routable endpoints for container modes, projected from the - deployments-plugin Deployment. Empty for subprocess mode (which uses 'endpoint'). - image: + title: Endpoint + description: Subprocess loopback endpoint of the agent process (e.g. http://localhost:9001). + default: '' + endpoints: + items: + $ref: '#/components/schemas/Endpoint' + type: array + title: Endpoints + description: Routable endpoints for container modes, projected from the + deployments-plugin Deployment. Empty for subprocess mode (which uses 'endpoint'). + image: + type: string + title: Image + description: Container image for docker/k8s modes. Empty for subprocess; + falls back to AgentsConfig.deployments.default_image. + default: '' + plugin_deployment: + type: string + title: Plugin Deployment + description: Name of the linked nemo-deployments Deployment entity. Defaults + to this deployment's name when empty (set by the controller on create). + default: '' + port: + type: integer + title: Port + description: Port the agent process is listening on. + default: 0 + pid: + type: integer + title: Pid + description: OS process ID of the agent subprocess. + default: 0 + error: + type: string + title: Error + description: Error message if status is 'failed'. + default: '' + id: + type: string + title: Id + readOnly: true + created_at: + title: Created At + readOnly: true + type: string + format: date-time + created_by: + title: Created By + readOnly: true + nullable: true + type: string + updated_at: + title: Updated At + readOnly: true + type: string + format: date-time + updated_by: + title: Updated By + readOnly: true + nullable: true + type: string + entity_id: + type: string + title: Entity Id + description: Alias for id for backwards compatibility. + readOnly: true + parent: + title: Parent + description: Parent entity ID for nested entities. + readOnly: true + type: string + db_version: + type: integer + title: Db Version + description: Database version of the entity for optimistic locking. + readOnly: true + type: object + required: + - workspace + - id + - created_at + - created_by + - updated_at + - updated_by + - entity_id + - parent + - db_version + title: AgentDeployment + description: "A running (or pending) deployment of an Agent.\n\nEntity type:\ + \ ``agent_deployment``\nLifecycle: pending \u2192 starting \u2192 running\ + \ | failed.\nThe :class:`~nemo_agents_plugin.runner.controller.AgentDeploymentController`\n\ + drives state transitions by reconciling this entity against the\n:class:`~nemo_agents_plugin.runner.backend.RunnerBackend`." + AgentEnvironment: + properties: + description: + type: string + title: Description + description: Human-readable description. + default: '' + environment_spec: + anyOf: + - type: string + title: Reference + description: A reference to EnvironmentSpecInline. + - $ref: '#/components/schemas/EnvironmentSpecInline' + title: Environment Spec + description: '"workspace/name" ref to an AgentEnvironmentSpec, an inline + spec, or None.' + compute_spec: + anyOf: + - type: string + title: Reference + description: A reference to ComputeSpecInline. + - $ref: '#/components/schemas/ComputeSpecInline' + title: Compute Spec + description: '"workspace/name" ref to an AgentComputeSpec, an inline spec, + or None.' + name: + type: string + title: Name + description: Entity name within the workspace + default: '' + workspace: + type: string + pattern: ^[\w\-\+.@:]+$ + title: Workspace + description: Workspace identifier + project: + title: Project + description: The name of the project associated with this entity. + type: string + id: + type: string + title: Id + readOnly: true + created_at: + title: Created At + readOnly: true + type: string + format: date-time + created_by: + title: Created By + readOnly: true + nullable: true + type: string + updated_at: + title: Updated At + readOnly: true + type: string + format: date-time + updated_by: + title: Updated By + readOnly: true + nullable: true + type: string + entity_id: + type: string + title: Entity Id + description: Alias for id for backwards compatibility. + readOnly: true + parent: + title: Parent + description: Parent entity ID for nested entities. + readOnly: true + type: string + db_version: + type: integer + title: Db Version + description: Database version of the entity for optimistic locking. + readOnly: true + type: object + required: + - workspace + - id + - created_at + - created_by + - updated_at + - updated_by + - entity_id + - parent + - db_version + title: AgentEnvironment + description: 'A composition of an environment spec and a compute spec. + + + Entity type: ``agent_environment`` + + The single thing an AgentDeployment references. Each part is a + + ``ref | inline | None`` union so specs can be authored once and reused.' + AgentEnvironmentInline: + properties: + description: + type: string + title: Description + description: Human-readable description. + default: '' + environment_spec: + anyOf: + - type: string + title: Reference + description: A reference to EnvironmentSpecInline. + - $ref: '#/components/schemas/EnvironmentSpecInline' + title: Environment Spec + description: '"workspace/name" ref to an AgentEnvironmentSpec, an inline + spec, or None.' + compute_spec: + anyOf: + - type: string + title: Reference + description: A reference to ComputeSpecInline. + - $ref: '#/components/schemas/ComputeSpecInline' + title: Compute Spec + description: '"workspace/name" ref to an AgentComputeSpec, an inline spec, + or None.' + type: object + title: AgentEnvironmentInline + description: 'Inline AgentEnvironment - a composition of environment + 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.)' + AgentEnvironmentSpec: + properties: + description: + type: string + title: Description + description: Human-readable description. + default: '' + env: + additionalProperties: + type: string + type: object + title: Env + description: Plaintext, non-secret env vars. + secrets: + additionalProperties: + type: string + type: object + title: Secrets + description: ENV_VAR_NAME -> Secrets-service/plugin ref. + model_provider_override: + allOf: + - $ref: '#/components/schemas/ModelProviderOverride' + description: Set only to point at a non-IGW external model provider. + provider: + type: string + title: Provider + description: local | docker | opensandbox | k8s. + default: local + workspace_path: + title: Workspace Path + description: Workspace path visible to the harness. + type: string + artifacts_path: + title: Artifacts Path + description: Provider-specific artifact output location. + type: string + control_location: + title: Control Location + description: external_control | in_env_control. + type: string + ownership: + title: Ownership + description: caller_owned | fabric_owned. + type: string + connection: + additionalProperties: true + type: object + title: Connection + description: Provider connection metadata (server url, cred ref, namespace). + metadata: + additionalProperties: true + type: object + title: Metadata + description: Consumer-provided passthrough metadata. + settings: + additionalProperties: true + type: object + title: Settings + description: Provider-specific settings. + mcp: + additionalProperties: + $ref: '#/components/schemas/McpFulfillment' + type: object + title: Mcp + description: server-name -> fulfillment (url/env/secrets) for an Agent-declared + MCP dependency. + name: type: string - title: Image - description: Container image for docker/k8s modes. Empty for subprocess; - falls back to AgentsConfig.deployments.default_image. + title: Name + description: Entity name within the workspace default: '' - plugin_deployment: + workspace: type: string - title: Plugin Deployment - description: Name of the linked nemo-deployments Deployment entity. Defaults - to this deployment's name when empty (set by the controller on create). - default: '' - port: - type: integer - title: Port - description: Port the agent process is listening on. - default: 0 - pid: - type: integer - title: Pid - description: OS process ID of the agent subprocess. - default: 0 - error: + pattern: ^[\w\-\+.@:]+$ + title: Workspace + description: Workspace identifier + project: + title: Project + description: The name of the project associated with this entity. type: string - title: Error - description: Error message if status is 'failed'. - default: '' id: type: string title: Id @@ -2482,11 +3281,13 @@ components: - entity_id - parent - db_version - title: AgentDeployment - description: "A running (or pending) deployment of an Agent.\n\nEntity type:\ - \ ``agent_deployment``\nLifecycle: pending \u2192 starting \u2192 running\ - \ | failed.\nThe :class:`~nemo_agents_plugin.runner.controller.AgentDeploymentController`\n\ - drives state transitions by reconciling this entity against the\n:class:`~nemo_agents_plugin.runner.backend.RunnerBackend`." + title: AgentEnvironmentSpec + description: 'A reusable environment spec (the dependencies an agent reaches). + + + Entity type: ``agent_environment_spec`` + + Referenced by an AgentEnvironment''s ``environment_spec`` (by name or inline).' AnalyzeBatchConfig: properties: batch: @@ -2659,6 +3460,44 @@ components: - updated_at - -updated_at title: AnalyzeJobsSortField + ComputeResources: + properties: + limits: + additionalProperties: + type: string + type: object + title: Limits + description: k8s resource limits (e.g. cpu, memory, nvidia.com/gpu). + requests: + additionalProperties: + type: string + type: object + title: Requests + description: k8s resource requests. + type: object + title: ComputeResources + description: 'Kubernetes-style resource requests/limits. + + + Mirrors ``nemo_deployments_plugin.entities.ResourceRequirements`` so the + + agents entity schema does not depend on the deployments plugin. Compiled + + into the execute container''s resources for container deployment modes.' + ComputeSpecInline: + properties: + description: + type: string + title: Description + description: Human-readable description. + default: '' + resources: + allOf: + - $ref: '#/components/schemas/ComputeResources' + description: k8s-style resource requests/limits for the execute container. + type: object + title: ComputeSpecInline + description: Inline compute spec - the resources an invocation runs with. CreateAgentRequest: properties: name: @@ -2686,6 +3525,26 @@ components: - config title: CreateAgentRequest description: Request body for ``POST /v2/workspaces/{workspace}/agents``. + CreateComputeSpecRequest: + properties: + description: + type: string + title: Description + description: Human-readable description. + default: '' + resources: + allOf: + - $ref: '#/components/schemas/ComputeResources' + description: k8s-style resource requests/limits for the execute container. + name: + type: string + title: Name + description: Unique compute-spec name within the workspace. + type: object + required: + - name + title: CreateComputeSpecRequest + description: Request body for ``POST /v2/workspaces/{workspace}/compute-specs``. CreateDeploymentRequest: properties: agent: @@ -2711,11 +3570,130 @@ components: title: Image description: Container image for docker/k8s modes. Ignored for subprocess. default: '' + environment: + anyOf: + - type: string + title: Reference + description: A reference to AgentEnvironmentInline. + - $ref: '#/components/schemas/AgentEnvironmentInline' + title: Environment + description: 'Optional AgentEnvironment: a "workspace/name" ref, an inline + environment, or None. Resolved and snapshotted onto the deployment at + create time.' type: object required: - agent title: CreateDeploymentRequest description: Request body for ``POST /v2/workspaces/{workspace}/deployments``. + CreateEnvironmentRequest: + properties: + description: + type: string + title: Description + description: Human-readable description. + default: '' + environment_spec: + anyOf: + - type: string + title: Reference + description: A reference to EnvironmentSpecInline. + - $ref: '#/components/schemas/EnvironmentSpecInline' + title: Environment Spec + description: '"workspace/name" ref to an AgentEnvironmentSpec, an inline + spec, or None.' + compute_spec: + anyOf: + - type: string + title: Reference + description: A reference to ComputeSpecInline. + - $ref: '#/components/schemas/ComputeSpecInline' + title: Compute Spec + description: '"workspace/name" ref to an AgentComputeSpec, an inline spec, + or None.' + name: + type: string + title: Name + description: Unique environment name within the workspace. + type: object + required: + - name + title: CreateEnvironmentRequest + description: Request body for ``POST /v2/workspaces/{workspace}/environments``. + CreateEnvironmentSpecRequest: + properties: + description: + type: string + title: Description + description: Human-readable description. + default: '' + env: + additionalProperties: + type: string + type: object + title: Env + description: Plaintext, non-secret env vars. + secrets: + additionalProperties: + type: string + type: object + title: Secrets + description: ENV_VAR_NAME -> Secrets-service/plugin ref. + model_provider_override: + allOf: + - $ref: '#/components/schemas/ModelProviderOverride' + description: Set only to point at a non-IGW external model provider. + provider: + type: string + title: Provider + description: local | docker | opensandbox | k8s. + default: local + workspace_path: + title: Workspace Path + description: Workspace path visible to the harness. + type: string + artifacts_path: + title: Artifacts Path + description: Provider-specific artifact output location. + type: string + control_location: + title: Control Location + description: external_control | in_env_control. + type: string + ownership: + title: Ownership + description: caller_owned | fabric_owned. + type: string + connection: + additionalProperties: true + type: object + title: Connection + description: Provider connection metadata (server url, cred ref, namespace). + metadata: + additionalProperties: true + type: object + title: Metadata + description: Consumer-provided passthrough metadata. + settings: + additionalProperties: true + type: object + title: Settings + description: Provider-specific settings. + mcp: + additionalProperties: + $ref: '#/components/schemas/McpFulfillment' + type: object + title: Mcp + description: server-name -> fulfillment (url/env/secrets) for an Agent-declared + MCP dependency. + name: + type: string + title: Name + description: Unique environment-spec name within the workspace. + type: object + required: + - name + title: CreateEnvironmentSpecRequest + description: Request body for ``POST /v2/workspaces/{workspace}/environment-specs``. DatetimeFilter: additionalProperties: false properties: @@ -2786,6 +3764,85 @@ components: projected without the agents plugin depending on that plugin at the entity-schema layer.' + EnvironmentSpecInline: + properties: + description: + type: string + title: Description + description: Human-readable description. + default: '' + env: + additionalProperties: + type: string + type: object + title: Env + description: Plaintext, non-secret env vars. + secrets: + additionalProperties: + type: string + type: object + title: Secrets + description: ENV_VAR_NAME -> Secrets-service/plugin ref. + model_provider_override: + allOf: + - $ref: '#/components/schemas/ModelProviderOverride' + description: Set only to point at a non-IGW external model provider. + provider: + type: string + title: Provider + description: local | docker | opensandbox | k8s. + default: local + workspace_path: + title: Workspace Path + description: Workspace path visible to the harness. + type: string + artifacts_path: + title: Artifacts Path + description: Provider-specific artifact output location. + type: string + control_location: + title: Control Location + description: external_control | in_env_control. + type: string + ownership: + title: Ownership + description: caller_owned | fabric_owned. + type: string + connection: + additionalProperties: true + type: object + title: Connection + description: Provider connection metadata (server url, cred ref, namespace). + metadata: + additionalProperties: true + type: object + title: Metadata + description: Consumer-provided passthrough metadata. + settings: + additionalProperties: true + type: object + title: Settings + description: Provider-specific settings. + mcp: + additionalProperties: + $ref: '#/components/schemas/McpFulfillment' + type: object + title: Mcp + description: server-name -> fulfillment (url/env/secrets) for an Agent-declared + MCP dependency. + type: object + title: EnvironmentSpecInline + description: 'Inline environment spec - the dependencies and configuration an + agent reaches. + + + This is the fulfillment half of a request/fulfill split: the Agent declares + + the dependencies it needs; the EnvironmentSpec provides concrete endpoints + + and secret references. It compiles into the agent.yaml / FabricConfig and + + the injected process environment.' EvaluateAgentSpec: properties: agent: @@ -3343,6 +4400,89 @@ components: title: LogLine description: One line shaped to match ``PlatformJobLog`` so Studio's LogViewer renders it as-is. + McpFulfillment: + properties: + url: + type: string + title: Url + description: Endpoint the environment provides for this MCP server. + env: + additionalProperties: + type: string + type: object + title: Env + description: Non-secret env for the MCP server. + secrets: + additionalProperties: + type: string + type: object + title: Secrets + description: ENV_NAME -> Secrets-service ref, merged into the MCP server + env at compile. + type: object + required: + - url + title: McpFulfillment + description: 'EnvironmentSpec-side fulfillment for one MCP server the Agent + declares. + + + The Agent DECLARES an MCP dependency by name; the EnvironmentSpec PROVIDES + + the url + env + secrets for that same name. Matched by server-name key at + + compile time; ``secrets`` are merged into the server''s ``env``.' + ModelProviderOverride: + properties: + base_url: + type: string + title: Base Url + description: External model-provider endpoint. + api_key: + title: Api Key + description: Secrets-service ref for the provider API key (only needed for + external providers). + type: string + provider: + title: Provider + description: Provider selector (e.g. "openai", "anthropic"). + type: string + type: object + required: + - base_url + title: ModelProviderOverride + description: 'Exceptional external model-provider override. + + + Null in the normal case: model selection is on the Agent and the provider + + URL is the Inference Gateway (auto-injected). Set ONLY to point the agent + at + + a non-IGW external provider endpoint.' + NemoListResponse_AgentComputeSpec_: + properties: + data: + items: + $ref: '#/components/schemas/AgentComputeSpec' + type: array + title: Data + pagination: + allOf: + - $ref: '#/components/schemas/PaginationData' + description: "Pagination metadata \u2014 page, page_size, total_results,\ + \ etc." + sort: + title: Sort + description: Sort field applied to this result set (e.g. '-created_at'). + type: string + filter: + title: Filter + description: Filter criteria echoed back from the request. + type: object + required: + - data + title: NemoListResponse_AgentComputeSpec_ NemoListResponse_AgentDeployment_: properties: data: @@ -3366,6 +4506,52 @@ components: required: - data title: NemoListResponse_AgentDeployment_ + NemoListResponse_AgentEnvironmentSpec_: + properties: + data: + items: + $ref: '#/components/schemas/AgentEnvironmentSpec' + type: array + title: Data + pagination: + allOf: + - $ref: '#/components/schemas/PaginationData' + description: "Pagination metadata \u2014 page, page_size, total_results,\ + \ etc." + sort: + title: Sort + description: Sort field applied to this result set (e.g. '-created_at'). + type: string + filter: + title: Filter + description: Filter criteria echoed back from the request. + type: object + required: + - data + title: NemoListResponse_AgentEnvironmentSpec_ + NemoListResponse_AgentEnvironment_: + properties: + data: + items: + $ref: '#/components/schemas/AgentEnvironment' + type: array + title: Data + pagination: + allOf: + - $ref: '#/components/schemas/PaginationData' + description: "Pagination metadata \u2014 page, page_size, total_results,\ + \ etc." + sort: + title: Sort + description: Sort field applied to this result set (e.g. '-created_at'). + type: string + filter: + title: Filter + description: Filter criteria echoed back from the request. + type: object + required: + - data + title: NemoListResponse_AgentEnvironment_ NemoListResponse_Agent_: properties: data: diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py b/plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py index e7f8c061ca..f48cd68cf3 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py @@ -4,10 +4,10 @@ """Platform-owned agent.yaml config models for NeMo Agents. These models back Agent.config when config_format is nemo-agents-spec-v1. -RFC122 proposes first-class environment_spec, sandbox_spec, and harness_spec -fields on Agent; until those shapes are finalized, this config keeps those -inputs together in the versioned Agent.config payload and can be migrated once -the RFC122 contract lands. +First-class environment_spec, sandbox_spec, and harness_spec fields on Agent +are planned; until those shapes are finalized, this config keeps those inputs +together in the versioned Agent.config payload and can be migrated once that +contract lands. """ from __future__ import annotations @@ -50,7 +50,7 @@ class EnvironmentConfig(BaseModel): workspace: str = "./workspace" artifacts: str = "./artifacts" settings: dict[str, Any] = Field(default_factory=dict) - # Fabric environment mirror fields (RFC-122). Additive with backward-compatible + # Fabric environment mirror fields. Additive with backward-compatible # defaults; populated when an AgentEnvironmentSpec is merged at deploy time and # forwarded into FabricConfig.environment by the translator. env: dict[str, str] = Field(default_factory=dict) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py index 45e6dbd86f..f5a34cc2ac 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py @@ -146,7 +146,9 @@ async def _resolve_deployment_environment( try: return await resolve_environment(environment, workspace=workspace, entity_client=entity_client) except EnvironmentResolutionError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc + # 422: the request is syntactically valid but references an environment + # (or one of its specs) that cannot be resolved (e.g. a missing entity). + raise HTTPException(status_code=422, detail=str(exc)) from exc @router.get("/deployments", response_model=DeploymentPage, tags=["Agent Deployments"]) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/entities.py b/plugins/nemo-agents/src/nemo_agents_plugin/entities.py index cdd658e6f3..c7df7df709 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/entities.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/entities.py @@ -49,7 +49,7 @@ class Endpoint(BaseModel): # --------------------------------------------------------------------------- -# AgentEnvironment composition (RFC-122) +# AgentEnvironment composition # --------------------------------------------------------------------------- # # An AgentDeployment (and, later, an AgentInvocationJob) runs against an @@ -163,10 +163,11 @@ class EnvironmentSpecInline(BaseModel): # Fabric environment mirror -> compiles into FabricConfig.environment. # NOTE: ``workspace_path`` (the harness workspace path) is deliberately named # to avoid colliding with the NeMo entity ``workspace`` (tenant) field that - # AgentEnvironmentSpec inherits from EntityBase. + # AgentEnvironmentSpec inherits from EntityBase. ``artifacts_path`` carries a + # matching ``_path`` suffix for symmetry. provider: str = Field(default="local", description="local | docker | opensandbox | k8s.") workspace_path: str | None = Field(default=None, description="Workspace path visible to the harness.") - artifacts: str | None = Field(default=None, description="Provider-specific artifact output location.") + artifacts_path: str | None = Field(default=None, description="Provider-specific artifact output location.") control_location: str | None = Field( default=None, description="external_control | in_env_control.", @@ -191,7 +192,7 @@ class AgentEnvironmentInline(BaseModel): 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. (``sandbox_spec`` is out of scope for RFC-122 and omitted.) + ``None`` omits it. (A ``sandbox_spec`` is out of scope for now and omitted.) """ description: str = Field(default="", description="Human-readable description.") @@ -314,8 +315,8 @@ class AgentEnvironment(NemoEntity, AgentEnvironmentInline, entity_type="agent_en """ -# TODO: RFC-122 will add specs for environment, sandbox, and harness. Add those -# specs to this object once finalized. +# TODO: first-class environment, sandbox, and harness specs are planned for the +# Agent entity. Add those specs to this object once the contract is finalized. class Agent(NemoEntity, entity_type="agent"): """An agent definition — stores agent config and metadata. diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/environment_resolution.py b/plugins/nemo-agents/src/nemo_agents_plugin/environment_resolution.py index d85c8e7b66..ecc9135c69 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/environment_resolution.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/environment_resolution.py @@ -163,12 +163,13 @@ def _merge_environment_block(config: dict[str, Any], env_spec: EnvironmentSpecIn return # Scalar mirror fields: only fill when the Agent did not set them. The spec's - # ``workspace_path`` maps onto the config's ``workspace`` (the harness path); - # the entity/tenant ``workspace`` is unrelated and never merged here. + # ``workspace_path``/``artifacts_path`` map onto the config's + # ``workspace``/``artifacts`` (the harness paths); the entity/tenant + # ``workspace`` is unrelated and never merged here. scalar_fields = { "provider": "provider", "workspace_path": "workspace", - "artifacts": "artifacts", + "artifacts_path": "artifacts", "control_location": "control_location", "ownership": "ownership", } diff --git a/plugins/nemo-agents/tests/unit/test_deployments_api.py b/plugins/nemo-agents/tests/unit/test_deployments_api.py index 4ef6bfbdf2..ef469709f1 100644 --- a/plugins/nemo-agents/tests/unit/test_deployments_api.py +++ b/plugins/nemo-agents/tests/unit/test_deployments_api.py @@ -198,7 +198,7 @@ def test_create_rejects_missing_environment_ref(self) -> None: json={"agent": "fabric-agent", "name": "fabric-dep", "environment": "default/missing"}, ) - assert resp.status_code == 400 + assert resp.status_code == 422 assert "AgentEnvironment 'missing' not found" in resp.json()["detail"] mock_entity_client.create.assert_not_called() diff --git a/plugins/nemo-agents/tests/unit/test_environment_resolution.py b/plugins/nemo-agents/tests/unit/test_environment_resolution.py index 1bdf49aafd..5ca458689e 100644 --- a/plugins/nemo-agents/tests/unit/test_environment_resolution.py +++ b/plugins/nemo-agents/tests/unit/test_environment_resolution.py @@ -137,7 +137,7 @@ def test_merge_environment_mirror_fields_fill_only_when_unset() -> None: spec = EnvironmentSpecInline( provider="k8s", workspace_path="/ws", - artifacts="/artifacts", + artifacts_path="/artifacts", control_location="in_env_control", ownership="fabric_owned", connection={"url": "http://x"}, @@ -146,7 +146,8 @@ def test_merge_environment_mirror_fields_fill_only_when_unset() -> None: env = merged["environment"] # Agent explicitly set provider -> preserved. assert env["provider"] == "docker" - # Agent left these unset -> filled from spec (workspace_path -> workspace). + # Agent left these unset -> filled from spec (workspace_path/artifacts_path + # -> workspace/artifacts). assert env["workspace"] == "/ws" assert env["artifacts"] == "/artifacts" assert env["control_location"] == "in_env_control" From 9e6284385a172547d8c0bbb219f32abf6e25304b Mon Sep 17 00:00:00 2001 From: Ben McCown Date: Wed, 19 Aug 2026 12:30:46 -0600 Subject: [PATCH 3/5] fix(agents): use hyphenated authz namespaces for spec permission sets Permission-id segments must match [a-z0-9]+(-[a-z0-9]+)* (no underscores); the EnvironmentSpec/ComputeSpec PermissionSets used underscored namespaces (agents.environment_specs / agents.compute_specs), which the authz bundle rejected as malformed permission ids and failed closed (hard_fail), 500ing the auth service and breaking platform startup in tests. Rename the namespaces to agents.environment-specs / agents.compute-specs (matching the hyphenated route paths). Covered by the existing plugins/nemo-agents/tests/test_authz.py derivation assertion (problems == []). Signed-off-by: Ben McCown --- plugins/nemo-agents/src/nemo_agents_plugin/api/v2/_perms.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/_perms.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/_perms.py index 60b863fc02..046a4516be 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/_perms.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/_perms.py @@ -40,14 +40,14 @@ class EnvironmentPerms(PermissionSet, namespace="agents.environments"): DELETE = perm("Delete an agent environment") -class EnvironmentSpecPerms(PermissionSet, namespace="agents.environment_specs"): +class EnvironmentSpecPerms(PermissionSet, namespace="agents.environment-specs"): CREATE = perm("Create agent environment specs") LIST = perm("List agent environment specs") READ = perm("Read an agent environment spec") DELETE = perm("Delete an agent environment spec") -class ComputeSpecPerms(PermissionSet, namespace="agents.compute_specs"): +class ComputeSpecPerms(PermissionSet, namespace="agents.compute-specs"): CREATE = perm("Create agent compute specs") LIST = perm("List agent compute specs") READ = perm("Read an agent compute spec") From b9847ab6ae4dc6ac14acb1063de53659163bf4db Mon Sep 17 00:00:00 2001 From: Ben McCown Date: Wed, 19 Aug 2026 14:43:05 -0600 Subject: [PATCH 4/5] fix(agents): address environment-resolution review feedback - deployments.py: type _resolve_deployment_environment's environment param as str | AgentEnvironmentInline | None (was Any) to match the request contract and restore type checking at that boundary. - environments.py/test: add concrete type hints (NemoEntitiesClient, bounded TypeVars for entity/page/filter) to the shared CRUD helpers and to the _stamp test helper so ty checks them. - environment_resolution.py (_merge_mcp): only fulfill MCP servers the Agent declared; skip fulfillments for undeclared server names (request/fulfill contract) so an environment cannot inject unrequested MCP servers. - Preserve EnvironmentSpec secret references (previously dropped): snapshot them onto AgentDeployment.secrets and compile them into secret-backed container env vars (EnvVar.secret_ref, never plaintext), threaded through the controller/backend like the compute snapshot. The deployments-plugin substrate materializes/mounts them (docker plaintext env, k8s managed Secret via envFrom). Subprocess mode ignores them. - Regenerate the agents OpenAPI spec for the new AgentDeployment.secrets field. Adds coverage for undeclared-MCP skipping, secret snapshotting on create, and secret_ref env-var compilation. Signed-off-by: Ben McCown --- plugins/nemo-agents/openapi/openapi.yaml | 11 +++- .../nemo_agents_plugin/api/v2/deployments.py | 13 +++-- .../nemo_agents_plugin/api/v2/environments.py | 50 +++++++++++++++++-- .../src/nemo_agents_plugin/entities.py | 15 ++++-- .../environment_resolution.py | 12 ++++- .../src/nemo_agents_plugin/runner/backend.py | 6 +++ .../nemo_agents_plugin/runner/controller.py | 1 + .../runner/deployments_backend.py | 26 ++++++++++ .../nemo_agents_plugin/runner/in_memory.py | 8 +-- .../tests/unit/test_deployments_api.py | 10 +++- .../tests/unit/test_environment_resolution.py | 7 +-- .../tests/unit/test_environments_api.py | 14 +++++- .../tests/unit/test_runner_deployments.py | 40 +++++++++++++++ 13 files changed, 189 insertions(+), 24 deletions(-) diff --git a/plugins/nemo-agents/openapi/openapi.yaml b/plugins/nemo-agents/openapi/openapi.yaml index 3ce4ce96b7..82f5f25b59 100644 --- a/plugins/nemo-agents/openapi/openapi.yaml +++ b/plugins/nemo-agents/openapi/openapi.yaml @@ -2894,13 +2894,22 @@ components: title: Environment description: '"workspace/name" ref to an AgentEnvironment, an inline environment, or None. Snapshotted at create time for provenance; the resolved values - live in config/compute.' + live in config/compute/secrets.' compute: allOf: - $ref: '#/components/schemas/ComputeSpecInline' description: Resolved compute snapshot from the referenced environment. Compiled into the container resources for docker/k8s modes; ignored for subprocess. + secrets: + additionalProperties: + type: string + type: object + title: Secrets + description: Resolved secret env references from the referenced environment, + as ENV_VAR_NAME -> 'workspace/secret-name'. Compiled into secret-backed + container env vars (never plaintext) for docker/k8s modes; ignored for + subprocess. status: type: string enum: diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py index f5a34cc2ac..247af1cf64 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py @@ -28,6 +28,7 @@ from nemo_agents_plugin.entities import ( Agent, AgentDeployment, + AgentEnvironmentInline, is_container_deployment_mode, ) from nemo_agents_plugin.environment_resolution import ( @@ -90,13 +91,16 @@ async def create_deployment( resolved_config = _resolve_deployment_config(agent, workspace=workspace) # 4. Resolve and snapshot the referenced AgentEnvironment. The environment - # spec is merged into the resolved config (Agent-config-wins precedence) and - # the compute spec is snapshotted for the container backend. Once created, a - # deployment is not kept in sync with the underlying environment entities. + # spec is merged into the resolved config (Agent-config-wins precedence); the + # compute spec and secret-env references are snapshotted for the container + # backend. Once created, a deployment is not kept in sync with the underlying + # environment entities. resolved_environment = await _resolve_deployment_environment( body.environment, workspace=workspace, entity_client=entity_client ) resolved_config = merge_environment_spec_into_agent_config(resolved_config, resolved_environment.environment_spec) + env_spec = resolved_environment.environment_spec + resolved_secrets = dict(env_spec.secrets) if env_spec is not None else {} # 5. Create the entity with status "pending" deployment = AgentDeployment( @@ -106,6 +110,7 @@ async def create_deployment( config=resolved_config, environment=body.environment, compute=resolved_environment.compute_spec, + secrets=resolved_secrets, status="pending", deployment_mode=body.deployment_mode, image=body.image, @@ -138,7 +143,7 @@ def _resolve_deployment_config(agent: Agent, *, workspace: str) -> dict[str, Any async def _resolve_deployment_environment( - environment: Any, + environment: str | AgentEnvironmentInline | None, *, workspace: str, entity_client: NemoEntitiesClient, diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/environments.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/environments.py index 6bf6f7d7f2..6dfead11d7 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/environments.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/environments.py @@ -16,6 +16,7 @@ from __future__ import annotations import logging +from typing import TypeVar from fastapi import APIRouter, Depends, HTTPException, Query from nemo_agents_plugin.api.v2._perms import ComputeSpecPerms, EnvironmentPerms, EnvironmentSpecPerms @@ -35,11 +36,18 @@ ) from nemo_platform_plugin.api.filters import make_filter_obj_dep from nemo_platform_plugin.authz import CallerKind, path_rule +from nemo_platform_plugin.entity import NemoEntity from nemo_platform_plugin.entity_client import NemoEntitiesClient, NemoEntityConflictError, NemoEntityNotFoundError -from nemo_platform_plugin.schema import PaginationData +from nemo_platform_plugin.schema import NemoFilter, NemoListResponse, PaginationData logger = logging.getLogger(__name__) +# Generics for the shared CRUD helpers below: one entity type, its list-response +# page type, and its query filter type. +EntityT = TypeVar("EntityT", bound=NemoEntity) +PageT = TypeVar("PageT", bound=NemoListResponse) +FilterT = TypeVar("FilterT", bound=NemoFilter) + router = APIRouter() _environment_filter_dep = make_filter_obj_dep(EnvironmentFilter) @@ -263,7 +271,14 @@ async def delete_compute_spec( # --------------------------------------------------------------------------- -async def _create_entity(entity_client, entity, *, kind: str, name: str, workspace: str): +async def _create_entity( + entity_client: NemoEntitiesClient, + entity: EntityT, + *, + kind: str, + name: str, + workspace: str, +) -> EntityT: try: return await entity_client.create(entity) except NemoEntityConflictError as exc: @@ -276,7 +291,18 @@ async def _create_entity(entity_client, entity, *, kind: str, name: str, workspa raise HTTPException(status_code=500, detail=f"Failed to create agent {kind}.") from exc -async def _list_entities(entity_client, entity_type, page_type, *, workspace, page, page_size, sort, filter, kind): +async def _list_entities( + entity_client: NemoEntitiesClient, + entity_type: type[EntityT], + page_type: type[PageT], + *, + workspace: str, + page: int, + page_size: int, + sort: str, + filter: FilterT, + kind: str, +) -> PageT: filter_dict = filter if isinstance(filter, dict) else filter.model_dump(exclude_none=True) try: result = await entity_client.list( @@ -295,7 +321,14 @@ async def _list_entities(entity_client, entity_type, page_type, *, workspace, pa return page_type(data=result.data, pagination=pagination, sort=sort, filter=filter) -async def _get_entity(entity_client, entity_type, *, name: str, workspace: str, kind: str): +async def _get_entity( + entity_client: NemoEntitiesClient, + entity_type: type[EntityT], + *, + name: str, + workspace: str, + kind: str, +) -> EntityT: try: return await entity_client.get(entity_type, name=name, workspace=workspace) except NemoEntityNotFoundError as exc: @@ -308,7 +341,14 @@ async def _get_entity(entity_client, entity_type, *, name: str, workspace: str, raise HTTPException(status_code=500, detail=f"Failed to get agent {kind}.") from exc -async def _delete_entity(entity_client, entity_type, *, name: str, workspace: str, kind: str) -> None: +async def _delete_entity( + entity_client: NemoEntitiesClient, + entity_type: type[EntityT], + *, + name: str, + workspace: str, + kind: str, +) -> None: try: await entity_client.delete(entity_type, name=name, workspace=workspace) except NemoEntityNotFoundError as exc: diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/entities.py b/plugins/nemo-agents/src/nemo_agents_plugin/entities.py index c7df7df709..4798652965 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/entities.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/entities.py @@ -364,14 +364,15 @@ class AgentDeployment(NemoEntity, entity_type="agent_deployment"): ) # AgentEnvironment is snapshotted at create time: ``environment`` records the # raw request input for provenance, environment-spec content is merged into - # ``config``, and ``compute`` holds the resolved compute snapshot threaded to - # the container backend. A deployment is not kept in sync with the underlying + # ``config``, ``compute`` holds the resolved compute snapshot, and ``secrets`` + # holds the resolved secret-env references — all threaded to the container + # backend. A deployment is not kept in sync with the underlying # AgentEnvironment/spec entities after creation. environment: str | AgentEnvironmentInline | None = Field( default=None, description=( '"workspace/name" ref to an AgentEnvironment, an inline environment, or None. ' - "Snapshotted at create time for provenance; the resolved values live in config/compute." + "Snapshotted at create time for provenance; the resolved values live in config/compute/secrets." ), ) compute: ComputeSpecInline | None = Field( @@ -381,6 +382,14 @@ class AgentDeployment(NemoEntity, entity_type="agent_deployment"): "resources for docker/k8s modes; ignored for subprocess." ), ) + secrets: dict[str, str] = Field( + default_factory=dict, + description=( + "Resolved secret env references from the referenced environment, as " + "ENV_VAR_NAME -> 'workspace/secret-name'. Compiled into secret-backed container env " + "vars (never plaintext) for docker/k8s modes; ignored for subprocess." + ), + ) status: DeploymentStatus = Field( default="pending", description="Lifecycle status: pending | starting | running | failed | deleting.", diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/environment_resolution.py b/plugins/nemo-agents/src/nemo_agents_plugin/environment_resolution.py index ecc9135c69..ad018acc81 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/environment_resolution.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/environment_resolution.py @@ -217,7 +217,13 @@ def _merge_model_provider_override(config: dict[str, Any], env_spec: Environment def _merge_mcp(config: dict[str, Any], env_spec: EnvironmentSpecInline) -> None: - """Fulfill Agent-declared MCP servers by name (Agent keys win).""" + """Fulfill Agent-declared MCP servers by name (Agent keys win). + + McpFulfillment is a request/fulfill contract: the Agent DECLARES an MCP + server by name and the EnvironmentSpec PROVIDES its url/env/secrets. A + fulfillment whose name the Agent did not declare is ignored - an environment + must not add MCP servers the Agent never requested. + """ if not env_spec.mcp: return mcp = config.setdefault("mcp", {}) @@ -229,7 +235,9 @@ def _merge_mcp(config: dict[str, Any], env_spec: EnvironmentSpecInline) -> None: for name, fulfillment in env_spec.mcp.items(): server = servers.get(name) - server = server if isinstance(server, dict) else {} + # Only fulfill servers the Agent declared; skip undeclared names. + if not isinstance(server, dict): + continue # url: fill only when the Agent did not provide one. if "url" not in server: server["url"] = fulfillment.url diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/runner/backend.py b/plugins/nemo-agents/src/nemo_agents_plugin/runner/backend.py index a4133fa35c..17ccc8c646 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/runner/backend.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/runner/backend.py @@ -98,6 +98,7 @@ async def create_deployment( deployment_mode: DeploymentMode = "subprocess", created_by: str | None = None, resources: ComputeResources | None = None, + secrets: dict[str, str] | None = None, ) -> DeploymentInfo: """Start the agent process; returns status="starting". @@ -111,6 +112,11 @@ async def create_deployment( requests/limits. Container backends compile it into the execute container's resources (k8s passes both; docker consolidates to limits). Subprocess mode ignores it. + + ``secrets`` maps ENV_VAR_NAME -> "workspace/secret-name" from the + 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. """ ... diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/runner/controller.py b/plugins/nemo-agents/src/nemo_agents_plugin/runner/controller.py index 0487d547c7..173a61bfe7 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/runner/controller.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/runner/controller.py @@ -191,6 +191,7 @@ async def _start_deployment(self, dep: AgentDeployment) -> None: deployment_mode=dep.deployment_mode, created_by=dep.created_by, resources=dep.compute.resources if dep.compute is not None else None, + secrets=dep.secrets or None, ) except Exception as exc: logger.exception("Failed to start agent for deployment '%s'", dep.name) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py b/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py index 721243332c..43d8620437 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py @@ -50,11 +50,13 @@ HTTPGetAction, Probe, ResourceRequirements, + SecretRef, VolumeMount, ) from nemo_platform_plugin.auth import platform_auth_enabled from nemo_platform_plugin.client.adapter import client_from_platform from nemo_platform_plugin.config import LOOPBACK_ADDRESSES +from nemo_platform_plugin.entities.base import parse_qualified_name from nemo_platform_plugin.entities.client import AsyncEntitiesClient from nemo_platform_plugin.entity_client import NemoEntitiesClient, NemoEntityNotFoundError from nemo_platform_plugin.sdk_provider import get_async_platform_sdk @@ -248,6 +250,23 @@ def build_container_resources(resources: ComputeResources | None, *, mode: Deplo return ResourceRequirements(limits=dict(resources.limits), requests=dict(resources.requests)) +def _secret_env_vars(secrets: dict[str, str] | None, *, workspace: str) -> list[EnvVar]: + """Compile resolved secret references into secret-backed container env vars. + + ``secrets`` maps ENV_VAR_NAME -> "workspace/secret-name" (an unqualified + name resolves against the deployment ``workspace``). Each entry becomes an + ``EnvVar`` carrying a ``secret_ref`` (never a plaintext value); the + deployments-plugin substrate materializes the value at deploy time. + """ + if not secrets: + return [] + env_vars: list[EnvVar] = [] + for env_name, ref in secrets.items(): + secret_workspace, secret_name = parse_qualified_name(ref, default_workspace=workspace) + env_vars.append(EnvVar(name=env_name, secretRef=SecretRef(workspace=secret_workspace, name=secret_name))) + return env_vars + + def _fabric_config_mount_path(config_mount_path: str) -> str: parent = str(PurePosixPath(config_mount_path).parent) if parent in ("", "."): @@ -318,6 +337,7 @@ def build_deployment_config( auth_proxy_on_behalf_of: str | None = None, config_files: list[ConfigFile] | None = None, resources: ComputeResources | None = None, + secrets: dict[str, str] | None = None, ) -> DeploymentConfig: """Compile an agent into a long-running ``DeploymentConfig`` (Always). @@ -355,6 +375,10 @@ def build_deployment_config( ) else: env.append(EnvVar(name=_NAT_CONFIG_ENV, value=config_mount_path)) + # Secret-backed env vars from the resolved environment: emitted as + # secret_ref (never plaintext). The deployments-plugin substrate resolves + # them (docker) or mounts a managed Secret via envFrom (k8s). + env.extend(_secret_env_vars(secrets, workspace=workspace)) volume_mounts: list[VolumeMount] = [] init_containers: list[Container] = [] @@ -463,6 +487,7 @@ async def create_deployment( deployment_mode: DeploymentMode = "docker", created_by: str | None = None, resources: ComputeResources | None = None, + secrets: dict[str, str] | None = None, ) -> DeploymentInfo: """Create DeploymentConfig + Deployment entities for the agent container.""" del port # Host port is allocated by the deployments executor, not agents. @@ -567,6 +592,7 @@ async def create_deployment( auth_proxy_on_behalf_of=auth_proxy_on_behalf_of, config_files=staged_config_files, resources=resources, + secrets=secrets, ) await entities.create(deployment_config) try: diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/runner/in_memory.py b/plugins/nemo-agents/src/nemo_agents_plugin/runner/in_memory.py index ab66764ec4..acd2d69e8d 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/runner/in_memory.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/runner/in_memory.py @@ -229,14 +229,16 @@ async def create_deployment( deployment_mode: DeploymentMode = "subprocess", created_by: str | None = None, resources: ComputeResources | None = None, + secrets: dict[str, str] | None = None, ) -> DeploymentInfo: """Start a local deployment for NAT workflows or Platform-owned agent specs.""" # created_by drives on-behalf-of delegation only for container modes (via # the auth-proxy sidecar). Subprocess deployments run in-process on the # platform host and do not use the sidecar, so it does not apply here. - # resources (compute spec) only apply to container modes; subprocess runs - # in-process on the platform host with no resource isolation. - del agent, image, deployment_mode, created_by, resources + # resources (compute spec) and secrets (secret-env refs) only apply to + # container modes; subprocess runs in-process on the platform host with no + # resource isolation and injects no managed secrets. + del agent, image, deployment_mode, created_by, resources, secrets if config.get("config_format") == NEMO_AGENTS_SPEC_CONFIG_FORMAT: return await self._create_fabric_deployment(workspace, name, config, port) diff --git a/plugins/nemo-agents/tests/unit/test_deployments_api.py b/plugins/nemo-agents/tests/unit/test_deployments_api.py index ef469709f1..eb51617857 100644 --- a/plugins/nemo-agents/tests/unit/test_deployments_api.py +++ b/plugins/nemo-agents/tests/unit/test_deployments_api.py @@ -124,7 +124,12 @@ def test_create_with_environment_ref_snapshots_config_and_compute(self) -> None: environment_spec="default/espec", compute_spec="default/cspec", ) - espec = AgentEnvironmentSpec(name="espec", workspace="default", env={"CUSTOM": "from-spec"}) + espec = AgentEnvironmentSpec( + name="espec", + workspace="default", + env={"CUSTOM": "from-spec"}, + secrets={"APP_TOKEN": "default/app-token"}, + ) cspec = AgentComputeSpec(name="cspec", workspace="default", resources={"limits": {"cpu": "2"}}) mock_entity_client = AsyncMock() @@ -153,6 +158,9 @@ async def _save_deployment(deployment: AgentDeployment) -> AgentDeployment: # Compute spec snapshotted onto the deployment. assert created.compute is not None assert created.compute.resources.limits == {"cpu": "2"} + # Secret env references snapshotted (never merged into config as plaintext). + assert created.secrets == {"APP_TOKEN": "default/app-token"} + assert "APP_TOKEN" not in created.config.get("environment", {}).get("env", {}) def test_create_with_inline_environment(self) -> None: agent = _make_agent() diff --git a/plugins/nemo-agents/tests/unit/test_environment_resolution.py b/plugins/nemo-agents/tests/unit/test_environment_resolution.py index 5ca458689e..7601c47ae8 100644 --- a/plugins/nemo-agents/tests/unit/test_environment_resolution.py +++ b/plugins/nemo-agents/tests/unit/test_environment_resolution.py @@ -176,7 +176,8 @@ def test_merge_mcp_fulfills_by_name_agent_url_wins() -> None: spec = EnvironmentSpecInline( mcp={ "search": McpFulfillment(url="http://env-url", env={"E": "1"}, secrets={"TOKEN": "secret-ref"}), - "new": McpFulfillment(url="http://new-url"), + # Fulfillment for a server the Agent did not declare — must be ignored. + "undeclared": McpFulfillment(url="http://new-url"), } ) merged = merge_environment_spec_into_agent_config(config, spec) @@ -184,8 +185,8 @@ def test_merge_mcp_fulfills_by_name_agent_url_wins() -> None: # Agent-provided url wins; env + secrets merged in. assert servers["search"]["url"] == "http://agent-url" assert servers["search"]["env"] == {"E": "1", "TOKEN": "secret-ref"} - # New server contributed entirely by the spec. - assert servers["new"]["url"] == "http://new-url" + # An environment cannot add MCP servers the Agent never declared. + assert "undeclared" not in servers def test_merge_no_environment_reference_is_identical_to_today() -> None: diff --git a/plugins/nemo-agents/tests/unit/test_environments_api.py b/plugins/nemo-agents/tests/unit/test_environments_api.py index 7d3889ee11..ec3a649fdf 100644 --- a/plugins/nemo-agents/tests/unit/test_environments_api.py +++ b/plugins/nemo-agents/tests/unit/test_environments_api.py @@ -6,19 +6,28 @@ from __future__ import annotations from datetime import datetime, timezone +from typing import TypeVar from unittest.mock import AsyncMock from fastapi import FastAPI from fastapi.testclient import TestClient from nemo_agents_plugin.api.v2 import environments as environments_router_module from nemo_agents_plugin.api.v2.dependencies import get_entity_client -from nemo_agents_plugin.entities import AgentComputeSpec, AgentEnvironment, AgentEnvironmentSpec +from nemo_agents_plugin.entities import ( + AgentComputeSpec, + AgentEnvironment, + AgentEnvironmentSpec, + EnvironmentSpecInline, +) +from nemo_platform_plugin.entity import NemoEntity from nemo_platform_plugin.entity_client import NemoEntityConflictError, NemoEntityNotFoundError NOW = datetime.now(timezone.utc) +EntityT = TypeVar("EntityT", bound=NemoEntity) -def _stamp(entity): + +def _stamp(entity: EntityT) -> EntityT: entity._id = f"{entity.__entity_type__}-{entity.name}-id" entity._created_at = NOW return entity @@ -125,6 +134,7 @@ def test_create_with_inline(self) -> None: assert resp.status_code == 201 created: AgentEnvironment = client_mock.create.call_args[0][0] + assert isinstance(created.environment_spec, EnvironmentSpecInline) assert created.environment_spec.env == {"A": "1"} def test_get(self) -> None: diff --git a/plugins/nemo-agents/tests/unit/test_runner_deployments.py b/plugins/nemo-agents/tests/unit/test_runner_deployments.py index bdda456752..1a855b28db 100644 --- a/plugins/nemo-agents/tests/unit/test_runner_deployments.py +++ b/plugins/nemo-agents/tests/unit/test_runner_deployments.py @@ -317,6 +317,46 @@ def test_build_deployment_config_no_resources_is_empty() -> None: assert cfg.containers[0].resources.requests == {} +def test_build_deployment_config_emits_secret_ref_env_vars() -> None: + cfg = build_deployment_config( + name="hello-dep", + workspace="default", + image="nat-runtime:latest", + port=8000, + agent_config={}, + platform_base_url="http://nmp-api:8080", + config_mount_path="/workspace/config.yaml", + mode="k8s", + secrets={"APP_TOKEN": "default/app-token", "OTHER": "prod/other-secret"}, + ) + by_name = {e.name: e for e in cfg.containers[0].env} + # Secret-backed env vars carry a secret_ref, never a plaintext value. + assert by_name["APP_TOKEN"].value is None + assert by_name["APP_TOKEN"].secret_ref is not None + assert by_name["APP_TOKEN"].secret_ref.workspace == "default" + assert by_name["APP_TOKEN"].secret_ref.name == "app-token" + # A workspace-qualified reference keeps its explicit workspace. + assert by_name["OTHER"].secret_ref is not None + assert by_name["OTHER"].secret_ref.workspace == "prod" + assert by_name["OTHER"].secret_ref.name == "other-secret" + # The plaintext secret value never appears in the rendered config. + assert "app-token" not in yaml.safe_dump([e.model_dump() for e in cfg.containers[0].env if e.value]) + + +def test_build_deployment_config_no_secrets_adds_no_secret_env() -> None: + cfg = build_deployment_config( + name="hello-dep", + workspace="default", + image="nat-runtime:latest", + port=8000, + agent_config={}, + platform_base_url="http://nmp-api:8080", + config_mount_path="/workspace/config.yaml", + mode="k8s", + ) + assert all(e.secret_ref is None for e in cfg.containers[0].env) + + def test_build_deployment_config_k8s_uses_nat_entrypoint() -> None: cfg = build_deployment_config( name="hello-dep", From 87ecd32acf3b1f54843a106cf57759c1460781f4 Mon Sep 17 00:00:00 2001 From: Ben McCown Date: Wed, 19 Aug 2026 15:05:05 -0600 Subject: [PATCH 5/5] fix(agents): reject secret env names colliding with reserved container vars A secret env var from the environment whose name matches a platform-generated container env var (NMP_WORKSPACE, NMP_AGENT_NAME, NMP_BASE_URL, AGENT_CONFIG_PATH, NAT_CONFIG_PATH) behaves inconsistently across substrates: docker applies the secret value over the generated one, while k8s ignores the colliding secret because explicit env entries take precedence over the managed Secret's envFrom. Reject the collision at compile time (ReservedSecretEnvVarError, surfaced as a failed deployment) instead of silently shadowing platform wiring. Adds docker + k8s coverage for each reserved name. Signed-off-by: Ben McCown --- .../runner/deployments_backend.py | 70 ++++++++++++++----- .../tests/unit/test_runner_deployments.py | 23 ++++++ 2 files changed, 76 insertions(+), 17 deletions(-) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py b/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py index 43d8620437..af5eaaafe8 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py @@ -72,6 +72,22 @@ _FABRIC_SERVER_MODULE = "nemo_agents_plugin.fabric.server" _AUTH_PROXY_IDENTITY = "agents" +# Env var names the backend generates on the agent container. A secret env var +# from the environment must not collide with these: docker would apply the +# secret value over the generated one, while k8s ignores the colliding secret +# (explicit ``env`` entries take precedence over the managed Secret's +# ``envFrom``), so the behavior would be inconsistent and surprising. Reject the +# collision at compile time instead. +_RESERVED_ENV_VAR_NAMES = frozenset( + { + "NMP_WORKSPACE", + "NMP_AGENT_NAME", + "NMP_BASE_URL", + _AGENT_CONFIG_PATH_ENV, + _NAT_CONFIG_ENV, + } +) + # On delete, wait up to this long for the deployments controller to tear down the # container and remove the Deployment entity before we drop the DeploymentConfig. @@ -250,6 +266,10 @@ def build_container_resources(resources: ComputeResources | None, *, mode: Deplo return ResourceRequirements(limits=dict(resources.limits), requests=dict(resources.requests)) +class ReservedSecretEnvVarError(ValueError): + """A secret env var name collides with a platform-generated container env var.""" + + def _secret_env_vars(secrets: dict[str, str] | None, *, workspace: str) -> list[EnvVar]: """Compile resolved secret references into secret-backed container env vars. @@ -257,9 +277,21 @@ def _secret_env_vars(secrets: dict[str, str] | None, *, workspace: str) -> list[ name resolves against the deployment ``workspace``). Each entry becomes an ``EnvVar`` carrying a ``secret_ref`` (never a plaintext value); the deployments-plugin substrate materializes the value at deploy time. + + Raises :class:`ReservedSecretEnvVarError` when a secret name collides with a + platform-generated env var (see ``_RESERVED_ENV_VAR_NAMES``): such a + collision behaves inconsistently across substrates, so it is rejected up + front rather than silently shadowing platform wiring. """ if not secrets: return [] + reserved = sorted(name for name in secrets if name in _RESERVED_ENV_VAR_NAMES) + if reserved: + raise ReservedSecretEnvVarError( + "Environment secret variable name(s) collide with platform-reserved container env vars: " + f"{', '.join(reserved)}. Rename the secret env var(s) to avoid " + f"{', '.join(sorted(_RESERVED_ENV_VAR_NAMES))}." + ) env_vars: list[EnvVar] = [] for env_name, ref in secrets.items(): secret_workspace, secret_name = parse_qualified_name(ref, default_workspace=workspace) @@ -577,23 +609,27 @@ async def create_deployment( 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, - image=resolved_image, - port=self._config.container_port, - agent_config=config, - platform_base_url=gateway, - config_mount_path=self._config.config_mount_path, - mode=deployment_mode, - plugin_wheels_init_image=self._config.plugin_wheels_init_image, - labels=deployment_labels, - auth_proxy_identity=auth_proxy_identity, - auth_proxy_on_behalf_of=auth_proxy_on_behalf_of, - config_files=staged_config_files, - resources=resources, - secrets=secrets, - ) + try: + deployment_config = build_deployment_config( + name=name, + workspace=workspace, + image=resolved_image, + port=self._config.container_port, + agent_config=config, + platform_base_url=gateway, + config_mount_path=self._config.config_mount_path, + mode=deployment_mode, + plugin_wheels_init_image=self._config.plugin_wheels_init_image, + labels=deployment_labels, + auth_proxy_identity=auth_proxy_identity, + auth_proxy_on_behalf_of=auth_proxy_on_behalf_of, + config_files=staged_config_files, + resources=resources, + secrets=secrets, + ) + except ReservedSecretEnvVarError as exc: + logger.error("Refusing to deploy agent %r: %s", name, exc) + return DeploymentInfo(name=name, status="failed", error=str(exc)) await entities.create(deployment_config) try: deployment = Deployment( diff --git a/plugins/nemo-agents/tests/unit/test_runner_deployments.py b/plugins/nemo-agents/tests/unit/test_runner_deployments.py index 1a855b28db..da96271ce9 100644 --- a/plugins/nemo-agents/tests/unit/test_runner_deployments.py +++ b/plugins/nemo-agents/tests/unit/test_runner_deployments.py @@ -15,6 +15,7 @@ from nemo_agents_plugin.fabric.gateway_credentials import PLATFORM_IGW_API_KEY_ENV, PLATFORM_IGW_API_KEY_PLACEHOLDER from nemo_agents_plugin.runner.deployments_backend import ( DeploymentsRunnerBackend, + ReservedSecretEnvVarError, UnreachableGatewayURLError, build_container_resources, build_deployment_config, @@ -357,6 +358,28 @@ def test_build_deployment_config_no_secrets_adds_no_secret_env() -> None: assert all(e.secret_ref is None for e in cfg.containers[0].env) +@pytest.mark.parametrize("mode", ["docker", "k8s"]) +@pytest.mark.parametrize( + "reserved_name", + ["NMP_WORKSPACE", "NMP_AGENT_NAME", "NMP_BASE_URL", "AGENT_CONFIG_PATH", "NAT_CONFIG_PATH"], +) +def test_build_deployment_config_rejects_secret_name_colliding_with_reserved(reserved_name: str, mode: str) -> None: + # A secret env var whose name collides with a platform-generated container + # env var is rejected up front (behavior would otherwise differ by substrate). + with pytest.raises(ReservedSecretEnvVarError, match=reserved_name): + build_deployment_config( + name="hello-dep", + workspace="default", + image="nat-runtime:latest", + port=8000, + agent_config={}, + platform_base_url="http://nmp-api:8080", + config_mount_path="/workspace/config.yaml", + mode=mode, + secrets={reserved_name: "default/some-secret"}, + ) + + def test_build_deployment_config_k8s_uses_nat_entrypoint() -> None: cfg = build_deployment_config( name="hello-dep",