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..c32431e030 --- /dev/null +++ b/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml @@ -0,0 +1,49 @@ +config_format: nemo-agents-spec-v1 +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/openapi/openapi.yaml b/plugins/nemo-agents/openapi/openapi.yaml index 5e53975db4..ea7fead01a 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: @@ -2660,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 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/agent_config.py b/plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py new file mode 100644 index 0000000000..45a78eb326 --- /dev/null +++ b/plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py @@ -0,0 +1,116 @@ +# 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 pathlib import Path +from typing import Any, Literal, Self + +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): + 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") + + config_format: Literal["nemo-agents-spec-v1"] + 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 + + +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/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." ), ) 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 new file mode 100644 index 0000000000..45a75f98e6 --- /dev/null +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py @@ -0,0 +1,153 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Translate Platform-owned agent config into typed in-memory FabricConfig.""" + +from __future__ import annotations + +import importlib +from typing import Any + +from nemo_agents_plugin.agent_config import AgentConfig, HarnessConfig, ModelConfig + +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) -> Any: + """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. + """ + + ( + 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]: + # 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: + 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 ( + 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]: + 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: Any, 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..050d17b806 --- /dev/null +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/validation.py @@ -0,0 +1,117 @@ +# 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 + +import asyncio +import importlib +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +FABRIC_VALIDATION_TIMEOUT_SECONDS = 60.0 + + +@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: Any, + *, + 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 = 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 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 + + _ensure_doctor_passed(_to_mapping(doctor_report)) + return FabricValidationResult(plan=plan, doctor_report=doctor_report) + + +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: + 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 getattr(nemo_fabric, "Fabric"), getattr(nemo_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/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_agent_config.py b/plugins/nemo-agents/tests/unit/test_agent_config.py new file mode 100644 index 0000000000..8198e0a9b7 --- /dev/null +++ b/plugins/nemo-agents/tests/unit/test_agent_config.py @@ -0,0 +1,201 @@ +# 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 + +from pathlib import Path + +import pytest +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 { + "config_format": "nemo-agents-spec-v1", + "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 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": "test-agent", + "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.config_format == "nemo-agents-spec-v1" + 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" + 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( + { + "config_format": "nemo-agents-spec-v1", + "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( + { + "config_format": "nemo-agents-spec-v1", + "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_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" + + 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) diff --git a/plugins/nemo-agents/tests/unit/test_entities.py b/plugins/nemo-agents/tests/unit/test_entities.py index 6e84780aee..7eef5bf9e2 100644 --- a/plugins/nemo-agents/tests/unit/test_entities.py +++ b/plugins/nemo-agents/tests/unit/test_entities.py @@ -17,8 +17,10 @@ import pytest from nemo_agents_plugin.entities import ( + NAT_WORKFLOW_CONFIG_FORMAT, Agent, AgentDeployment, + agent_config_file_ref, agent_spec_file_ref, agent_spec_fileset_name, agent_spec_local_path, @@ -47,7 +49,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 +62,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 +178,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): @@ -199,6 +201,10 @@ def test_spec_location_convention(self) -> None: 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 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..8ddb453725 --- /dev/null +++ b/plugins/nemo-agents/tests/unit/test_fabric_translator.py @@ -0,0 +1,225 @@ +# 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 copy +import importlib +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") + 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) + + +def _example_yaml_config() -> dict[str, Any]: + return { + "config_format": "nemo-agents-spec-v1", + "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_module = importlib.import_module + + 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_module(name, package) + + monkeypatch.delitem(sys.modules, "nemo_fabric", raising=False) + 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"): + 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..6562386b75 --- /dev/null +++ b/plugins/nemo-agents/tests/unit/test_fabric_validation.py @@ -0,0 +1,172 @@ +# 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 asyncio +import importlib +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, + 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, + doctor_delay: float = 0.0, + ) -> None: + self.plan_result = plan + 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 + 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 + + +@pytest.fixture() +def fake_nemo_fabric(monkeypatch: pytest.MonkeyPatch) -> None: + module = types.ModuleType("nemo_fabric") + setattr(module, "Fabric", _FakeFabric) + setattr(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_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")) + + 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_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( + { + "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_module = importlib.import_module + + 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_module(name, package) + + monkeypatch.delitem(sys.modules, "nemo_fabric", raising=False) + 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())