Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/nemo_platform/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ nemo-agents-plugin = [
"boto3>=1.40.46,<1.40.62",
"botocore>=1.40.46,<1.40.62",
"httpx>=0.27",
"nemo-fabric>=0.1.0a20260717,<0.2.0",
"pyyaml>=6.0",
"anthropic>=0.88.0",
"rich>=13.7.1",
Expand Down
6 changes: 3 additions & 3 deletions plugins/nemo-agents/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ dependencies = [
"boto3>=1.40.46,<1.40.62",
"botocore>=1.40.46,<1.40.62",
"httpx>=0.27",
# TODO(AIRCORE-897): Move this to a stable Fabric version before release once available.
# TODO(AIRCORE-897): Add the `relay` extra once nemo-evaluator-sdk's nemo-relay pin allows >=0.5.
"nemo-fabric>=0.1.0a20260717,<0.2.0",
"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",
Expand Down
49 changes: 9 additions & 40 deletions plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@

from __future__ import annotations

import importlib
from typing import Any

# CI type-checks this plugin via ty extra-paths without installing nemo-agents deps.
import nemo_fabric as fabric # ty: ignore[unresolved-import]
from nemo_agents_plugin.agent_config import AgentConfig, HarnessConfig, ModelConfig

HARNESS_ADAPTER_IDS = {
Expand All @@ -22,35 +23,22 @@ 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()

def translate_agent_config(config: AgentConfig, harness_name: str | None = None) -> fabric.FabricConfig:
"""Translate Platform-owned agent config into a typed in-memory FabricConfig."""
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_(
fabric_config = fabric.FabricConfig(
metadata=fabric.MetadataConfig(name=config.name, description=config.description or None),
harness=fabric.HarnessConfig(
adapter_id=_adapter_id_for_harness(harness),
resolution="preinstalled",
settings=harness.settings,
),
models={
"default": ModelConfig_(**_model_payload(model)),
"default": fabric.ModelConfig(**_model_payload(model)),
},
environment=EnvironmentConfig(
environment=fabric.EnvironmentConfig(
provider=config.environment.provider,
workspace=config.environment.workspace,
artifacts=config.environment.artifacts,
Expand All @@ -62,25 +50,6 @@ def translate_agent_config(config: AgentConfig, harness_name: str | None = None)
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)
Expand Down
62 changes: 49 additions & 13 deletions plugins/nemo-agents/src/nemo_agents_plugin/fabric/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@
from __future__ import annotations

import asyncio
import importlib
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from nemo_agents_plugin.agent_config import AgentConfig
from nemo_agents_plugin.fabric.translator import FabricTranslationError, translate_agent_config

# CI type-checks this plugin via ty extra-paths without installing nemo-agents deps.
from nemo_fabric import Fabric, FabricConfig, FabricConfigError # ty: ignore[unresolved-import]
from pydantic import ValidationError

FABRIC_VALIDATION_TIMEOUT_SECONDS = 60.0


Expand All @@ -22,6 +29,15 @@ class FabricValidationResult:
doctor_report: Any


@dataclass(frozen=True, slots=True)
class PlatformFabricValidationResult:
"""Result of Platform config translation and Fabric validation."""

agent_config: AgentConfig
fabric_config: FabricConfig
fabric_validation_result: FabricValidationResult


class FabricValidationError(ValueError):
"""Raised when Fabric planning or preflight validation fails."""

Expand All @@ -36,20 +52,41 @@ def __init__(self, status: str | None, failed_checks: list[str]) -> None:
super().__init__(f"Fabric preflight failed with status {status!r}: {details}")


async def validate_platform_agent_config(
config: AgentConfig | Mapping[str, Any],
*,
base_dir: Path | str,
harness_name: str | None = None,
fabric: Any | None = None,
) -> PlatformFabricValidationResult:
"""Translate and validate a Platform-owned agent config with Fabric."""

agent_config = _coerce_agent_config(config)
try:
fabric_config = translate_agent_config(agent_config, harness_name=harness_name)
except FabricTranslationError as error:
raise FabricValidationError(f"Fabric config translation failed: {error}") from error

validation_result = await validate_fabric_config(fabric_config, base_dir=base_dir, fabric=fabric)
return PlatformFabricValidationResult(
agent_config=agent_config,
fabric_config=fabric_config,
fabric_validation_result=validation_result,
)


async def validate_fabric_config(
fabric_config: Any,
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.
agent. Fabric is a required dependency of the ``nemo-agents`` plugin.
"""

Fabric, FabricConfigError = _fabric_validation_types()
fabric_client = fabric or Fabric()

try:
Expand All @@ -71,15 +108,14 @@ async def validate_fabric_config(
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
def _coerce_agent_config(config: AgentConfig | Mapping[str, Any]) -> AgentConfig:
if isinstance(config, AgentConfig):
return config

return getattr(nemo_fabric, "Fabric"), getattr(nemo_fabric, "FabricConfigError")
try:
return AgentConfig.model_validate(config)
except ValidationError as error:
raise FabricValidationError(f"Invalid Platform agent config: {error}") from error


def _ensure_doctor_passed(report: dict[str, Any]) -> None:
Expand Down
71 changes: 9 additions & 62 deletions plugins/nemo-agents/tests/unit/test_fabric_translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,50 +6,13 @@
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",
Expand Down Expand Up @@ -107,7 +70,7 @@ def _example_yaml_config() -> dict[str, Any]:


class TestTranslateAgentConfig:
def test_translates_default_harness(self, fake_nemo_fabric: None) -> None:
def test_translates_default_harness(self) -> None:
config = AgentConfig.model_validate(_example_yaml_config())

fabric_config = translate_agent_config(config)
Expand All @@ -122,9 +85,9 @@ def test_translates_default_harness(self, fake_nemo_fabric: None) -> None:
assert fabric_config.environment.provider == "local"
assert fabric_config.environment.workspace == "./workspace"
assert fabric_config.environment.artifacts == "./artifacts"
assert not hasattr(fabric_config, "relay")
assert fabric_config.relay is None

def test_selected_harness_uses_default_model(self, fake_nemo_fabric: None) -> None:
def test_selected_harness_uses_default_model(self) -> None:
config = AgentConfig.model_validate(_example_yaml_config())

fabric_config = translate_agent_config(config, harness_name="codex")
Expand All @@ -145,7 +108,6 @@ def test_selected_harness_uses_default_model(self, fake_nemo_fabric: None) -> No
)
def test_supported_harness_kinds_translate_to_adapter_ids(
self,
fake_nemo_fabric: None,
kind: str,
adapter_id: str,
) -> None:
Expand All @@ -158,13 +120,13 @@ def test_supported_harness_kinds_translate_to_adapter_ids(

assert fabric_config.harness.adapter_id == adapter_id

def test_unknown_selected_harness_rejected(self, fake_nemo_fabric: None) -> None:
def test_unknown_selected_harness_rejected(self) -> 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:
def test_unsupported_harness_kind_rejected(self) -> None:
payload = _example_yaml_config()
payload["harnesses"]["custom"] = {"kind": "custom"}
payload["default_harness"] = "custom"
Expand All @@ -173,7 +135,7 @@ def test_unsupported_harness_kind_rejected(self, fake_nemo_fabric: None) -> None
with pytest.raises(FabricTranslationError, match="Unsupported harness kind 'custom'"):
translate_agent_config(config)

def test_missing_model_rejected(self, fake_nemo_fabric: None) -> None:
def test_missing_model_rejected(self) -> None:
payload = _example_yaml_config()
payload["models"] = {}
payload["default_harness"] = "codex"
Expand All @@ -182,17 +144,17 @@ def test_missing_model_rejected(self, fake_nemo_fabric: None) -> None:
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:
def test_relay_telemetry_uses_latest_fabric_shape(self) -> 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.telemetry.providers["relay"].config is None
assert fabric_config.relay.project == "example-agent"
assert fabric_config.relay.output_dir == "./artifacts/relay"
assert fabric_config.relay.observability == {
assert fabric_config.relay.observability.model_dump(exclude_none=True) == {
"version": 1,
"atif": {
"enabled": True,
Expand All @@ -208,18 +170,3 @@ def test_relay_telemetry_uses_latest_fabric_shape(self, fake_nemo_fabric: None)
"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)
Loading