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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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