From fe2817808cbd491f06cf9440e26eb80be5297b23 Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Tue, 14 Jul 2026 14:24:48 -0500 Subject: [PATCH 01/10] updating descriptions and adding constants Signed-off-by: Manjesh Mogallapalli --- plugins/nemo-agents/openapi/openapi.yaml | 19 +++--- .../src/nemo_agents_plugin/entities.py | 64 +++++++++++++------ 2 files changed, 54 insertions(+), 29 deletions(-) diff --git a/plugins/nemo-agents/openapi/openapi.yaml b/plugins/nemo-agents/openapi/openapi.yaml index 5e53975db4..8f906361a2 100644 --- a/plugins/nemo-agents/openapi/openapi.yaml +++ b/plugins/nemo-agents/openapi/openapi.yaml @@ -2280,14 +2280,13 @@ components: additionalProperties: true type: object title: Config - description: NAT workflow config (YAML-equivalent dict, keyed by component - name). + 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. Not read or validated by NAT \u2014 used by NeMo Platform for\ - \ future config migration. Currently only 'nat-workflow-v1' is supported." + 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 @@ -2334,11 +2333,11 @@ components: - entity_id - parent title: Agent - description: "An agent definition \u2014 stores the NAT workflow config and\ - \ metadata.\n\nEntity type: ``agent``\nPrimary lookup: by ``name`` within\ - \ a ``workspace``.\n\nThe agent's spec lives at the location returned by\n\ - :func:`agent_spec_file_ref` \u2014 it is **not** stored on the entity\nbecause\ - \ the path is fully derivable from ``(workspace, name)``." + 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)``." AgentDeployment: properties: name: diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/entities.py b/plugins/nemo-agents/src/nemo_agents_plugin/entities.py index 2cec22d610..5203b315ba 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/entities.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/entities.py @@ -52,18 +52,19 @@ class Endpoint(BaseModel): # Canonical spec storage convention # --------------------------------------------------------------------------- # -# Each agent has exactly one spec, named by convention. We do **not** store -# the spec location on the agent — it is fully derivable from the agent's -# workspace and name. The convention is enforced by the nemo-spec and -# nemo-build-agent skills; consumers (analyst agent, Studio, optimization -# loop) should call :func:`agent_spec_file_ref` rather than reconstruct the -# path inline. +# Each agent has exactly one spec fileset, named by convention. The fileset can +# hold both the human-readable agent spec and the machine-readable agent config. +# We do **not** store these locations on the agent - they are fully derivable +# from the agent's workspace and name. Consumers should call the file-ref +# helpers below rather than reconstructing refs inline. # # Layout: # - Fileset (entity ref): ``{workspace}/{agent-name}-spec`` -# - File inside fileset: ``AGENT-SPEC.md`` (industry-standard name) -# - Full file ref: ``{workspace}/{agent-name}-spec#AGENT-SPEC.md`` -# - Local cache: ``agents/{agent-name}-spec/AGENT-SPEC.md`` +# - Human-readable spec: ``AGENT-SPEC.md`` (industry-standard name) +# - Machine-readable cfg: ``agent.yaml`` +# - Spec file ref: ``{workspace}/{agent-name}-spec#AGENT-SPEC.md`` +# - Config file ref: ``{workspace}/{agent-name}-spec#agent.yaml`` +# - Local cache root: ``agents/{agent-name}-spec/`` # # This is intentionally **not** an Optional field on the Agent. The # relationship is 1:1 and convention-bound; carrying a stored ref would @@ -73,10 +74,21 @@ class Endpoint(BaseModel): AGENT_SPEC_FILENAME = "AGENT-SPEC.md" """Canonical filename inside the agent's spec fileset.""" +AGENT_CONFIG_FILENAME = "agent.yaml" +"""Canonical machine-readable agent config filename in the agent spec fileset. + +This file is parsed into Agent.config when using the nemo-agents-spec-v1 format. +""" AGENT_SPEC_LOCAL_ROOT = "agents" """Local directory holding agent build artifacts.""" +NAT_WORKFLOW_CONFIG_FORMAT = "nat-workflow-v1" +"""Canonical format tag for the legacy NAT workflow config format.""" + +NEMO_AGENTS_SPEC_CONFIG_FORMAT = "nemo-agents-spec-v1" +"""Canonical format tag for the Platform-owned agent.yaml spec format.""" + def agent_spec_fileset_name(agent_name: str) -> str: """Return the conventional fileset name holding an agent's spec.""" @@ -91,7 +103,7 @@ def agent_spec_local_path(agent_name: str, root: str | Path = AGENT_SPEC_LOCAL_R def agent_spec_file_ref(workspace: str, agent_name: str) -> FilesetRef: """Return the canonical file ref ``workspace/-spec#AGENT-SPEC.md``. - Use this anywhere downstream code needs to point at an agent's spec — + Use this anywhere downstream code needs to point at an agent's spec - do not reconstruct the path inline. If the layout ever changes (e.g. moving to a per-agent bundle fileset holding multiple artifacts), this is the only function that needs to update. @@ -99,28 +111,42 @@ def agent_spec_file_ref(workspace: str, agent_name: str) -> FilesetRef: return FilesetRef(f"{workspace}/{agent_spec_fileset_name(agent_name)}#{AGENT_SPEC_FILENAME}") +def agent_config_file_ref(workspace: str, agent_name: str) -> FilesetRef: + """Return the canonical file ref ``workspace/-spec#agent.yaml``. + + Use this anywhere downstream code needs to point at an agent's config - + do not reconstruct the path inline. If the layout ever changes (e.g. + moving to a per-agent bundle fileset holding multiple artifacts), this + is the only function that needs to update. + """ + return FilesetRef(f"{workspace}/{agent_spec_fileset_name(agent_name)}#{AGENT_CONFIG_FILENAME}") + + +# 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"): - """An agent definition — stores the NAT workflow config and metadata. + """An agent definition — stores agent config and metadata. Entity type: ``agent`` Primary lookup: by ``name`` within a ``workspace``. - The agent's spec lives at the location returned by - :func:`agent_spec_file_ref` — it is **not** stored on the entity - because the path is fully derivable from ``(workspace, name)``. + The agent's spec files live at the locations returned by + :func:`agent_spec_file_ref` and :func:`agent_config_file_ref` — they + are **not** stored on the entity because the paths are fully derivable + from ``(workspace, name)``. """ description: str = Field(default="", description="Human-readable description of the agent.") config: dict[str, Any] = Field( default_factory=dict, - description="NAT workflow config (YAML-equivalent dict, keyed by component name).", + description="Agent config dict interpreted according to config_format.", ) config_format: str = Field( - default="nat-workflow-v1", + default=NAT_WORKFLOW_CONFIG_FORMAT, description=( - "platform-internal schema version tag for the agent config dict. " - "Not read or validated by NAT — used by NeMo Platform for future config migration. " - "Currently only 'nat-workflow-v1' is supported." + "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." ), ) From f235142c17ac0f6e509b92439a43ca0c72c19680 Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Tue, 14 Jul 2026 15:35:08 -0500 Subject: [PATCH 02/10] adding config format, translation, and validation Signed-off-by: Manjesh Mogallapalli --- .../src/nemo_agents_plugin/agent_config.py | 78 ++++++ .../src/nemo_agents_plugin/fabric/__init__.py | 4 + .../nemo_agents_plugin/fabric/translator.py | 148 ++++++++++++ .../nemo_agents_plugin/fabric/validation.py | 109 +++++++++ .../tests/unit/test_agent_config.py | 132 +++++++++++ .../tests/unit/test_fabric_translator.py | 224 ++++++++++++++++++ .../tests/unit/test_fabric_validation.py | 145 ++++++++++++ 7 files changed, 840 insertions(+) create mode 100644 plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py create mode 100644 plugins/nemo-agents/src/nemo_agents_plugin/fabric/__init__.py create mode 100644 plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py create mode 100644 plugins/nemo-agents/src/nemo_agents_plugin/fabric/validation.py create mode 100644 plugins/nemo-agents/tests/unit/test_agent_config.py create mode 100644 plugins/nemo-agents/tests/unit/test_fabric_translator.py create mode 100644 plugins/nemo-agents/tests/unit/test_fabric_validation.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 new file mode 100644 index 0000000000..cfd1d4624e --- /dev/null +++ b/plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""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. +""" + +from __future__ import annotations + +from typing import Any, Self + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class ModelConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + provider: str + model: str + api_key_env: str | None = None + temperature: float | None = None + settings: dict[str, Any] = Field(default_factory=dict) + + +class HarnessConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + kind: str + model: ModelConfig | None = None + settings: dict[str, Any] = Field(default_factory=dict) + + +class EnvironmentConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + provider: str = "local" + workspace: str = "./workspace" + artifacts: str = "./artifacts" + settings: dict[str, Any] = Field(default_factory=dict) + + +class TelemetryConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + enabled: bool = False + provider: str | None = None + output_dir: str | None = None + project: str | None = None + atif: dict[str, Any] | None = None + atof: dict[str, Any] | None = None + + +class AgentConfig(BaseModel): + """Platform-owned agent.yaml config for nemo-agents-spec-v1.""" + + model_config = ConfigDict(extra="forbid") + + name: str + description: str = "" + default_harness: str + harnesses: dict[str, HarnessConfig] + models: dict[str, ModelConfig] = Field(default_factory=dict) + prompts: dict[str, str] = Field(default_factory=dict) + skills: dict[str, Any] | list[Any] | None = None + environment: EnvironmentConfig = Field(default_factory=EnvironmentConfig) + telemetry: TelemetryConfig = Field(default_factory=TelemetryConfig) + + @model_validator(mode="after") + def _validate_default_harness(self) -> Self: + if self.default_harness not in self.harnesses: + available = ", ".join(sorted(self.harnesses)) + raise ValueError(f"default_harness must reference one of harnesses: {available}") + return self diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/__init__.py b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/__init__.py new file mode 100644 index 0000000000..9936788ffa --- /dev/null +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Fabric integration helpers for NeMo Agents.""" diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py new file mode 100644 index 0000000000..cd58c13b7c --- /dev/null +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py @@ -0,0 +1,148 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Translate Platform-owned agent config into Fabric SDK config.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from nemo_agents_plugin.agent_config import AgentConfig, HarnessConfig, ModelConfig + +if TYPE_CHECKING: + from nemo_fabric import FabricConfig + + +HARNESS_ADAPTER_IDS = { + "claude": "nvidia.fabric.claude", + "codex": "nvidia.fabric.codex.cli", + "deepagents": "nvidia.fabric.langchain.deepagents", + "hermes": "nvidia.fabric.hermes", +} + + +class FabricTranslationError(ValueError): + """Raised when Platform agent config cannot be translated to Fabric config.""" + + +def translate_agent_config(config: AgentConfig, harness_name: str | None = None) -> "FabricConfig": + """Translate Platform-owned agent config into a FabricConfig. + + The Fabric SDK import is intentionally local to this function so existing + NAT-backed NeMo Agents paths do not require Fabric to be installed. + """ + + ( + FabricConfig, + HarnessConfig_, + MetadataConfig, + ModelConfig_, + EnvironmentConfig, + ) = _fabric_model_types() + + selected_harness_name, harness = _select_harness(config, harness_name) + model = _resolve_model(config, selected_harness_name, harness) + + fabric_config = FabricConfig( + metadata=MetadataConfig(name=config.name, description=config.description or None), + harness=HarnessConfig_( + adapter_id=_adapter_id_for_harness(harness), + resolution="preinstalled", + settings=harness.settings, + ), + models={ + "default": ModelConfig_(**_model_payload(model)), + }, + environment=EnvironmentConfig( + provider=config.environment.provider, + workspace=config.environment.workspace, + artifacts=config.environment.artifacts, + settings=config.environment.settings, + ), + ) + + _apply_telemetry(fabric_config, config, model) + return fabric_config + + +def _fabric_model_types() -> tuple[type, type, type, type, type]: + try: + from nemo_fabric import EnvironmentConfig, FabricConfig, HarnessConfig, MetadataConfig, ModelConfig + except ImportError as error: + raise FabricTranslationError( + "NeMo Fabric SDK is required to translate nemo-agents-spec-v1 config to FabricConfig." + ) from error + + return FabricConfig, HarnessConfig, MetadataConfig, ModelConfig, EnvironmentConfig + + +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) + if harness is None: + available = ", ".join(sorted(config.harnesses)) + raise FabricTranslationError( + f"Unknown configured harness {selected_harness_name!r}. Configured harnesses: {available}" + ) + return selected_harness_name, harness + + +def _adapter_id_for_harness(harness: HarnessConfig) -> str: + adapter_id = HARNESS_ADAPTER_IDS.get(harness.kind) + if adapter_id is None: + available = ", ".join(sorted(HARNESS_ADAPTER_IDS)) + raise FabricTranslationError(f"Unsupported harness kind {harness.kind!r}. Supported harness kinds: {available}") + return adapter_id + + +def _resolve_model(config: AgentConfig, harness_name: str, harness: HarnessConfig) -> ModelConfig: + if harness.model is not None: + return harness.model + + model = config.models.get("default") + if model is None: + raise FabricTranslationError( + f"Harness {harness_name!r} does not define a model and no models.default is configured." + ) + return model + + +def _model_payload(model: ModelConfig) -> dict[str, Any]: + return model.model_dump(exclude_none=True) + + +def _apply_telemetry(fabric_config: "FabricConfig", config: AgentConfig, model: ModelConfig) -> None: + telemetry = config.telemetry + if not telemetry.enabled: + return + + provider = telemetry.provider or "relay" + if provider != "relay": + raise FabricTranslationError(f"Unsupported telemetry provider {provider!r}. Only 'relay' is supported.") + + fabric_config.enable_relay( + project=telemetry.project, + output_dir=telemetry.output_dir, + observability=_relay_observability_config(config, model), + ) + + +def _relay_observability_config(config: AgentConfig, model: ModelConfig) -> dict[str, Any]: + telemetry = config.telemetry + observability: dict[str, Any] = {"version": 1} + + if telemetry.atif is not None: + atif = dict(telemetry.atif) + if telemetry.output_dir is not None: + atif.setdefault("output_directory", telemetry.output_dir) + atif.setdefault("agent_name", config.name) + atif.setdefault("model_name", model.model) + observability["atif"] = atif + + if telemetry.atof is not None: + atof = dict(telemetry.atof) + if telemetry.output_dir is not None: + atof.setdefault("output_directory", telemetry.output_dir) + observability["atof"] = atof + + return observability diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/validation.py b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/validation.py new file mode 100644 index 0000000000..059967da70 --- /dev/null +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/validation.py @@ -0,0 +1,109 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Plan and preflight validation helpers for Fabric-backed agents.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from nemo_fabric import FabricConfig + + +@dataclass(frozen=True, slots=True) +class FabricValidationResult: + """Result of Fabric planning and preflight validation.""" + + plan: Any + doctor_report: Any + + +class FabricValidationError(ValueError): + """Raised when Fabric planning or preflight validation fails.""" + + +class FabricPreflightError(FabricValidationError): + """Raised when Fabric doctor reports a non-passing preflight status.""" + + def __init__(self, status: str | None, failed_checks: list[str]) -> None: + self.status = status + self.failed_checks = failed_checks + details = "; ".join(failed_checks) + super().__init__(f"Fabric preflight failed with status {status!r}: {details}") + + +async def validate_fabric_config( + fabric_config: "FabricConfig", + *, + base_dir: Path | str, + fabric: Any | None = None, +) -> FabricValidationResult: + """Run Fabric plan and doctor for a translated FabricConfig. + + This validates the selected harness and environment without invoking the + agent. The Fabric SDK import is intentionally local so NAT-backed paths do + not require Fabric to be installed. + """ + + Fabric, FabricConfigError = _fabric_validation_types() + fabric_client = fabric or Fabric() + + try: + plan = fabric_client.plan(fabric_config, base_dir=base_dir) + except FabricConfigError as error: + raise FabricValidationError(f"Fabric plan failed: {error}") from error + + try: + doctor_report = await fabric_client.doctor(fabric_config, base_dir=base_dir) + except Exception as error: + raise FabricValidationError(f"Fabric doctor failed: {error}") from error + + _ensure_doctor_passed(_to_mapping(doctor_report)) + return FabricValidationResult(plan=plan, doctor_report=doctor_report) + + +def _fabric_validation_types() -> tuple[type, type[Exception]]: + try: + from nemo_fabric import Fabric, FabricConfigError + except ImportError as error: + raise FabricValidationError("NeMo Fabric SDK is required to plan and preflight FabricConfig.") from error + + return Fabric, FabricConfigError + + +def _ensure_doctor_passed(report: dict[str, Any]) -> None: + status = report.get("status") + if status == "pass": + return + + failed_checks: list[str] = [] + for check in report.get("checks", []): + check_status = check.get("status") + if check_status == "pass": + continue + + name = check.get("name", "unknown") + message = check.get("message", "No diagnostic message provided.") + failed_checks.append(f"{name}: {check_status} - {message}") + + if not failed_checks: + failed_checks.append("No failing subsection was reported.") + + raise FabricPreflightError(status, failed_checks) + + +def _to_mapping(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if hasattr(value, "to_mapping"): + mapping = value.to_mapping() + if isinstance(mapping, dict): + return mapping + if hasattr(value, "model_dump"): + mapping = value.model_dump(mode="json") + if isinstance(mapping, dict): + return mapping + raise FabricValidationError("Fabric doctor returned a report that could not be converted to a mapping.") diff --git a/plugins/nemo-agents/tests/unit/test_agent_config.py b/plugins/nemo-agents/tests/unit/test_agent_config.py new file mode 100644 index 0000000000..be0244576f --- /dev/null +++ b/plugins/nemo-agents/tests/unit/test_agent_config.py @@ -0,0 +1,132 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for Platform-owned agent.yaml config models.""" + +from __future__ import annotations + +import pytest +from nemo_agents_plugin.agent_config import AgentConfig +from pydantic import ValidationError + + +def _example_yaml_config() -> dict: + return { + "name": "fabric-mvp-spike", + "description": "Fabric MVP Spike", + "default_harness": "hermes", + "harnesses": { + "hermes": { + "kind": "hermes", + "model": { + "provider": "nvidia", + "model": "nvidia/nemotron-3-nano-30b-a3b", + "api_key_env": "NVIDIA_API_KEY", + "temperature": 0.0, + }, + "settings": { + "base_url": "https://integrate.api.nvidia.com/v1", + "max_iterations": 1, + "max_tokens": 512, + "reasoning_config": {"effort": "none"}, + "enabled_toolsets": [], + "system_prompt": "You are a concise smoke test assistant.", + }, + }, + "codex": { + "kind": "codex", + "settings": { + "sandbox": "workspace-write", + "skip_git_repo_check": True, + "config_overrides": {"model_reasoning_effort": "high"}, + }, + }, + }, + "models": { + "default": { + "provider": "openai", + "model": "openai/gpt-5.4", + }, + }, + "prompts": { + "system": "prompts/system.md", + }, + "skills": None, + "environment": { + "workspace": "./workspace", + "artifacts": "./artifacts", + }, + "telemetry": { + "enabled": False, + "provider": "relay", + "output_dir": "./artifacts/relay", + "project": "fabric-mvp-spike", + "atif": { + "enabled": True, + "filename_template": "trajectory-{session_id}.atif.json", + }, + "atof": { + "enabled": True, + "filename": "events.atof.jsonl", + "mode": "overwrite", + }, + }, + } + + +class TestAgentConfig: + def test_example_yaml_config_validates(self) -> None: + config = AgentConfig.model_validate(_example_yaml_config()) + + assert config.name == "fabric-mvp-spike" + assert config.default_harness == "hermes" + assert config.harnesses["hermes"].model is not None + assert config.harnesses["hermes"].model.provider == "nvidia" + assert config.harnesses["codex"].settings["sandbox"] == "workspace-write" + assert config.models["default"].model == "openai/gpt-5.4" + assert config.skills is None + assert config.telemetry.atif == { + "enabled": True, + "filename_template": "trajectory-{session_id}.atif.json", + } + + def test_defaults_fill_optional_sections(self) -> None: + config = AgentConfig.model_validate( + { + "name": "minimal-agent", + "default_harness": "codex", + "harnesses": {"codex": {"kind": "codex"}}, + } + ) + + assert config.description == "" + assert config.models == {} + assert config.prompts == {} + assert config.environment.provider == "local" + assert config.environment.workspace == "./workspace" + assert config.environment.artifacts == "./artifacts" + assert config.telemetry.enabled is False + + def test_default_harness_must_reference_configured_harness(self) -> None: + with pytest.raises(ValidationError, match="default_harness must reference one of harnesses: codex"): + AgentConfig.model_validate( + { + "name": "bad-agent", + "default_harness": "hermes", + "harnesses": {"codex": {"kind": "codex"}}, + } + ) + + def test_unknown_top_level_fields_rejected(self) -> None: + payload = _example_yaml_config() + payload["unexpected"] = "value" + + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AgentConfig.model_validate(payload) + + def test_unknown_nested_fields_rejected_outside_settings(self) -> None: + payload = _example_yaml_config() + payload["harnesses"]["codex"]["unknown"] = "value" + + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AgentConfig.model_validate(payload) diff --git a/plugins/nemo-agents/tests/unit/test_fabric_translator.py b/plugins/nemo-agents/tests/unit/test_fabric_translator.py new file mode 100644 index 0000000000..42cfd01bb5 --- /dev/null +++ b/plugins/nemo-agents/tests/unit/test_fabric_translator.py @@ -0,0 +1,224 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for Platform agent config to FabricConfig translation.""" + +from __future__ import annotations + +import builtins +import copy +import sys +import types +from typing import Any + +import pytest +from nemo_agents_plugin.agent_config import AgentConfig +from nemo_agents_plugin.fabric.translator import FabricTranslationError, translate_agent_config + + +class _FabricObject: + def __init__(self, **kwargs: Any) -> None: + for key, value in kwargs.items(): + setattr(self, key, value) + + +class _FakeFabricConfig(_FabricObject): + def enable_relay( + self, + *, + project: str | None = None, + output_dir: str | None = None, + observability: dict[str, Any] | None = None, + ) -> "_FakeFabricConfig": + self.telemetry = _FabricObject(providers={"relay": {}}) + self.relay = _FabricObject( + project=project, + output_dir=output_dir, + observability=observability, + ) + return self + + +@pytest.fixture() +def fake_nemo_fabric(monkeypatch: pytest.MonkeyPatch) -> None: + module = types.ModuleType("nemo_fabric") + module.EnvironmentConfig = _FabricObject + module.FabricConfig = _FakeFabricConfig + module.HarnessConfig = _FabricObject + module.MetadataConfig = _FabricObject + module.ModelConfig = _FabricObject + monkeypatch.setitem(sys.modules, "nemo_fabric", module) + + +def _example_yaml_config() -> dict[str, Any]: + return { + "name": "example-agent", + "description": "Example Agent", + "default_harness": "hermes", + "harnesses": { + "hermes": { + "kind": "hermes", + "model": { + "provider": "nvidia", + "model": "nvidia/nemotron-3-nano-30b-a3b", + "api_key_env": "NVIDIA_API_KEY", + "temperature": 0.0, + }, + "settings": { + "base_url": "https://integrate.api.nvidia.com/v1", + "system_prompt": "You are a concise assistant.", + }, + }, + "codex": { + "kind": "codex", + "settings": { + "sandbox": "workspace-write", + "skip_git_repo_check": True, + }, + }, + }, + "models": { + "default": { + "provider": "openai", + "model": "openai/gpt-5.4", + }, + }, + "environment": { + "workspace": "./workspace", + "artifacts": "./artifacts", + }, + "telemetry": { + "enabled": False, + "provider": "relay", + "output_dir": "./artifacts/relay", + "project": "example-agent", + "atif": { + "enabled": True, + "filename_template": "trajectory-{session_id}.atif.json", + }, + "atof": { + "enabled": True, + "filename": "events.atof.jsonl", + "mode": "overwrite", + }, + }, + } + + +class TestTranslateAgentConfig: + def test_translates_default_harness(self, fake_nemo_fabric: None) -> None: + config = AgentConfig.model_validate(_example_yaml_config()) + + fabric_config = translate_agent_config(config) + + assert fabric_config.metadata.name == "example-agent" + assert fabric_config.metadata.description == "Example Agent" + assert fabric_config.harness.adapter_id == "nvidia.fabric.hermes" + assert fabric_config.harness.resolution == "preinstalled" + assert fabric_config.harness.settings["system_prompt"] == "You are a concise assistant." + assert fabric_config.models["default"].provider == "nvidia" + assert fabric_config.models["default"].model == "nvidia/nemotron-3-nano-30b-a3b" + assert fabric_config.environment.provider == "local" + assert fabric_config.environment.workspace == "./workspace" + assert fabric_config.environment.artifacts == "./artifacts" + assert not hasattr(fabric_config, "relay") + + def test_selected_harness_uses_default_model(self, fake_nemo_fabric: None) -> None: + config = AgentConfig.model_validate(_example_yaml_config()) + + fabric_config = translate_agent_config(config, harness_name="codex") + + assert fabric_config.harness.adapter_id == "nvidia.fabric.codex.cli" + assert fabric_config.harness.settings["sandbox"] == "workspace-write" + assert fabric_config.models["default"].provider == "openai" + assert fabric_config.models["default"].model == "openai/gpt-5.4" + + @pytest.mark.parametrize( + ("kind", "adapter_id"), + [ + ("claude", "nvidia.fabric.claude"), + ("codex", "nvidia.fabric.codex.cli"), + ("deepagents", "nvidia.fabric.langchain.deepagents"), + ("hermes", "nvidia.fabric.hermes"), + ], + ) + def test_supported_harness_kinds_translate_to_adapter_ids( + self, + fake_nemo_fabric: None, + kind: str, + adapter_id: str, + ) -> None: + payload = _example_yaml_config() + payload["default_harness"] = "selected" + payload["harnesses"] = {"selected": {"kind": kind}} + config = AgentConfig.model_validate(payload) + + fabric_config = translate_agent_config(config) + + assert fabric_config.harness.adapter_id == adapter_id + + def test_unknown_selected_harness_rejected(self, fake_nemo_fabric: None) -> None: + config = AgentConfig.model_validate(_example_yaml_config()) + + with pytest.raises(FabricTranslationError, match="Unknown configured harness 'claude'"): + translate_agent_config(config, harness_name="claude") + + def test_unsupported_harness_kind_rejected(self, fake_nemo_fabric: None) -> None: + payload = _example_yaml_config() + payload["harnesses"]["custom"] = {"kind": "custom"} + payload["default_harness"] = "custom" + config = AgentConfig.model_validate(payload) + + with pytest.raises(FabricTranslationError, match="Unsupported harness kind 'custom'"): + translate_agent_config(config) + + def test_missing_model_rejected(self, fake_nemo_fabric: None) -> None: + payload = _example_yaml_config() + payload["models"] = {} + payload["default_harness"] = "codex" + config = AgentConfig.model_validate(payload) + + with pytest.raises(FabricTranslationError, match="no models.default is configured"): + translate_agent_config(config) + + def test_relay_telemetry_uses_latest_fabric_shape(self, fake_nemo_fabric: None) -> None: + payload = copy.deepcopy(_example_yaml_config()) + payload["telemetry"]["enabled"] = True + config = AgentConfig.model_validate(payload) + + fabric_config = translate_agent_config(config) + + assert fabric_config.telemetry.providers == {"relay": {}} + assert fabric_config.relay.project == "example-agent" + assert fabric_config.relay.output_dir == "./artifacts/relay" + assert fabric_config.relay.observability == { + "version": 1, + "atif": { + "enabled": True, + "filename_template": "trajectory-{session_id}.atif.json", + "output_directory": "./artifacts/relay", + "agent_name": "example-agent", + "model_name": "nvidia/nemotron-3-nano-30b-a3b", + }, + "atof": { + "enabled": True, + "filename": "events.atof.jsonl", + "mode": "overwrite", + "output_directory": "./artifacts/relay", + }, + } + + def test_missing_fabric_dependency_reports_actionable_error(self, monkeypatch: pytest.MonkeyPatch) -> None: + real_import = builtins.__import__ + + def fake_import(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "nemo_fabric": + raise ImportError("No module named 'nemo_fabric'") + return real_import(name, *args, **kwargs) + + monkeypatch.delitem(sys.modules, "nemo_fabric", raising=False) + monkeypatch.setattr(builtins, "__import__", fake_import) + config = AgentConfig.model_validate(_example_yaml_config()) + + with pytest.raises(FabricTranslationError, match="NeMo Fabric SDK is required"): + translate_agent_config(config) diff --git a/plugins/nemo-agents/tests/unit/test_fabric_validation.py b/plugins/nemo-agents/tests/unit/test_fabric_validation.py new file mode 100644 index 0000000000..95a84f612f --- /dev/null +++ b/plugins/nemo-agents/tests/unit/test_fabric_validation.py @@ -0,0 +1,145 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for Fabric plan and preflight validation helpers.""" + +from __future__ import annotations + +import builtins +import sys +import types +from pathlib import Path +from typing import Any + +import pytest +from nemo_agents_plugin.fabric.validation import ( + FabricPreflightError, + FabricValidationError, + validate_fabric_config, +) + + +class _FakeFabricConfigError(Exception): + pass + + +class _FakeDoctorReport: + def __init__(self, mapping: dict[str, Any]) -> None: + self._mapping = mapping + + def to_mapping(self) -> dict[str, Any]: + return self._mapping + + +class _FakeFabric: + def __init__( + self, + *, + plan: Any = "plan", + doctor_report: Any | None = None, + plan_error: Exception | None = None, + doctor_error: Exception | None = None, + ) -> None: + self.plan_result = plan + self.doctor_report = doctor_report or _FakeDoctorReport({"status": "pass", "checks": []}) + self.plan_error = plan_error + self.doctor_error = doctor_error + self.plan_calls: list[dict[str, Any]] = [] + self.doctor_calls: list[dict[str, Any]] = [] + + def plan(self, fabric_config: Any, *, base_dir: Path | str) -> Any: + self.plan_calls.append({"fabric_config": fabric_config, "base_dir": base_dir}) + if self.plan_error is not None: + raise self.plan_error + return self.plan_result + + async def doctor(self, fabric_config: Any, *, base_dir: Path | str) -> Any: + self.doctor_calls.append({"fabric_config": fabric_config, "base_dir": base_dir}) + if self.doctor_error is not None: + raise self.doctor_error + return self.doctor_report + + +@pytest.fixture() +def fake_nemo_fabric(monkeypatch: pytest.MonkeyPatch) -> None: + module = types.ModuleType("nemo_fabric") + module.Fabric = _FakeFabric + module.FabricConfigError = _FakeFabricConfigError + monkeypatch.setitem(sys.modules, "nemo_fabric", module) + + +@pytest.mark.asyncio +class TestValidateFabricConfig: + async def test_returns_plan_and_doctor_report(self, fake_nemo_fabric: None) -> None: + fabric_config = object() + doctor_report = _FakeDoctorReport({"status": "pass", "checks": [{"name": "adapter", "status": "pass"}]}) + fabric = _FakeFabric(plan={"plan": "ok"}, doctor_report=doctor_report) + + result = await validate_fabric_config(fabric_config, base_dir=Path("/tmp/agent"), fabric=fabric) + + assert result.plan == {"plan": "ok"} + assert result.doctor_report is doctor_report + assert fabric.plan_calls == [{"fabric_config": fabric_config, "base_dir": Path("/tmp/agent")}] + assert fabric.doctor_calls == [{"fabric_config": fabric_config, "base_dir": Path("/tmp/agent")}] + + async def test_wraps_plan_errors(self, fake_nemo_fabric: None) -> None: + fabric = _FakeFabric(plan_error=_FakeFabricConfigError("bad config")) + + with pytest.raises(FabricValidationError, match="Fabric plan failed: bad config"): + await validate_fabric_config(object(), base_dir=Path("/tmp/agent"), fabric=fabric) + + async def test_wraps_doctor_errors(self, fake_nemo_fabric: None) -> None: + fabric = _FakeFabric(doctor_error=RuntimeError("doctor exploded")) + + with pytest.raises(FabricValidationError, match="Fabric doctor failed: doctor exploded"): + await validate_fabric_config(object(), base_dir=Path("/tmp/agent"), fabric=fabric) + + async def test_preflight_failure_reports_failed_checks(self, fake_nemo_fabric: None) -> None: + doctor_report = _FakeDoctorReport( + { + "status": "fail", + "checks": [ + {"name": "adapter_descriptor", "status": "pass", "message": "ok"}, + {"name": "requirement.binary", "status": "fail", "message": "binary `codex` missing"}, + {"name": "environment", "status": "warn", "message": "workspace missing"}, + ], + } + ) + fabric = _FakeFabric(doctor_report=doctor_report) + + with pytest.raises(FabricPreflightError) as error_info: + await validate_fabric_config(object(), base_dir=Path("/tmp/agent"), fabric=fabric) + + assert error_info.value.status == "fail" + assert error_info.value.failed_checks == [ + "requirement.binary: fail - binary `codex` missing", + "environment: warn - workspace missing", + ] + + async def test_preflight_failure_without_checks_reports_fallback(self, fake_nemo_fabric: None) -> None: + doctor_report = _FakeDoctorReport({"status": "fail", "checks": []}) + fabric = _FakeFabric(doctor_report=doctor_report) + + with pytest.raises(FabricPreflightError, match="No failing subsection was reported"): + await validate_fabric_config(object(), base_dir=Path("/tmp/agent"), fabric=fabric) + + async def test_dict_doctor_report_is_supported(self, fake_nemo_fabric: None) -> None: + fabric = _FakeFabric(doctor_report={"status": "pass", "checks": []}) + + result = await validate_fabric_config(object(), base_dir=Path("/tmp/agent"), fabric=fabric) + + assert result.doctor_report == {"status": "pass", "checks": []} + + async def test_missing_fabric_dependency_reports_actionable_error(self, monkeypatch: pytest.MonkeyPatch) -> None: + real_import = builtins.__import__ + + def fake_import(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "nemo_fabric": + raise ImportError("No module named 'nemo_fabric'") + return real_import(name, *args, **kwargs) + + monkeypatch.delitem(sys.modules, "nemo_fabric", raising=False) + monkeypatch.setattr(builtins, "__import__", fake_import) + + with pytest.raises(FabricValidationError, match="NeMo Fabric SDK is required"): + await validate_fabric_config(object(), base_dir=Path("/tmp/agent"), fabric=_FakeFabric()) From 7999587552479460213eb062f487ea9fc2039e5b Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Tue, 14 Jul 2026 16:48:47 -0500 Subject: [PATCH 03/10] adding dep todos Signed-off-by: Manjesh Mogallapalli --- plugins/nemo-agents/pyproject.toml | 4 +++- .../nemo-agents/src/nemo_agents_plugin/fabric/translator.py | 2 ++ .../nemo-agents/src/nemo_agents_plugin/fabric/validation.py | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/plugins/nemo-agents/pyproject.toml b/plugins/nemo-agents/pyproject.toml index cfeb973ac6..69e10a2386 100644 --- a/plugins/nemo-agents/pyproject.toml +++ b/plugins/nemo-agents/pyproject.toml @@ -17,6 +17,9 @@ dependencies = [ "botocore>=1.40.46,<1.40.62", "httpx>=0.27", "pyyaml>=6.0", + # TODO(AIRCORE-896): Add Fabric SDK/runtime and Relay as default + # dependencies once the PyPI wheels are available to the repo resolver. + # Harness adapter packages should stay target-environment dependencies. # improvement/ subpackage — agent-improvement workflow (POC). "anthropic>=0.88.0", "rich>=13.7.1", @@ -102,4 +105,3 @@ pythonpath = ["src"] # Opt this plugin into OpenAPI spec generation. [tool.nemo.openapi] - 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 cd58c13b7c..9e6d23abea 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py @@ -66,6 +66,8 @@ def translate_agent_config(config: AgentConfig, harness_name: str | None = None) def _fabric_model_types() -> tuple[type, type, type, type, type]: + # TODO(AIRCORE-896): Keep this import lazy until Fabric SDK/runtime wheels + # are available to the repo resolver and can be added as plugin dependencies. try: from nemo_fabric import EnvironmentConfig, FabricConfig, HarnessConfig, MetadataConfig, ModelConfig except ImportError as error: diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/validation.py b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/validation.py index 059967da70..e32f7dbc80 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/validation.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/validation.py @@ -66,6 +66,8 @@ async def validate_fabric_config( def _fabric_validation_types() -> tuple[type, type[Exception]]: + # TODO(AIRCORE-896): Keep this import lazy until Fabric SDK/runtime wheels + # are available to the repo resolver and can be added as plugin dependencies. try: from nemo_fabric import Fabric, FabricConfigError except ImportError as error: From 2392f11d8a77b31069f6bca970bc1ff460c16fbf Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Wed, 15 Jul 2026 11:01:21 -0500 Subject: [PATCH 04/10] adding config loading Signed-off-by: Manjesh Mogallapalli --- .../src/nemo_agents_plugin/agent_config.py | 39 ++++++++++- .../tests/unit/test_agent_config.py | 68 +++++++++++++++++-- 2 files changed, 101 insertions(+), 6 deletions(-) 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 cfd1d4624e..7222ce60b2 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py @@ -12,9 +12,16 @@ from __future__ import annotations +from pathlib import Path from typing import Any, Self -from pydantic import BaseModel, ConfigDict, Field, model_validator +import yaml +from nemo_agents_plugin.entities import AGENT_CONFIG_FILENAME +from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator + + +class AgentConfigLoadError(ValueError): + """Raised when a Platform-owned agent.yaml cannot be loaded.""" class ModelConfig(BaseModel): @@ -76,3 +83,33 @@ def _validate_default_harness(self) -> Self: available = ", ".join(sorted(self.harnesses)) raise ValueError(f"default_harness must reference one of harnesses: {available}") return self + + +def load_agent_config(path: str | Path) -> AgentConfig: + """Load a Platform-owned agent.yaml file as an AgentConfig.""" + config_path = Path(path) + + try: + raw_config = config_path.read_text(encoding="utf-8") + except OSError as error: + raise AgentConfigLoadError(f"Unable to read agent config {config_path}: {error}") from error + except UnicodeDecodeError as error: + raise AgentConfigLoadError(f"Agent config {config_path} is not valid UTF-8: {error}") from error + + try: + data = yaml.safe_load(raw_config) + except yaml.YAMLError as error: + raise AgentConfigLoadError(f"YAML parse error in agent config {config_path}: {error}") from error + + if not isinstance(data, dict): + raise AgentConfigLoadError(f"Agent config {config_path} root must be a YAML mapping.") + + try: + return AgentConfig.model_validate(data) + except ValidationError as error: + raise AgentConfigLoadError(f"Invalid agent config {config_path}: {error}") from error + + +def load_agent_config_from_dir(agent_dir: str | Path) -> AgentConfig: + """Load the canonical agent.yaml file from an agent directory.""" + return load_agent_config(Path(agent_dir) / AGENT_CONFIG_FILENAME) diff --git a/plugins/nemo-agents/tests/unit/test_agent_config.py b/plugins/nemo-agents/tests/unit/test_agent_config.py index be0244576f..c57fa2c7ce 100644 --- a/plugins/nemo-agents/tests/unit/test_agent_config.py +++ b/plugins/nemo-agents/tests/unit/test_agent_config.py @@ -5,15 +5,23 @@ from __future__ import annotations +from pathlib import Path + import pytest -from nemo_agents_plugin.agent_config import AgentConfig +import yaml +from nemo_agents_plugin.agent_config import ( + AgentConfig, + AgentConfigLoadError, + load_agent_config, + load_agent_config_from_dir, +) from pydantic import ValidationError def _example_yaml_config() -> dict: return { - "name": "fabric-mvp-spike", - "description": "Fabric MVP Spike", + "name": "test-agent", + "description": "Test agent config", "default_harness": "hermes", "harnesses": { "hermes": { @@ -60,7 +68,7 @@ def _example_yaml_config() -> dict: "enabled": False, "provider": "relay", "output_dir": "./artifacts/relay", - "project": "fabric-mvp-spike", + "project": "test-agent", "atif": { "enabled": True, "filename_template": "trajectory-{session_id}.atif.json", @@ -78,7 +86,7 @@ class TestAgentConfig: def test_example_yaml_config_validates(self) -> None: config = AgentConfig.model_validate(_example_yaml_config()) - assert config.name == "fabric-mvp-spike" + assert config.name == "test-agent" assert config.default_harness == "hermes" assert config.harnesses["hermes"].model is not None assert config.harnesses["hermes"].model.provider == "nvidia" @@ -130,3 +138,53 @@ def test_unknown_nested_fields_rejected_outside_settings(self) -> None: with pytest.raises(ValidationError, match="Extra inputs are not permitted"): AgentConfig.model_validate(payload) + + +def _write_agent_yaml(path: Path, payload: dict) -> None: + path.write_text(yaml.safe_dump(payload), encoding="utf-8") + + +class TestLoadAgentConfig: + def test_load_agent_config_reads_yaml_file(self, tmp_path: Path) -> None: + config_path = tmp_path / "custom-agent.yaml" + _write_agent_yaml(config_path, _example_yaml_config()) + + config = load_agent_config(config_path) + + assert config.name == "test-agent" + assert config.default_harness == "hermes" + + def test_load_agent_config_from_dir_uses_canonical_filename(self, tmp_path: Path) -> None: + config_path = tmp_path / "agent.yaml" + _write_agent_yaml(config_path, _example_yaml_config()) + + config = load_agent_config_from_dir(tmp_path) + + assert config.name == "test-agent" + + def test_missing_file_reports_load_error(self, tmp_path: Path) -> None: + with pytest.raises(AgentConfigLoadError, match="Unable to read agent config"): + load_agent_config(tmp_path / "missing.yaml") + + def test_invalid_yaml_reports_load_error(self, tmp_path: Path) -> None: + config_path = tmp_path / "agent.yaml" + config_path.write_text("name: [", encoding="utf-8") + + with pytest.raises(AgentConfigLoadError, match="YAML parse error"): + load_agent_config(config_path) + + def test_non_mapping_yaml_reports_load_error(self, tmp_path: Path) -> None: + config_path = tmp_path / "agent.yaml" + config_path.write_text("- not-a-mapping\n", encoding="utf-8") + + with pytest.raises(AgentConfigLoadError, match="root must be a YAML mapping"): + load_agent_config(config_path) + + def test_validation_error_reports_load_error(self, tmp_path: Path) -> None: + config_path = tmp_path / "agent.yaml" + payload = _example_yaml_config() + del payload["default_harness"] + _write_agent_yaml(config_path, payload) + + with pytest.raises(AgentConfigLoadError, match="Invalid agent config"): + load_agent_config(config_path) From 4d7ac3aeceeea1956cf8cdd5c48517d0d242fdbe Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Wed, 15 Jul 2026 11:16:20 -0500 Subject: [PATCH 05/10] cleanup and examples Signed-off-by: Manjesh Mogallapalli --- .../examples/nemo-agent-config/agent.yaml | 48 +++++++++++++++++++ .../src/nemo_agents_plugin/fabric/README.md | 22 +++++++++ .../nemo_agents_plugin/fabric/translator.py | 4 +- .../src/nemo_agents_plugin/schema.py | 12 +++-- .../nemo-agents/tests/unit/test_entities.py | 19 ++++++-- 5 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 plugins/nemo-agents/examples/nemo-agent-config/agent.yaml create mode 100644 plugins/nemo-agents/src/nemo_agents_plugin/fabric/README.md diff --git a/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml b/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml new file mode 100644 index 0000000000..5b85a10f77 --- /dev/null +++ b/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml @@ -0,0 +1,48 @@ +name: test-agent +description: Test agent config + +default_harness: hermes + +harnesses: + hermes: + kind: hermes + model: + provider: nvidia + model: nvidia/nemotron-3-nano-30b-a3b + api_key_env: NVIDIA_API_KEY + temperature: 0.0 + settings: + base_url: https://integrate.api.nvidia.com/v1 + max_iterations: 1 + max_tokens: 512 + reasoning_config: + effort: none + enabled_toolsets: [] + system_prompt: You are a concise test assistant. + codex: + kind: codex + settings: + sandbox: workspace-write + skip_git_repo_check: true + config_overrides: + model_reasoning_effort: high + +models: + default: + provider: openai + model: openai/gpt-5.4 + +prompts: + system: prompts/system.md + +skills: + +environment: + workspace: ./workspace + artifacts: ./artifacts + +telemetry: + enabled: false + provider: relay + output_dir: ./artifacts/relay + project: test-agent diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/README.md b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/README.md new file mode 100644 index 0000000000..5834cebaae --- /dev/null +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/README.md @@ -0,0 +1,22 @@ +# Fabric Config Boundary + +This package contains the Platform-side config and translation helpers for +Fabric-backed NeMo Agents. + +NeMo Platform owns the persisted agent contract. A Fabric-backed agent is stored +using the Platform-owned `nemo-agents-spec-v1` config shape, authored as +`agent.yaml` in the agent spec fileset and represented in code as `AgentConfig`. + +Fabric is an execution dependency, not the persisted Platform contract. Before +calling Fabric SDK APIs, NeMo Agents translates the Platform-owned config into a +typed in-memory `FabricConfig`. + +```text +Platform agent.yaml -> AgentConfig -> FabricConfig +``` + +`agent.yaml` is not treated as a Fabric SDK file-backed config or profile. The +Platform config may keep product concepts, defaults, and artifact references in +the shape NeMo Platform needs, while the Fabric translator owns the mapping into +Fabric's runtime fields such as harness adapter, model, environment, and +telemetry config. 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 9e6d23abea..2780edcad3 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Translate Platform-owned agent config into Fabric SDK config.""" +"""Translate Platform-owned agent config into typed in-memory FabricConfig.""" from __future__ import annotations @@ -26,7 +26,7 @@ class FabricTranslationError(ValueError): def translate_agent_config(config: AgentConfig, harness_name: str | None = None) -> "FabricConfig": - """Translate Platform-owned agent config into a FabricConfig. + """Translate Platform-owned agent config into a typed in-memory FabricConfig. The Fabric SDK import is intentionally local to this function so existing NAT-backed NeMo Agents paths do not require Fabric to be installed. diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/schema.py b/plugins/nemo-agents/src/nemo_agents_plugin/schema.py index 2f372d6f85..6955181c15 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/schema.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/schema.py @@ -22,7 +22,13 @@ from typing import Any -from nemo_agents_plugin.entities import Agent, AgentDeployment, DeploymentMode, DeploymentStatus +from nemo_agents_plugin.entities import ( + NAT_WORKFLOW_CONFIG_FORMAT, + Agent, + AgentDeployment, + DeploymentMode, + DeploymentStatus, +) from nemo_platform_plugin.schema import NemoFilter, NemoListResponse from pydantic import BaseModel, Field @@ -36,8 +42,8 @@ class CreateAgentRequest(BaseModel): name: str = Field(description="Unique agent name within the workspace.") description: str = Field(default="", description="Human-readable description.") - config: dict[str, Any] = Field(description="NAT workflow config dict.") - config_format: str = Field(default="nat-workflow-v1", description="Config format identifier.") + config: dict[str, Any] = Field(description="Agent config dict interpreted according to config_format.") + config_format: str = Field(default=NAT_WORKFLOW_CONFIG_FORMAT, description="Config format identifier.") class CreateDeploymentRequest(BaseModel): diff --git a/plugins/nemo-agents/tests/unit/test_entities.py b/plugins/nemo-agents/tests/unit/test_entities.py index 6e84780aee..f3085a9dd5 100644 --- a/plugins/nemo-agents/tests/unit/test_entities.py +++ b/plugins/nemo-agents/tests/unit/test_entities.py @@ -17,8 +17,12 @@ import pytest from nemo_agents_plugin.entities import ( + AGENT_CONFIG_FILENAME, + NAT_WORKFLOW_CONFIG_FORMAT, + NEMO_AGENTS_SPEC_CONFIG_FORMAT, Agent, AgentDeployment, + agent_config_file_ref, agent_spec_file_ref, agent_spec_fileset_name, agent_spec_local_path, @@ -47,7 +51,7 @@ def test_defaults(self) -> None: assert a.workspace == "default" assert a.description == "" assert a.config == {} - assert a.config_format == "nat-workflow-v1" + assert a.config_format == NAT_WORKFLOW_CONFIG_FORMAT def test_config_stored(self) -> None: config = {"llms": {"my_llm": {"_type": "nim", "model_name": "llama"}}} @@ -60,7 +64,7 @@ def test_data_fields_include_domain_fields(self) -> None: workspace="default", description="A calculator", config={"key": "value"}, - config_format="nat-workflow-v1", + config_format=NAT_WORKFLOW_CONFIG_FORMAT, ) data = a._get_data_fields() assert "description" in data @@ -176,7 +180,7 @@ def test_required_fields(self) -> None: assert req.name == "calc" assert req.config == {"llms": {}} assert req.description == "" - assert req.config_format == "nat-workflow-v1" + assert req.config_format == NAT_WORKFLOW_CONFIG_FORMAT def test_missing_config_raises(self) -> None: with pytest.raises(ValidationError): @@ -193,12 +197,21 @@ def test_custom_format(self) -> None: class TestSpecLocationConvention: + def test_config_contract_constants(self) -> None: + assert AGENT_CONFIG_FILENAME == "agent.yaml" + assert NAT_WORKFLOW_CONFIG_FORMAT == "nat-workflow-v1" + assert NEMO_AGENTS_SPEC_CONFIG_FORMAT == "nemo-agents-spec-v1" + def test_spec_location_convention(self) -> None: assert agent_spec_fileset_name("checkout-bot") == "checkout-bot-spec" ref = agent_spec_file_ref("default", "checkout-bot") assert str(ref) == "default/checkout-bot-spec#AGENT-SPEC.md" assert agent_spec_local_path("checkout-bot").as_posix() == "agents/checkout-bot-spec/AGENT-SPEC.md" + def test_config_file_ref_uses_canonical_agent_yaml(self) -> None: + ref = agent_config_file_ref("default", "checkout-bot") + assert str(ref) == "default/checkout-bot-spec#agent.yaml" + # --------------------------------------------------------------------------- # API schema: CreateDeploymentRequest From 600e5736a43b3df78d46f7203e9ccbcce6dcbb8a Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Wed, 15 Jul 2026 14:52:25 -0500 Subject: [PATCH 06/10] lint Signed-off-by: Manjesh Mogallapalli --- plugins/nemo-agents/openapi/openapi.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/nemo-agents/openapi/openapi.yaml b/plugins/nemo-agents/openapi/openapi.yaml index 8f906361a2..ea7fead01a 100644 --- a/plugins/nemo-agents/openapi/openapi.yaml +++ b/plugins/nemo-agents/openapi/openapi.yaml @@ -2659,7 +2659,7 @@ components: additionalProperties: true type: object title: Config - description: NAT workflow config dict. + description: Agent config dict interpreted according to config_format. config_format: type: string title: Config Format From d5f253124b7a236616a544fd037feedcc8c3c7fd Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Wed, 15 Jul 2026 15:01:01 -0500 Subject: [PATCH 07/10] addressing feedback Signed-off-by: Manjesh Mogallapalli --- .../tests/unit/test_fabric_validation.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/plugins/nemo-agents/tests/unit/test_fabric_validation.py b/plugins/nemo-agents/tests/unit/test_fabric_validation.py index 95a84f612f..042fe3a4c4 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_validation.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_validation.py @@ -5,12 +5,15 @@ from __future__ import annotations +import asyncio import builtins import sys +import threading import types from pathlib import Path from typing import Any +import nemo_agents_plugin.fabric.validation as validation import pytest from nemo_agents_plugin.fabric.validation import ( FabricPreflightError, @@ -39,22 +42,28 @@ def __init__( doctor_report: Any | None = None, plan_error: Exception | None = None, doctor_error: Exception | None = None, + doctor_delay: float = 0.0, ) -> None: self.plan_result = plan self.doctor_report = doctor_report or _FakeDoctorReport({"status": "pass", "checks": []}) self.plan_error = plan_error self.doctor_error = doctor_error + self.doctor_delay = doctor_delay self.plan_calls: list[dict[str, Any]] = [] + self.plan_thread_ids: list[int] = [] self.doctor_calls: list[dict[str, Any]] = [] def plan(self, fabric_config: Any, *, base_dir: Path | str) -> Any: self.plan_calls.append({"fabric_config": fabric_config, "base_dir": base_dir}) + self.plan_thread_ids.append(threading.get_ident()) if self.plan_error is not None: raise self.plan_error return self.plan_result async def doctor(self, fabric_config: Any, *, base_dir: Path | str) -> Any: self.doctor_calls.append({"fabric_config": fabric_config, "base_dir": base_dir}) + if self.doctor_delay: + await asyncio.sleep(self.doctor_delay) if self.doctor_error is not None: raise self.doctor_error return self.doctor_report @@ -82,6 +91,15 @@ async def test_returns_plan_and_doctor_report(self, fake_nemo_fabric: None) -> N assert fabric.plan_calls == [{"fabric_config": fabric_config, "base_dir": Path("/tmp/agent")}] assert fabric.doctor_calls == [{"fabric_config": fabric_config, "base_dir": Path("/tmp/agent")}] + async def test_runs_plan_off_event_loop_thread(self, fake_nemo_fabric: None) -> None: + main_thread_id = threading.get_ident() + fabric = _FakeFabric() + + await validate_fabric_config(object(), base_dir=Path("/tmp/agent"), fabric=fabric) + + assert fabric.plan_thread_ids + assert fabric.plan_thread_ids[0] != main_thread_id + async def test_wraps_plan_errors(self, fake_nemo_fabric: None) -> None: fabric = _FakeFabric(plan_error=_FakeFabricConfigError("bad config")) @@ -94,6 +112,13 @@ async def test_wraps_doctor_errors(self, fake_nemo_fabric: None) -> None: with pytest.raises(FabricValidationError, match="Fabric doctor failed: doctor exploded"): await validate_fabric_config(object(), base_dir=Path("/tmp/agent"), fabric=fabric) + async def test_wraps_doctor_timeout(self, fake_nemo_fabric: None, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(validation, "FABRIC_VALIDATION_TIMEOUT_SECONDS", 0.01) + fabric = _FakeFabric(doctor_delay=1.0) + + with pytest.raises(FabricValidationError, match="Fabric doctor timed out after 0.01s"): + await validate_fabric_config(object(), base_dir=Path("/tmp/agent"), fabric=fabric) + async def test_preflight_failure_reports_failed_checks(self, fake_nemo_fabric: None) -> None: doctor_report = _FakeDoctorReport( { From 8aea23bd594d4edd39e6baf43368347b79719fb6 Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Wed, 15 Jul 2026 16:24:59 -0500 Subject: [PATCH 08/10] addressing feedback Signed-off-by: Manjesh Mogallapalli --- .../src/nemo_agents_plugin/fabric/validation.py | 12 ++++++++++-- .../nemo-agents/tests/unit/test_fabric_validation.py | 4 +++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/validation.py b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/validation.py index e32f7dbc80..9e06c82a08 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/validation.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/validation.py @@ -5,6 +5,7 @@ from __future__ import annotations +import asyncio from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any @@ -12,6 +13,8 @@ if TYPE_CHECKING: from nemo_fabric import FabricConfig +FABRIC_VALIDATION_TIMEOUT_SECONDS = 60.0 + @dataclass(frozen=True, slots=True) class FabricValidationResult: @@ -52,12 +55,17 @@ async def validate_fabric_config( fabric_client = fabric or Fabric() try: - plan = fabric_client.plan(fabric_config, base_dir=base_dir) + plan = await asyncio.to_thread(fabric_client.plan, fabric_config, base_dir=base_dir) except FabricConfigError as error: raise FabricValidationError(f"Fabric plan failed: {error}") from error try: - doctor_report = await fabric_client.doctor(fabric_config, base_dir=base_dir) + doctor_report = await asyncio.wait_for( + fabric_client.doctor(fabric_config, base_dir=base_dir), + timeout=FABRIC_VALIDATION_TIMEOUT_SECONDS, + ) + except asyncio.TimeoutError as error: + raise FabricValidationError(f"Fabric doctor timed out after {FABRIC_VALIDATION_TIMEOUT_SECONDS:g}s.") from error except Exception as error: raise FabricValidationError(f"Fabric doctor failed: {error}") from error diff --git a/plugins/nemo-agents/tests/unit/test_fabric_validation.py b/plugins/nemo-agents/tests/unit/test_fabric_validation.py index 042fe3a4c4..a57518dd6e 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_validation.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_validation.py @@ -45,7 +45,9 @@ def __init__( doctor_delay: float = 0.0, ) -> None: self.plan_result = plan - self.doctor_report = doctor_report or _FakeDoctorReport({"status": "pass", "checks": []}) + self.doctor_report = ( + doctor_report if doctor_report is not None else _FakeDoctorReport({"status": "pass", "checks": []}) + ) self.plan_error = plan_error self.doctor_error = doctor_error self.doctor_delay = doctor_delay From 9509d2d72bda33acedfc673fdcdb72a07faf8493 Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Wed, 15 Jul 2026 16:45:16 -0500 Subject: [PATCH 09/10] lint Signed-off-by: Manjesh Mogallapalli --- .../nemo_agents_plugin/fabric/translator.py | 21 +++++++++++-------- .../nemo_agents_plugin/fabric/validation.py | 12 +++++------ .../tests/unit/test_fabric_translator.py | 20 +++++++++--------- .../tests/unit/test_fabric_validation.py | 14 ++++++------- 4 files changed, 34 insertions(+), 33 deletions(-) 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 2780edcad3..45a75f98e6 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py @@ -5,14 +5,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +import importlib +from typing import Any from nemo_agents_plugin.agent_config import AgentConfig, HarnessConfig, ModelConfig -if TYPE_CHECKING: - from nemo_fabric import FabricConfig - - HARNESS_ADAPTER_IDS = { "claude": "nvidia.fabric.claude", "codex": "nvidia.fabric.codex.cli", @@ -25,7 +22,7 @@ class FabricTranslationError(ValueError): """Raised when Platform agent config cannot be translated to Fabric config.""" -def translate_agent_config(config: AgentConfig, harness_name: str | None = None) -> "FabricConfig": +def translate_agent_config(config: AgentConfig, harness_name: str | None = None) -> Any: """Translate Platform-owned agent config into a typed in-memory FabricConfig. The Fabric SDK import is intentionally local to this function so existing @@ -69,13 +66,19 @@ def _fabric_model_types() -> tuple[type, type, type, type, type]: # TODO(AIRCORE-896): Keep this import lazy until Fabric SDK/runtime wheels # are available to the repo resolver and can be added as plugin dependencies. try: - from nemo_fabric import EnvironmentConfig, FabricConfig, HarnessConfig, MetadataConfig, ModelConfig + nemo_fabric = importlib.import_module("nemo_fabric") except ImportError as error: raise FabricTranslationError( "NeMo Fabric SDK is required to translate nemo-agents-spec-v1 config to FabricConfig." ) from error - return FabricConfig, HarnessConfig, MetadataConfig, ModelConfig, EnvironmentConfig + return ( + getattr(nemo_fabric, "FabricConfig"), + getattr(nemo_fabric, "HarnessConfig"), + getattr(nemo_fabric, "MetadataConfig"), + getattr(nemo_fabric, "ModelConfig"), + getattr(nemo_fabric, "EnvironmentConfig"), + ) def _select_harness(config: AgentConfig, harness_name: str | None) -> tuple[str, HarnessConfig]: @@ -113,7 +116,7 @@ def _model_payload(model: ModelConfig) -> dict[str, Any]: return model.model_dump(exclude_none=True) -def _apply_telemetry(fabric_config: "FabricConfig", config: AgentConfig, model: ModelConfig) -> None: +def _apply_telemetry(fabric_config: Any, config: AgentConfig, model: ModelConfig) -> None: telemetry = config.telemetry if not telemetry.enabled: return diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/validation.py b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/validation.py index 9e06c82a08..050d17b806 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/validation.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/validation.py @@ -6,12 +6,10 @@ from __future__ import annotations import asyncio +import importlib from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from nemo_fabric import FabricConfig +from typing import Any FABRIC_VALIDATION_TIMEOUT_SECONDS = 60.0 @@ -39,7 +37,7 @@ def __init__(self, status: str | None, failed_checks: list[str]) -> None: async def validate_fabric_config( - fabric_config: "FabricConfig", + fabric_config: Any, *, base_dir: Path | str, fabric: Any | None = None, @@ -77,11 +75,11 @@ def _fabric_validation_types() -> tuple[type, type[Exception]]: # TODO(AIRCORE-896): Keep this import lazy until Fabric SDK/runtime wheels # are available to the repo resolver and can be added as plugin dependencies. try: - from nemo_fabric import Fabric, FabricConfigError + nemo_fabric = importlib.import_module("nemo_fabric") except ImportError as error: raise FabricValidationError("NeMo Fabric SDK is required to plan and preflight FabricConfig.") from error - return Fabric, FabricConfigError + return getattr(nemo_fabric, "Fabric"), getattr(nemo_fabric, "FabricConfigError") def _ensure_doctor_passed(report: dict[str, Any]) -> None: diff --git a/plugins/nemo-agents/tests/unit/test_fabric_translator.py b/plugins/nemo-agents/tests/unit/test_fabric_translator.py index 42cfd01bb5..b139fa61b4 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_translator.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_translator.py @@ -5,8 +5,8 @@ from __future__ import annotations -import builtins import copy +import importlib import sys import types from typing import Any @@ -42,11 +42,11 @@ def enable_relay( @pytest.fixture() def fake_nemo_fabric(monkeypatch: pytest.MonkeyPatch) -> None: module = types.ModuleType("nemo_fabric") - module.EnvironmentConfig = _FabricObject - module.FabricConfig = _FakeFabricConfig - module.HarnessConfig = _FabricObject - module.MetadataConfig = _FabricObject - module.ModelConfig = _FabricObject + setattr(module, "EnvironmentConfig", _FabricObject) + setattr(module, "FabricConfig", _FakeFabricConfig) + setattr(module, "HarnessConfig", _FabricObject) + setattr(module, "MetadataConfig", _FabricObject) + setattr(module, "ModelConfig", _FabricObject) monkeypatch.setitem(sys.modules, "nemo_fabric", module) @@ -209,15 +209,15 @@ def test_relay_telemetry_uses_latest_fabric_shape(self, fake_nemo_fabric: None) } def test_missing_fabric_dependency_reports_actionable_error(self, monkeypatch: pytest.MonkeyPatch) -> None: - real_import = builtins.__import__ + real_import_module = importlib.import_module - def fake_import(name: str, *args: Any, **kwargs: Any) -> Any: + def fake_import_module(name: str, package: str | None = None) -> Any: if name == "nemo_fabric": raise ImportError("No module named 'nemo_fabric'") - return real_import(name, *args, **kwargs) + return real_import_module(name, package) monkeypatch.delitem(sys.modules, "nemo_fabric", raising=False) - monkeypatch.setattr(builtins, "__import__", fake_import) + monkeypatch.setattr(importlib, "import_module", fake_import_module) config = AgentConfig.model_validate(_example_yaml_config()) with pytest.raises(FabricTranslationError, match="NeMo Fabric SDK is required"): diff --git a/plugins/nemo-agents/tests/unit/test_fabric_validation.py b/plugins/nemo-agents/tests/unit/test_fabric_validation.py index a57518dd6e..6562386b75 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_validation.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_validation.py @@ -6,7 +6,7 @@ from __future__ import annotations import asyncio -import builtins +import importlib import sys import threading import types @@ -74,8 +74,8 @@ async def doctor(self, fabric_config: Any, *, base_dir: Path | str) -> Any: @pytest.fixture() def fake_nemo_fabric(monkeypatch: pytest.MonkeyPatch) -> None: module = types.ModuleType("nemo_fabric") - module.Fabric = _FakeFabric - module.FabricConfigError = _FakeFabricConfigError + setattr(module, "Fabric", _FakeFabric) + setattr(module, "FabricConfigError", _FakeFabricConfigError) monkeypatch.setitem(sys.modules, "nemo_fabric", module) @@ -158,15 +158,15 @@ async def test_dict_doctor_report_is_supported(self, fake_nemo_fabric: None) -> assert result.doctor_report == {"status": "pass", "checks": []} async def test_missing_fabric_dependency_reports_actionable_error(self, monkeypatch: pytest.MonkeyPatch) -> None: - real_import = builtins.__import__ + real_import_module = importlib.import_module - def fake_import(name: str, *args: Any, **kwargs: Any) -> Any: + def fake_import_module(name: str, package: str | None = None) -> Any: if name == "nemo_fabric": raise ImportError("No module named 'nemo_fabric'") - return real_import(name, *args, **kwargs) + return real_import_module(name, package) monkeypatch.delitem(sys.modules, "nemo_fabric", raising=False) - monkeypatch.setattr(builtins, "__import__", fake_import) + monkeypatch.setattr(importlib, "import_module", fake_import_module) with pytest.raises(FabricValidationError, match="NeMo Fabric SDK is required"): await validate_fabric_config(object(), base_dir=Path("/tmp/agent"), fabric=_FakeFabric()) From 5f5a2e53443b2fa2d1f99098b6051b1b73f19a6e Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Thu, 16 Jul 2026 15:59:06 -0500 Subject: [PATCH 10/10] feedback Signed-off-by: Manjesh Mogallapalli --- .../nemo-agents/examples/nemo-agent-config/agent.yaml | 1 + .../src/nemo_agents_plugin/agent_config.py | 3 ++- .../src/nemo_agents_plugin/fabric/__init__.py | 4 ---- plugins/nemo-agents/tests/unit/test_agent_config.py | 11 +++++++++++ plugins/nemo-agents/tests/unit/test_entities.py | 7 ------- .../nemo-agents/tests/unit/test_fabric_translator.py | 1 + 6 files changed, 15 insertions(+), 12 deletions(-) delete mode 100644 plugins/nemo-agents/src/nemo_agents_plugin/fabric/__init__.py diff --git a/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml b/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml index 5b85a10f77..c32431e030 100644 --- a/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml +++ b/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml @@ -1,3 +1,4 @@ +config_format: nemo-agents-spec-v1 name: test-agent description: Test agent config 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 7222ce60b2..45a78eb326 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py @@ -13,7 +13,7 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Self +from typing import Any, Literal, Self import yaml from nemo_agents_plugin.entities import AGENT_CONFIG_FILENAME @@ -67,6 +67,7 @@ class AgentConfig(BaseModel): model_config = ConfigDict(extra="forbid") + config_format: Literal["nemo-agents-spec-v1"] name: str description: str = "" default_harness: str diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/__init__.py b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/__init__.py deleted file mode 100644 index 9936788ffa..0000000000 --- a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Fabric integration helpers for NeMo Agents.""" diff --git a/plugins/nemo-agents/tests/unit/test_agent_config.py b/plugins/nemo-agents/tests/unit/test_agent_config.py index c57fa2c7ce..8198e0a9b7 100644 --- a/plugins/nemo-agents/tests/unit/test_agent_config.py +++ b/plugins/nemo-agents/tests/unit/test_agent_config.py @@ -20,6 +20,7 @@ def _example_yaml_config() -> dict: return { + "config_format": "nemo-agents-spec-v1", "name": "test-agent", "description": "Test agent config", "default_harness": "hermes", @@ -86,6 +87,7 @@ class TestAgentConfig: def test_example_yaml_config_validates(self) -> None: config = AgentConfig.model_validate(_example_yaml_config()) + assert config.config_format == "nemo-agents-spec-v1" assert config.name == "test-agent" assert config.default_harness == "hermes" assert config.harnesses["hermes"].model is not None @@ -101,6 +103,7 @@ def test_example_yaml_config_validates(self) -> None: def test_defaults_fill_optional_sections(self) -> None: config = AgentConfig.model_validate( { + "config_format": "nemo-agents-spec-v1", "name": "minimal-agent", "default_harness": "codex", "harnesses": {"codex": {"kind": "codex"}}, @@ -119,6 +122,7 @@ def test_default_harness_must_reference_configured_harness(self) -> None: with pytest.raises(ValidationError, match="default_harness must reference one of harnesses: codex"): AgentConfig.model_validate( { + "config_format": "nemo-agents-spec-v1", "name": "bad-agent", "default_harness": "hermes", "harnesses": {"codex": {"kind": "codex"}}, @@ -132,6 +136,13 @@ def test_unknown_top_level_fields_rejected(self) -> None: with pytest.raises(ValidationError, match="Extra inputs are not permitted"): AgentConfig.model_validate(payload) + def test_config_format_must_match_platform_spec_version(self) -> None: + payload = _example_yaml_config() + payload["config_format"] = "nat-workflow-v1" + + with pytest.raises(ValidationError, match="Input should be 'nemo-agents-spec-v1'"): + AgentConfig.model_validate(payload) + def test_unknown_nested_fields_rejected_outside_settings(self) -> None: payload = _example_yaml_config() payload["harnesses"]["codex"]["unknown"] = "value" diff --git a/plugins/nemo-agents/tests/unit/test_entities.py b/plugins/nemo-agents/tests/unit/test_entities.py index f3085a9dd5..7eef5bf9e2 100644 --- a/plugins/nemo-agents/tests/unit/test_entities.py +++ b/plugins/nemo-agents/tests/unit/test_entities.py @@ -17,9 +17,7 @@ import pytest from nemo_agents_plugin.entities import ( - AGENT_CONFIG_FILENAME, NAT_WORKFLOW_CONFIG_FORMAT, - NEMO_AGENTS_SPEC_CONFIG_FORMAT, Agent, AgentDeployment, agent_config_file_ref, @@ -197,11 +195,6 @@ def test_custom_format(self) -> None: class TestSpecLocationConvention: - def test_config_contract_constants(self) -> None: - assert AGENT_CONFIG_FILENAME == "agent.yaml" - assert NAT_WORKFLOW_CONFIG_FORMAT == "nat-workflow-v1" - assert NEMO_AGENTS_SPEC_CONFIG_FORMAT == "nemo-agents-spec-v1" - def test_spec_location_convention(self) -> None: assert agent_spec_fileset_name("checkout-bot") == "checkout-bot-spec" ref = agent_spec_file_ref("default", "checkout-bot") diff --git a/plugins/nemo-agents/tests/unit/test_fabric_translator.py b/plugins/nemo-agents/tests/unit/test_fabric_translator.py index b139fa61b4..8ddb453725 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_translator.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_translator.py @@ -52,6 +52,7 @@ def fake_nemo_fabric(monkeypatch: pytest.MonkeyPatch) -> None: def _example_yaml_config() -> dict[str, Any]: return { + "config_format": "nemo-agents-spec-v1", "name": "example-agent", "description": "Example Agent", "default_harness": "hermes",