diff --git a/plugins/nemo-agents/examples/calculator-agent/fabric/.gitignore b/plugins/nemo-agents/examples/calculator-agent/fabric/.gitignore new file mode 100644 index 0000000000..11530f2cbf --- /dev/null +++ b/plugins/nemo-agents/examples/calculator-agent/fabric/.gitignore @@ -0,0 +1,2 @@ +artifacts/ +workspace/ diff --git a/plugins/nemo-agents/examples/calculator-agent/fabric/agent.yaml b/plugins/nemo-agents/examples/calculator-agent/fabric/agent.yaml new file mode 100644 index 0000000000..21818a920d --- /dev/null +++ b/plugins/nemo-agents/examples/calculator-agent/fabric/agent.yaml @@ -0,0 +1,18 @@ +config_format: nemo-agents-spec-v1 +name: nat-simple-calculator +description: Invoke the NAT simple calculator workflow through NeMo Fabric. + +default_harness: nat + +harnesses: + nat: + kind: nat + settings: + config_file: ./workflow.yml + +environment: + workspace: ./workspace + artifacts: ./artifacts + +telemetry: + enabled: false diff --git a/plugins/nemo-agents/examples/calculator-agent/fabric/workflow.yml b/plugins/nemo-agents/examples/calculator-agent/fabric/workflow.yml new file mode 100644 index 0000000000..b8299c8a6c --- /dev/null +++ b/plugins/nemo-agents/examples/calculator-agent/fabric/workflow.yml @@ -0,0 +1,26 @@ +function_groups: + calculator: + _type: calculator + +functions: + current_datetime: + _type: current_datetime + +llms: + llm: + _type: nim + model_name: nvidia/nemotron-3-nano-30b-a3b + temperature: 0.0 + max_tokens: 1024 + chat_template_kwargs: + enable_thinking: false + +workflow: + _type: react_agent + tool_names: + - calculator + - current_datetime + llm_name: llm + verbose: false + parse_agent_response_max_retries: 3 + use_native_tool_calling: true diff --git a/plugins/nemo-agents/examples/email-phishing-analyzer/fabric/.gitignore b/plugins/nemo-agents/examples/email-phishing-analyzer/fabric/.gitignore new file mode 100644 index 0000000000..11530f2cbf --- /dev/null +++ b/plugins/nemo-agents/examples/email-phishing-analyzer/fabric/.gitignore @@ -0,0 +1,2 @@ +artifacts/ +workspace/ diff --git a/plugins/nemo-agents/examples/email-phishing-analyzer/fabric/README.md b/plugins/nemo-agents/examples/email-phishing-analyzer/fabric/README.md new file mode 100644 index 0000000000..6928d6dfdb --- /dev/null +++ b/plugins/nemo-agents/examples/email-phishing-analyzer/fabric/README.md @@ -0,0 +1,15 @@ +# NAT Through NeMo Fabric + +This example keeps the NAT workflow YAML authoritative and invokes it through +the Platform-owned NAT Fabric adapter. + +From the repository root: + +```bash +uv pip install -e "plugins/nemo-agents[fabric]" +export NVIDIA_API_KEY="" + +nemo agents invoke \ + --agent-config plugins/nemo-agents/examples/email-phishing-analyzer/fabric/agent.yaml \ + --input "Subject: Verify your account. Send your password immediately." +``` diff --git a/plugins/nemo-agents/examples/email-phishing-analyzer/fabric/agent.yaml b/plugins/nemo-agents/examples/email-phishing-analyzer/fabric/agent.yaml new file mode 100644 index 0000000000..e4bed5d1fc --- /dev/null +++ b/plugins/nemo-agents/examples/email-phishing-analyzer/fabric/agent.yaml @@ -0,0 +1,18 @@ +config_format: nemo-agents-spec-v1 +name: nat-email-phishing-analyzer +description: Invoke the NAT email phishing analyzer through NeMo Fabric. + +default_harness: nat + +harnesses: + nat: + kind: nat + settings: + config_file: ./workflow.yml + +environment: + workspace: ./workspace + artifacts: ./artifacts + +telemetry: + enabled: false diff --git a/plugins/nemo-agents/examples/email-phishing-analyzer/fabric/workflow.yml b/plugins/nemo-agents/examples/email-phishing-analyzer/fabric/workflow.yml new file mode 100644 index 0000000000..d8751be218 --- /dev/null +++ b/plugins/nemo-agents/examples/email-phishing-analyzer/fabric/workflow.yml @@ -0,0 +1,23 @@ +functions: + email_phishing_analyzer: + _type: email_phishing_analyzer + llm: llm + +llms: + llm: + _type: openai + api_key: ${NVIDIA_API_KEY} + base_url: https://integrate.api.nvidia.com/v1 + model_name: nvidia/nemotron-3-nano-30b-a3b + temperature: 0.0 + max_tokens: 1024 + +workflow: + _type: react_agent + tool_names: + - email_phishing_analyzer + llm_name: llm + verbose: false + parse_agent_response_max_retries: 3 + additional_instructions: The final response should indicate that the email is either "phishing" or "benign". + use_native_tool_calling: true diff --git a/plugins/nemo-agents/examples/nemo-agent-config/README.md b/plugins/nemo-agents/examples/nemo-agent-config/README.md index 7bf3eeb14e..806d9d90d0 100644 --- a/plugins/nemo-agents/examples/nemo-agent-config/README.md +++ b/plugins/nemo-agents/examples/nemo-agent-config/README.md @@ -1,16 +1,48 @@ # Fabric-Backed Agent Config -This Platform-owned config invokes Codex or Hermes through NeMo Fabric. Run the -commands below from the repository root. +These Platform-owned configs invoke Codex, Hermes, or NAT through NeMo Fabric. +Run the commands below from the repository root. Fabric dependencies are currently optional so the default workspace does not -force other Fabric consumers onto the `0.1.0a1` SDK API before they migrate. +force other Fabric consumers onto the prerelease SDK API before they migrate. Install them explicitly before local Fabric smoke tests: ```bash uv pip install -e "plugins/nemo-agents[fabric]" ``` +## NAT + +The plugin install includes the Platform-packaged calculator and email phishing +NAT components. Their workflow YAML remains the source of truth; the adjacent +`agent.yaml` only selects the Platform-owned NAT Fabric adapter. + +Run the calculator workflow: + +```bash +export NVIDIA_API_KEY="" + +nemo agents invoke \ + --agent-config plugins/nemo-agents/examples/calculator-agent/fabric/agent.yaml \ + --input "What is 12 multiplied by 8?" +``` + +Run the email phishing analyzer: + +```bash +export NVIDIA_API_KEY="" + +nemo agents invoke \ + --agent-config plugins/nemo-agents/examples/email-phishing-analyzer/fabric/agent.yaml \ + --input "Subject: Verify your account. Send your password immediately." +``` + +To use another NAT workflow, keep its config and relative resources under the +Platform `agent.yaml` directory and set `harness.settings.config_file` to that +workflow. Install any package that exposes workflow-specific `nat.components` +into the same Python environment. Keep those packages on the same NAT release +as the `nvidia-nat-*` packages installed by NeMo Agents. + ## Codex Authenticate Codex, leave `default_harness: codex` in `agent.yaml`, and run: @@ -30,10 +62,10 @@ Hermes Agent has dependencies that conflict with the Platform environment, so install it with the Fabric adapter in a separate Python 3.12 environment: ```bash -uvx uv@0.9.14 venv --python 3.12 .venv-hermes -uvx uv@0.9.14 --no-config pip install \ +uv venv --python 3.12 .venv-hermes +uv --no-config pip install \ --python .venv-hermes/bin/python \ - "nemo-fabric-adapters-hermes==0.1.0a1" \ + "nemo-fabric-adapters-hermes==0.1.0a20260724" \ "hermes-agent==0.19.0" export HERMES_ADAPTER_PYTHON="$PWD/.venv-hermes/bin/python" diff --git a/plugins/nemo-agents/pyproject.toml b/plugins/nemo-agents/pyproject.toml index 1b16565c79..cc4783be90 100644 --- a/plugins/nemo-agents/pyproject.toml +++ b/plugins/nemo-agents/pyproject.toml @@ -64,9 +64,11 @@ fabric = [ # to the 0.1.0a1+ config-first SDK API and Fabric packaging is stable across Platform environments. # 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[runtime]==0.1.0a1", - "nemo-fabric-adapters-codex==0.1.0a1", - "nemo-fabric-adapters-hermes==0.1.0a1; python_version < '3.14'", + # Installed third-party adapter discovery requires the first Fabric build after NVIDIA/NeMo-Fabric#108. + "nemo-fabric[runtime]==0.1.0a20260724", + "nemo-fabric-adapters-common==0.1.0a20260724", + "nemo-fabric-adapters-codex==0.1.0a20260724", + "nemo-fabric-adapters-hermes==0.1.0a20260724; python_version < '3.14'", ] container = [ "jinja2>=3.1", @@ -94,6 +96,9 @@ packages = [ "vendor/openclaw_agent_adapter/src/nat_openclaw_agent_adapter", ] +[tool.hatch.build.targets.wheel.shared-data] +"src/nemo_agents_plugin/fabric/adapters/nat/fabric-adapter.json" = "share/nemo-fabric/adapters/nemo-platform-nat/fabric-adapter.json" + [tool.uv.sources] nemo-platform = { workspace = true } @@ -105,6 +110,9 @@ nemo-agents-example-email-phishing = { workspace = true } [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" +markers = [ + "integration: high-level tests that exercise installed runtime boundaries", +] # "src" puts nemo_agents_plugin on sys.path. nemo-platform is resolved # from the workspace — run `uv sync` at the repo root before running these tests. pythonpath = ["src"] diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/adapters/nat/adapter.py b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/adapters/nat/adapter.py new file mode 100644 index 0000000000..d370f84e56 --- /dev/null +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/adapters/nat/adapter.py @@ -0,0 +1,260 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""NeMo Agent Toolkit adapter for NeMo Fabric. + +One adapter host owns one entered NAT workflow context for the lifetime of a +Fabric runtime. The NAT configuration file remains the workflow source of truth. +""" + +from __future__ import annotations + +import asyncio +import logging +from contextlib import AsyncExitStack +from pathlib import Path +from typing import Any + +import nemo_fabric_adapters.common.utils as common_utils +from nemo_fabric_adapters.common import lifecycle +from pydantic_core import to_jsonable_python + +LOGGER = logging.getLogger(__name__) +HARNESS = "nat" +MODE = "nat_workflow" + + +def main() -> None: + """Serve the persistent local-host lifecycle protocol.""" + + lifecycle.serve(NatRuntime) + + +def resolve_config_file(payload: dict[str, Any]) -> Path: + """Resolve and validate the NAT config selected by harness settings.""" + + settings = common_utils.settings_payload(payload) + configured = settings.get("config_file") + if not isinstance(configured, str) or not configured.strip(): + raise lifecycle.LifecycleError( + "nat_config_file_required", + "harness.settings.config_file must be a non-empty string", + ) + + base_dir = Path(common_utils.base_dir(payload)).resolve() + candidate = Path(configured.strip()) + if not candidate.is_absolute(): + candidate = base_dir / candidate + + try: + config_file = candidate.resolve(strict=True) + except OSError as error: + raise lifecycle.LifecycleError( + "nat_config_file_not_found", + "NAT config file does not exist", + metadata={"config_file": configured}, + ) from error + + try: + config_file.relative_to(base_dir) + except ValueError as error: + raise lifecycle.LifecycleError( + "nat_config_file_outside_base_dir", + "NAT config file must resolve within the agent config directory", + metadata={"config_file": configured}, + ) from error + + if not config_file.is_file(): + raise lifecycle.LifecycleError( + "nat_config_file_not_file", + "NAT config file must be a regular file", + metadata={"config_file": configured}, + ) + return config_file + + +def validate_supported_fabric_config(payload: dict[str, Any]) -> None: + """Reject normalized config surfaces this config-file adapter does not map.""" + + config = common_utils.fabric_config(payload) + unsupported = [field for field in ("models", "mcp", "skills", "tools", "telemetry", "relay") if config.get(field)] + if unsupported: + fields = ", ".join(sorted(unsupported)) + raise lifecycle.LifecycleError( + "nat_unsupported_fabric_config", + f"NAT adapter does not map normalized Fabric config fields: {fields}; " + "configure them in the NAT config file", + metadata={"fields": sorted(unsupported)}, + ) + + +def _runtime_id(payload: dict[str, Any]) -> str: + try: + return common_utils.runtime_id(payload) + except ValueError as error: + raise lifecycle.LifecycleError( + "nat_invalid_runtime_context", + "NAT lifecycle payload is missing a runtime ID", + ) from error + + +def _session_kwargs(request: dict[str, Any]) -> dict[str, str]: + context = request.get("context") or {} + if not isinstance(context, dict): + raise ValueError("request.context must be a mapping") + + values = { + "user_id": context.get("user_id"), + "conversation_id": context.get("conversation_id"), + "user_message_id": context.get("user_message_id") or request.get("request_id"), + } + session_kwargs: dict[str, str] = {} + for name, value in values.items(): + if value is None: + continue + if not isinstance(value, str) or not value: + raise ValueError(f"request context {name} must be a non-empty string") + session_kwargs[name] = value + return session_kwargs + + +def _success_output(response: Any) -> dict[str, Any]: + return { + "harness": HARNESS, + "adapter": "python", + "mode": MODE, + "response": response, + "completed": True, + "failed": False, + "error": None, + } + + +def _failure_output(code: str, message: str) -> dict[str, Any]: + return { + "harness": HARNESS, + "adapter": "python", + "mode": MODE, + "response": None, + "completed": False, + "failed": True, + "error": { + "code": code, + "message": message, + "retryable": False, + }, + } + + +async def _close_after_failed_start(stack: AsyncExitStack) -> None: + try: + await stack.aclose() + except asyncio.CancelledError: + raise + except Exception: + LOGGER.exception("NAT workflow cleanup failed after start error") + + +class NatRuntime: + """One entered NAT workflow and session manager owned by a Fabric runtime.""" + + def __init__(self) -> None: + self._runtime_id: str | None = None + self._sessions: Any = None + self._exit_stack: AsyncExitStack | None = None + + async def start(self, payload: dict[str, Any]) -> None: + if self._exit_stack is not None: + raise lifecycle.LifecycleError( + "nat_runtime_already_started", + "NAT runtime is already started", + ) + + runtime_id = _runtime_id(payload) + validate_supported_fabric_config(payload) + config_file = resolve_config_file(payload) + stack = AsyncExitStack() + + try: + from nat.runtime.loader import load_workflow + + sessions = await stack.enter_async_context(load_workflow(config_file)) + except asyncio.CancelledError: + await _close_after_failed_start(stack) + raise + except Exception as error: + LOGGER.exception("NAT workflow failed to load") + await _close_after_failed_start(stack) + raise lifecycle.LifecycleError( + "nat_workflow_start_failed", + "NAT workflow failed to load; inspect adapter stderr for details", + metadata={"config_file": str(config_file)}, + ) from error + + self._runtime_id = runtime_id + self._sessions = sessions + self._exit_stack = stack + + async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: + if self._sessions is None or self._runtime_id is None: + raise lifecycle.LifecycleError( + "nat_runtime_not_started", + "NAT runtime is not started", + ) + if _runtime_id(payload) != self._runtime_id: + raise lifecycle.LifecycleError( + "nat_runtime_mismatch", + "NAT invocation does not match the active runtime", + ) + + request = common_utils.request_payload(payload) + try: + from nat.data_models.runtime_enum import RuntimeTypeEnum + + async with self._sessions.session(**_session_kwargs(request)) as session: + async with session.run( + request.get("input", ""), + runtime_type=RuntimeTypeEnum.RUN_OR_SERVE, + ) as runner: + result = await runner.result() + except asyncio.CancelledError: + raise + except Exception: + LOGGER.exception("NAT workflow invocation failed") + return _failure_output( + "nat_workflow_invoke_failed", + "NAT workflow invocation failed; inspect adapter stderr for details", + ) + + try: + response = to_jsonable_python(result, serialize_unknown=False) + except (TypeError, ValueError): + LOGGER.exception("NAT workflow returned a non-JSON result") + return _failure_output( + "nat_result_not_json_serializable", + "NAT workflow returned a result that cannot be represented as JSON", + ) + return _success_output(response) + + async def stop(self) -> None: + stack = self._exit_stack + self._runtime_id = None + self._sessions = None + self._exit_stack = None + + if stack is None: + return + try: + await stack.aclose() + except asyncio.CancelledError: + raise + except Exception as error: + LOGGER.exception("NAT workflow failed to stop cleanly") + raise lifecycle.LifecycleError( + "nat_runtime_stop_failed", + "NAT runtime failed to stop cleanly", + ) from error + + +if __name__ == "__main__": + main() diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/adapters/nat/fabric-adapter.json b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/adapters/nat/fabric-adapter.json new file mode 100644 index 0000000000..1a59553b95 --- /dev/null +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/adapters/nat/fabric-adapter.json @@ -0,0 +1,18 @@ +{ + "contract_version": "fabric.adapter/v1alpha1", + "adapter_id": "nvidia.nemo.platform.nat", + "harness": "nat", + "adapter_kind": "python", + "runner": { + "module": "nemo_agents_plugin.fabric.adapters.nat.adapter" + }, + "config": { + "accepts": [] + }, + "capabilities": { + "cancellation": false, + "service": false, + "streaming": false, + "updates": false + } +} 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 17f3ecd841..fc53907b77 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py @@ -16,6 +16,7 @@ "codex": "nvidia.fabric.codex", "deepagents": "nvidia.fabric.langchain.deepagents", "hermes": "nvidia.fabric.hermes", + "nat": "nvidia.nemo.platform.nat", } @@ -26,7 +27,13 @@ class FabricTranslationError(ValueError): 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) + model: ModelConfig | None = None + models: dict[str, fabric.ModelConfig | dict[str, Any]] = {} + if harness.kind == "nat": + _validate_nat_harness(config, selected_harness_name, harness) + else: + model = _resolve_model(config, selected_harness_name, harness) + models["default"] = fabric.ModelConfig(**_model_payload(model)) fabric_config = fabric.FabricConfig( metadata=fabric.MetadataConfig(name=config.name, description=config.description or None), @@ -35,9 +42,7 @@ def translate_agent_config(config: AgentConfig, harness_name: str | None = None) resolution="preinstalled", settings=harness.settings, ), - models={ - "default": fabric.ModelConfig(**_model_payload(model)), - }, + models=models, environment=fabric.EnvironmentConfig( provider=config.environment.provider, workspace=config.environment.workspace, @@ -46,7 +51,8 @@ def translate_agent_config(config: AgentConfig, harness_name: str | None = None) ), ) - _apply_telemetry(fabric_config, config, model) + if model is not None: + _apply_telemetry(fabric_config, config, model) return fabric_config @@ -81,6 +87,27 @@ def _resolve_model(config: AgentConfig, harness_name: str, harness: HarnessConfi return model +def _validate_nat_harness(config: AgentConfig, harness_name: str, harness: HarnessConfig) -> None: + if harness.model is not None or config.models: + raise FabricTranslationError( + f"NAT harness {harness_name!r} cannot define Platform models; configure models in the NAT config file." + ) + + if config.skills: + raise FabricTranslationError( + f"NAT harness {harness_name!r} does not map Platform skills; configure skills in the NAT config file." + ) + + config_file = harness.settings.get("config_file") + if not isinstance(config_file, str) or not config_file.strip(): + raise FabricTranslationError(f"NAT harness {harness_name!r} requires a non-empty harness.settings.config_file.") + + if config.telemetry.enabled: + raise FabricTranslationError( + f"NAT harness {harness_name!r} does not map Platform telemetry; configure telemetry in the NAT config file." + ) + + def _model_payload(model: ModelConfig) -> dict[str, Any]: return model.model_dump(exclude_none=True) diff --git a/plugins/nemo-agents/tests/integration/test_fabric_nat_adapter.py b/plugins/nemo-agents/tests/integration/test_fabric_nat_adapter.py new file mode 100644 index 0000000000..5391e0d12f --- /dev/null +++ b/plugins/nemo-agents/tests/integration/test_fabric_nat_adapter.py @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Installed-discovery integration coverage for the NAT Fabric adapter.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from nemo_agents_plugin.agent_config import AgentConfig +from nemo_agents_plugin.fabric.invocation import invoke_agent_config_once + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_platform_invokes_installed_nat_adapter_without_local_descriptor(tmp_path: Path) -> None: + (tmp_path / "workflow.yml").write_text( + "workflow:\n _type: current_timezone\n", + encoding="utf-8", + ) + agent_config = AgentConfig.model_validate( + { + "config_format": "nemo-agents-spec-v1", + "name": "nat-installed-discovery", + "default_harness": "nat", + "harnesses": { + "nat": { + "kind": "nat", + "settings": { + "config_file": "./workflow.yml", + }, + } + }, + "environment": { + "workspace": "./workspace", + "artifacts": "./artifacts", + }, + "telemetry": { + "enabled": False, + }, + } + ) + + results = await invoke_agent_config_once(agent_config, ["ignored"], base_dir=tmp_path) + + assert not (tmp_path / "adapters").exists() + assert len(results) == 1 + result = results[0] + assert result.status == "succeeded" + assert isinstance(result.response, str) + assert result.response.startswith("The time zone is ") + assert result.output["mode"] == "nat_workflow" + assert result.output["completed"] is True + assert result.metadata["adapter_runner"] == "persistent_local_host" diff --git a/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter.py b/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter.py new file mode 100644 index 0000000000..5fdcd254c7 --- /dev/null +++ b/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter.py @@ -0,0 +1,275 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for the Platform-owned NAT Fabric adapter.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +from nemo_agents_plugin.fabric.adapters.nat import adapter as nat_adapter +from nemo_fabric_adapters.common import lifecycle + + +class _FakeRunner: + def __init__(self, result: Any = "done", error: Exception | None = None) -> None: + self.result_value = result + self.error = error + + async def __aenter__(self) -> "_FakeRunner": + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: object, + ) -> None: + return None + + async def result(self) -> Any: + if self.error is not None: + raise self.error + return self.result_value + + +class _FakeSession: + def __init__(self, sessions: "_FakeSessions") -> None: + self.sessions = sessions + + async def __aenter__(self) -> "_FakeSession": + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: object, + ) -> None: + return None + + def run(self, value: Any, *, runtime_type: Any) -> _FakeRunner: + self.sessions.run_calls.append({"input": value, "runtime_type": runtime_type}) + return self.sessions.runner + + +class _FakeSessions: + def __init__(self, runner: _FakeRunner | None = None) -> None: + self.runner = runner or _FakeRunner() + self.session_calls: list[dict[str, str]] = [] + self.run_calls: list[dict[str, Any]] = [] + + def session(self, **kwargs: str) -> _FakeSession: + self.session_calls.append(kwargs) + return _FakeSession(self) + + +class _FakeWorkflowContext: + def __init__(self, sessions: _FakeSessions, enter_error: Exception | None = None) -> None: + self.sessions = sessions + self.enter_error = enter_error + self.entered = False + self.exited = False + + async def __aenter__(self) -> _FakeSessions: + self.entered = True + if self.enter_error is not None: + raise self.enter_error + return self.sessions + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: object, + ) -> None: + self.exited = True + + +def _start_payload(base_dir: Path, *, runtime_id: str = "runtime-1") -> dict[str, Any]: + return { + "base_dir": str(base_dir), + "config": { + "harness": { + "settings": { + "config_file": "./workflow.yml", + } + } + }, + "runtime_context": { + "runtime_id": runtime_id, + }, + } + + +def _invoke_payload(*, runtime_id: str = "runtime-1") -> dict[str, Any]: + return { + "runtime_context": { + "runtime_id": runtime_id, + }, + "request": { + "input": "hello", + "request_id": "request-1", + "context": { + "user_id": "user-1", + "conversation_id": "conversation-1", + }, + }, + } + + +@pytest.fixture() +def nat_config(tmp_path: Path) -> Path: + config = tmp_path / "workflow.yml" + config.write_text("workflow:\n _type: current_timezone\n", encoding="utf-8") + return config + + +@pytest.mark.asyncio +async def test_runtime_owns_nat_workflow_across_invoke( + nat_config: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + sessions = _FakeSessions(runner=_FakeRunner(result={"answer": "hello"})) + workflow = _FakeWorkflowContext(sessions) + monkeypatch.setattr("nat.runtime.loader.load_workflow", lambda path: workflow) + runtime = nat_adapter.NatRuntime() + + await runtime.start(_start_payload(nat_config.parent)) + output = await runtime.invoke(_invoke_payload()) + await runtime.stop() + + from nat.data_models.runtime_enum import RuntimeTypeEnum + + assert workflow.entered is True + assert workflow.exited is True + assert sessions.session_calls == [ + { + "user_id": "user-1", + "conversation_id": "conversation-1", + "user_message_id": "request-1", + } + ] + assert sessions.run_calls == [ + { + "input": "hello", + "runtime_type": RuntimeTypeEnum.RUN_OR_SERVE, + } + ] + assert output == { + "harness": "nat", + "adapter": "python", + "mode": "nat_workflow", + "response": {"answer": "hello"}, + "completed": True, + "failed": False, + "error": None, + } + + +@pytest.mark.asyncio +async def test_invoke_failure_is_normalized_without_exception_details( + nat_config: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + sessions = _FakeSessions(runner=_FakeRunner(error=RuntimeError("credential secret"))) + workflow = _FakeWorkflowContext(sessions) + monkeypatch.setattr("nat.runtime.loader.load_workflow", lambda path: workflow) + runtime = nat_adapter.NatRuntime() + + await runtime.start(_start_payload(nat_config.parent)) + output = await runtime.invoke(_invoke_payload()) + await runtime.stop() + + assert output["failed"] is True + assert output["response"] is None + assert output["error"] == { + "code": "nat_workflow_invoke_failed", + "message": "NAT workflow invocation failed; inspect adapter stderr for details", + "retryable": False, + } + assert "credential secret" not in str(output) + + +@pytest.mark.asyncio +async def test_start_failure_is_actionable_and_stop_remains_safe( + nat_config: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow = _FakeWorkflowContext(_FakeSessions(), enter_error=RuntimeError("invalid workflow")) + monkeypatch.setattr("nat.runtime.loader.load_workflow", lambda path: workflow) + runtime = nat_adapter.NatRuntime() + + with pytest.raises(lifecycle.LifecycleError) as error_info: + await runtime.start(_start_payload(nat_config.parent)) + + assert error_info.value.code == "nat_workflow_start_failed" + assert "invalid workflow" not in error_info.value.message + await runtime.stop() + + +@pytest.mark.asyncio +async def test_config_file_must_stay_within_agent_directory(tmp_path: Path) -> None: + base_dir = tmp_path / "agent" + base_dir.mkdir() + outside = tmp_path / "workflow.yml" + outside.write_text("workflow: {}\n", encoding="utf-8") + payload = _start_payload(base_dir) + payload["config"]["harness"]["settings"]["config_file"] = str(outside) + + with pytest.raises(lifecycle.LifecycleError) as error_info: + await nat_adapter.NatRuntime().start(payload) + + assert error_info.value.code == "nat_config_file_outside_base_dir" + + +@pytest.mark.asyncio +async def test_normalized_fabric_fields_are_rejected(nat_config: Path) -> None: + payload = _start_payload(nat_config.parent) + payload["config"]["models"] = { + "default": { + "provider": "nvidia", + "model": "example-model", + } + } + + with pytest.raises(lifecycle.LifecycleError) as error_info: + await nat_adapter.NatRuntime().start(payload) + + assert error_info.value.code == "nat_unsupported_fabric_config" + assert error_info.value.metadata == {"fields": ["models"]} + + +@pytest.mark.asyncio +async def test_runtime_rejects_mismatched_invocation( + nat_config: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow = _FakeWorkflowContext(_FakeSessions()) + monkeypatch.setattr("nat.runtime.loader.load_workflow", lambda path: workflow) + runtime = nat_adapter.NatRuntime() + await runtime.start(_start_payload(nat_config.parent)) + + with pytest.raises(lifecycle.LifecycleError) as error_info: + await runtime.invoke(_invoke_payload(runtime_id="runtime-2")) + + assert error_info.value.code == "nat_runtime_mismatch" + await runtime.stop() + + +@pytest.mark.asyncio +async def test_stop_is_idempotent( + nat_config: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow = _FakeWorkflowContext(_FakeSessions()) + monkeypatch.setattr("nat.runtime.loader.load_workflow", lambda path: workflow) + runtime = nat_adapter.NatRuntime() + await runtime.start(_start_payload(nat_config.parent)) + + await runtime.stop() + await runtime.stop() + + assert workflow.exited is True diff --git a/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter_packaging.py b/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter_packaging.py new file mode 100644 index 0000000000..dd7fa41ca9 --- /dev/null +++ b/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter_packaging.py @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Packaging assertions for the Platform-owned NAT Fabric adapter.""" + +from __future__ import annotations + +import json +import tomllib +from pathlib import Path + +_PLUGIN_ROOT = Path(__file__).resolve().parents[2] +_DESCRIPTOR = _PLUGIN_ROOT / "src" / "nemo_agents_plugin" / "fabric" / "adapters" / "nat" / "fabric-adapter.json" + + +def test_nat_adapter_descriptor_is_narrow_and_platform_owned() -> None: + descriptor = json.loads(_DESCRIPTOR.read_text(encoding="utf-8")) + + assert descriptor == { + "contract_version": "fabric.adapter/v1alpha1", + "adapter_id": "nvidia.nemo.platform.nat", + "harness": "nat", + "adapter_kind": "python", + "runner": { + "module": "nemo_agents_plugin.fabric.adapters.nat.adapter", + }, + "config": { + "accepts": [], + }, + "capabilities": { + "cancellation": False, + "service": False, + "streaming": False, + "updates": False, + }, + } + + +def test_nat_adapter_descriptor_is_installed_as_shared_data() -> None: + pyproject = tomllib.loads((_PLUGIN_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + shared_data = pyproject["tool"]["hatch"]["build"]["targets"]["wheel"]["shared-data"] + + assert shared_data == { + "src/nemo_agents_plugin/fabric/adapters/nat/fabric-adapter.json": ( + "share/nemo-fabric/adapters/nemo-platform-nat/fabric-adapter.json" + ) + } diff --git a/plugins/nemo-agents/tests/unit/test_fabric_translator.py b/plugins/nemo-agents/tests/unit/test_fabric_translator.py index c9f3883bac..3ca826f118 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_translator.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_translator.py @@ -118,6 +118,7 @@ def test_selected_harness_uses_default_model(self) -> None: ("codex", "nvidia.fabric.codex"), ("deepagents", "nvidia.fabric.langchain.deepagents"), ("hermes", "nvidia.fabric.hermes"), + ("nat", "nvidia.nemo.platform.nat"), ], ) def test_supported_harness_kinds_translate_to_adapter_ids( @@ -127,13 +128,115 @@ def test_supported_harness_kinds_translate_to_adapter_ids( ) -> None: payload = _example_yaml_config() payload["default_harness"] = "selected" - payload["harnesses"] = {"selected": {"kind": kind}} + payload["harnesses"] = { + "selected": { + "kind": kind, + "settings": {"config_file": "./workflow.yml"} if kind == "nat" else {}, + } + } + if kind == "nat": + payload["models"] = {} config = AgentConfig.model_validate(payload) fabric_config = translate_agent_config(config) assert fabric_config.harness.adapter_id == adapter_id + def test_nat_harness_uses_native_config_without_platform_model(self) -> None: + payload = _example_yaml_config() + payload["default_harness"] = "nat" + payload["harnesses"] = { + "nat": { + "kind": "nat", + "settings": {"config_file": "./workflow.yml"}, + } + } + payload["models"] = {} + config = AgentConfig.model_validate(payload) + + fabric_config = translate_agent_config(config) + + assert fabric_config.harness.adapter_id == "nvidia.nemo.platform.nat" + assert fabric_config.harness.settings == {"config_file": "./workflow.yml"} + assert fabric_config.models == {} + assert fabric_config.telemetry is None + assert fabric_config.relay is None + + def test_nat_harness_requires_config_file(self) -> None: + payload = _example_yaml_config() + payload["default_harness"] = "nat" + payload["harnesses"] = {"nat": {"kind": "nat"}} + payload["models"] = {} + config = AgentConfig.model_validate(payload) + + with pytest.raises(FabricTranslationError, match="requires a non-empty harness.settings.config_file"): + translate_agent_config(config) + + def test_nat_harness_rejects_inline_platform_model(self) -> None: + payload = _example_yaml_config() + payload["default_harness"] = "nat" + payload["harnesses"] = { + "nat": { + "kind": "nat", + "model": { + "provider": "nvidia", + "model": "nvidia/example-model", + }, + "settings": {"config_file": "./workflow.yml"}, + } + } + payload["models"] = {} + config = AgentConfig.model_validate(payload) + + with pytest.raises(FabricTranslationError, match="cannot define Platform models"): + translate_agent_config(config) + + def test_nat_harness_rejects_top_level_platform_models(self) -> None: + payload = _example_yaml_config() + payload["default_harness"] = "nat" + payload["harnesses"] = { + "nat": { + "kind": "nat", + "settings": {"config_file": "./workflow.yml"}, + } + } + config = AgentConfig.model_validate(payload) + + with pytest.raises(FabricTranslationError, match="cannot define Platform models"): + translate_agent_config(config) + + def test_nat_harness_rejects_platform_skills(self) -> None: + payload = _example_yaml_config() + payload["default_harness"] = "nat" + payload["harnesses"] = { + "nat": { + "kind": "nat", + "settings": {"config_file": "./workflow.yml"}, + } + } + payload["models"] = {} + payload["skills"] = [{"path": "./skills/example"}] + config = AgentConfig.model_validate(payload) + + with pytest.raises(FabricTranslationError, match="does not map Platform skills"): + translate_agent_config(config) + + def test_nat_harness_rejects_platform_telemetry(self) -> None: + payload = _example_yaml_config() + payload["default_harness"] = "nat" + payload["harnesses"] = { + "nat": { + "kind": "nat", + "settings": {"config_file": "./workflow.yml"}, + } + } + payload["models"] = {} + payload["telemetry"]["enabled"] = True + config = AgentConfig.model_validate(payload) + + with pytest.raises(FabricTranslationError, match="does not map Platform telemetry"): + translate_agent_config(config) + def test_unknown_selected_harness_rejected(self) -> None: config = AgentConfig.model_validate(_example_yaml_config())