From 6399d98f29bf75211641a175c2b4c0cba7ef168f Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Thu, 23 Jul 2026 23:59:25 -0700 Subject: [PATCH 01/10] feat(nemo-agents): add NAT Fabric adapter Signed-off-by: Ajay Thorve --- .../email-phishing-analyzer/fabric/.gitignore | 2 + .../email-phishing-analyzer/fabric/README.md | 15 + .../email-phishing-analyzer/fabric/agent.yaml | 18 ++ .../fabric/workflow.yml | 23 ++ .../examples/nemo-agent-config/README.md | 2 +- plugins/nemo-agents/pyproject.toml | 7 + .../fabric/adapters/nat/adapter.py | 260 +++++++++++++++++ .../fabric/adapters/nat/fabric-adapter.json | 18 ++ .../nemo_agents_plugin/fabric/translator.py | 37 ++- .../integration/test_fabric_nat_adapter.py | 55 ++++ .../tests/unit/test_fabric_nat_adapter.py | 275 ++++++++++++++++++ .../unit/test_fabric_nat_adapter_packaging.py | 47 +++ .../tests/unit/test_fabric_translator.py | 105 ++++++- uv.lock | 2 + 14 files changed, 859 insertions(+), 7 deletions(-) create mode 100644 plugins/nemo-agents/examples/email-phishing-analyzer/fabric/.gitignore create mode 100644 plugins/nemo-agents/examples/email-phishing-analyzer/fabric/README.md create mode 100644 plugins/nemo-agents/examples/email-phishing-analyzer/fabric/agent.yaml create mode 100644 plugins/nemo-agents/examples/email-phishing-analyzer/fabric/workflow.yml create mode 100644 plugins/nemo-agents/src/nemo_agents_plugin/fabric/adapters/nat/adapter.py create mode 100644 plugins/nemo-agents/src/nemo_agents_plugin/fabric/adapters/nat/fabric-adapter.json create mode 100644 plugins/nemo-agents/tests/integration/test_fabric_nat_adapter.py create mode 100644 plugins/nemo-agents/tests/unit/test_fabric_nat_adapter.py create mode 100644 plugins/nemo-agents/tests/unit/test_fabric_nat_adapter_packaging.py 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..07b89ddd33 --- /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 +uvx uv@0.9.14 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 f15d904b05..9754e787fe 100644 --- a/plugins/nemo-agents/examples/nemo-agent-config/README.md +++ b/plugins/nemo-agents/examples/nemo-agent-config/README.md @@ -8,7 +8,7 @@ force other Fabric consumers onto the `0.1.0a20260724` SDK API before they migra Install them explicitly before local Fabric smoke tests: ```bash -uv pip install -e "plugins/nemo-agents[fabric]" +uvx uv@0.9.14 pip install -e "plugins/nemo-agents[fabric]" ``` Top-level `skills`, `mcp`, and `tools` are Platform-owned shared fields that diff --git a/plugins/nemo-agents/pyproject.toml b/plugins/nemo-agents/pyproject.toml index e4ab5c05c9..9c288a9724 100644 --- a/plugins/nemo-agents/pyproject.toml +++ b/plugins/nemo-agents/pyproject.toml @@ -65,6 +65,7 @@ fabric = [ # 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.0a20260724,<0.2.0", + "nemo-fabric-adapters-common>=0.1.0a20260724,<0.2.0", "nemo-fabric-adapters-codex>=0.1.0a20260724,<0.2.0", "nemo-fabric-adapters-hermes>=0.1.0a20260724,<0.2.0; python_version < '3.14'", ] @@ -94,6 +95,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 +109,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 0920db83b8..6660b2fcd0 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,8 +27,14 @@ 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) _validate_untranslated_shared_fields(config) + 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), @@ -36,9 +43,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, @@ -50,7 +55,8 @@ def translate_agent_config(config: AgentConfig, harness_name: str | None = None) tools=_tools_config(config), ) - _apply_telemetry(fabric_config, config, model) + if model is not None: + _apply_telemetry(fabric_config, config, model) return fabric_config @@ -85,6 +91,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 4cb86fb63b..3717b71bc8 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_translator.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_translator.py @@ -149,6 +149,7 @@ def test_top_level_prompts_rejected_until_shared_prompt_contract_exists(self) -> ("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( @@ -158,13 +159,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()) diff --git a/uv.lock b/uv.lock index bfeb444e86..df71a2a805 100644 --- a/uv.lock +++ b/uv.lock @@ -3703,6 +3703,7 @@ container = [ ] fabric = [ { name = "nemo-fabric", extra = ["runtime"], marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nemo-fabric-adapters-common", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-fabric-adapters-codex", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-fabric-adapters-hermes", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] @@ -3729,6 +3730,7 @@ requires-dist = [ { name = "nemo-agents-example-email-phishing", editable = "plugins/nemo-agents/examples/email-phishing-analyzer" }, { name = "nemo-deployments-plugin", editable = "plugins/nemo-deployments" }, { name = "nemo-fabric", extras = ["runtime"], marker = "extra == 'fabric'", specifier = ">=0.1.0a20260724,<0.2.0" }, + { name = "nemo-fabric-adapters-common", marker = "extra == 'fabric'", specifier = ">=0.1.0a20260724,<0.2.0" }, { name = "nemo-fabric-adapters-codex", marker = "extra == 'fabric'", specifier = ">=0.1.0a20260724,<0.2.0" }, { name = "nemo-fabric-adapters-hermes", marker = "python_full_version < '3.14' and extra == 'fabric'", specifier = ">=0.1.0a20260724,<0.2.0" }, { name = "nemo-platform", editable = "packages/nemo_platform" }, From 59177b5eb4c01f2ba8f275ddb686ae99a44e147f Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 24 Jul 2026 00:20:23 -0700 Subject: [PATCH 02/10] docs(nemo-agents): use project uv command Signed-off-by: Ajay Thorve --- .../examples/email-phishing-analyzer/fabric/README.md | 2 +- plugins/nemo-agents/examples/nemo-agent-config/README.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/nemo-agents/examples/email-phishing-analyzer/fabric/README.md b/plugins/nemo-agents/examples/email-phishing-analyzer/fabric/README.md index 07b89ddd33..6928d6dfdb 100644 --- a/plugins/nemo-agents/examples/email-phishing-analyzer/fabric/README.md +++ b/plugins/nemo-agents/examples/email-phishing-analyzer/fabric/README.md @@ -6,7 +6,7 @@ the Platform-owned NAT Fabric adapter. From the repository root: ```bash -uvx uv@0.9.14 pip install -e "plugins/nemo-agents[fabric]" +uv pip install -e "plugins/nemo-agents[fabric]" export NVIDIA_API_KEY="" nemo agents invoke \ diff --git a/plugins/nemo-agents/examples/nemo-agent-config/README.md b/plugins/nemo-agents/examples/nemo-agent-config/README.md index 9754e787fe..0ba832827c 100644 --- a/plugins/nemo-agents/examples/nemo-agent-config/README.md +++ b/plugins/nemo-agents/examples/nemo-agent-config/README.md @@ -8,7 +8,7 @@ force other Fabric consumers onto the `0.1.0a20260724` SDK API before they migra Install them explicitly before local Fabric smoke tests: ```bash -uvx uv@0.9.14 pip install -e "plugins/nemo-agents[fabric]" +uv pip install -e "plugins/nemo-agents[fabric]" ``` Top-level `skills`, `mcp`, and `tools` are Platform-owned shared fields that @@ -34,8 +34,8 @@ 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.0a20260724,<0.2.0" \ "hermes-agent==0.19.0" From d82f471ea6b34456e4243d594a389025ebb802f9 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 24 Jul 2026 00:46:45 -0700 Subject: [PATCH 03/10] docs(nemo-agents): add NAT calculator example Signed-off-by: Ajay Thorve --- .../calculator-agent/fabric/.gitignore | 2 ++ .../calculator-agent/fabric/agent.yaml | 18 ++++++++++ .../calculator-agent/fabric/workflow.yml | 26 ++++++++++++++ .../examples/nemo-agent-config/README.md | 36 +++++++++++++++++-- 4 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 plugins/nemo-agents/examples/calculator-agent/fabric/.gitignore create mode 100644 plugins/nemo-agents/examples/calculator-agent/fabric/agent.yaml create mode 100644 plugins/nemo-agents/examples/calculator-agent/fabric/workflow.yml 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/nemo-agent-config/README.md b/plugins/nemo-agents/examples/nemo-agent-config/README.md index 0ba832827c..7db96d054b 100644 --- a/plugins/nemo-agents/examples/nemo-agent-config/README.md +++ b/plugins/nemo-agents/examples/nemo-agent-config/README.md @@ -1,7 +1,7 @@ # 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.0a20260724` SDK API before they migrate. @@ -15,6 +15,38 @@ Top-level `skills`, `mcp`, and `tools` are Platform-owned shared fields that translate into `FabricConfig`. Prompt settings are harness-specific for now and should be configured under `harnesses..settings`. +## 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: From 182631505f303e300d06f237f9285981f971b6b6 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 24 Jul 2026 09:05:13 -0700 Subject: [PATCH 04/10] fix(nemo-agents): satisfy NAT adapter type checks Signed-off-by: Ajay Thorve --- .../calculator-agent/src/calculator_agent/register.py | 8 ++++---- .../src/nemo_agents_plugin/fabric/adapters/nat/adapter.py | 4 ++-- plugins/nemo-agents/tests/unit/test_fabric_nat_adapter.py | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/nemo-agents/examples/calculator-agent/src/calculator_agent/register.py b/plugins/nemo-agents/examples/calculator-agent/src/calculator_agent/register.py index 1435c56c76..d7cfdf0d56 100644 --- a/plugins/nemo-agents/examples/calculator-agent/src/calculator_agent/register.py +++ b/plugins/nemo-agents/examples/calculator-agent/src/calculator_agent/register.py @@ -8,10 +8,10 @@ from collections.abc import AsyncGenerator -from nat.builder.builder import Builder # type: ignore -from nat.builder.function import FunctionGroup # type: ignore -from nat.cli.register_workflow import register_function_group # type: ignore -from nat.data_models.function import FunctionGroupBaseConfig # type: ignore +from nat.builder.builder import Builder +from nat.builder.function import FunctionGroup +from nat.cli.register_workflow import register_function_group +from nat.data_models.function import FunctionGroupBaseConfig from pydantic import Field 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 index d370f84e56..1e7cd34ba2 100644 --- 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 @@ -15,8 +15,8 @@ from pathlib import Path from typing import Any -import nemo_fabric_adapters.common.utils as common_utils -from nemo_fabric_adapters.common import lifecycle +import nemo_fabric_adapters.common.utils as common_utils # ty: ignore[unresolved-import] +from nemo_fabric_adapters.common import lifecycle # ty: ignore[unresolved-import] from pydantic_core import to_jsonable_python LOGGER = logging.getLogger(__name__) diff --git a/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter.py b/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter.py index 5fdcd254c7..d3c2d773ff 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter.py @@ -10,7 +10,7 @@ import pytest from nemo_agents_plugin.fabric.adapters.nat import adapter as nat_adapter -from nemo_fabric_adapters.common import lifecycle +from nemo_fabric_adapters.common import lifecycle # ty: ignore[unresolved-import] class _FakeRunner: From 12c124a9675952a8fc982f76e3eff87c060a71d9 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 24 Jul 2026 09:59:04 -0700 Subject: [PATCH 05/10] docs(nemo-agents): centralize Fabric examples Signed-off-by: Ajay Thorve --- .../examples/calculator-agent/fabric/.gitignore | 2 -- .../email-phishing-analyzer/fabric/.gitignore | 2 -- .../email-phishing-analyzer/fabric/README.md | 15 --------------- .../examples/nemo-agent-config/README.md | 13 ++++++++----- .../nat-calculator}/agent.yaml | 0 .../nat-calculator}/workflow.yml | 0 .../nat-email-phishing}/agent.yaml | 0 .../nat-email-phishing}/workflow.yml | 0 .../tests/unit/test_fabric_translator.py | 13 +++++++++++++ 9 files changed, 21 insertions(+), 24 deletions(-) delete mode 100644 plugins/nemo-agents/examples/calculator-agent/fabric/.gitignore delete mode 100644 plugins/nemo-agents/examples/email-phishing-analyzer/fabric/.gitignore delete mode 100644 plugins/nemo-agents/examples/email-phishing-analyzer/fabric/README.md rename plugins/nemo-agents/examples/{calculator-agent/fabric => nemo-agent-config/nat-calculator}/agent.yaml (100%) rename plugins/nemo-agents/examples/{calculator-agent/fabric => nemo-agent-config/nat-calculator}/workflow.yml (100%) rename plugins/nemo-agents/examples/{email-phishing-analyzer/fabric => nemo-agent-config/nat-email-phishing}/agent.yaml (100%) rename plugins/nemo-agents/examples/{email-phishing-analyzer/fabric => nemo-agent-config/nat-email-phishing}/workflow.yml (100%) diff --git a/plugins/nemo-agents/examples/calculator-agent/fabric/.gitignore b/plugins/nemo-agents/examples/calculator-agent/fabric/.gitignore deleted file mode 100644 index 11530f2cbf..0000000000 --- a/plugins/nemo-agents/examples/calculator-agent/fabric/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -artifacts/ -workspace/ diff --git a/plugins/nemo-agents/examples/email-phishing-analyzer/fabric/.gitignore b/plugins/nemo-agents/examples/email-phishing-analyzer/fabric/.gitignore deleted file mode 100644 index 11530f2cbf..0000000000 --- a/plugins/nemo-agents/examples/email-phishing-analyzer/fabric/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 6928d6dfdb..0000000000 --- a/plugins/nemo-agents/examples/email-phishing-analyzer/fabric/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# 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/nemo-agent-config/README.md b/plugins/nemo-agents/examples/nemo-agent-config/README.md index 7db96d054b..c04f301c45 100644 --- a/plugins/nemo-agents/examples/nemo-agent-config/README.md +++ b/plugins/nemo-agents/examples/nemo-agent-config/README.md @@ -1,4 +1,4 @@ -# Fabric-Backed Agent Config +# Fabric-Backed Agent Examples These Platform-owned configs invoke Codex, Hermes, or NAT through NeMo Fabric. Run the commands below from the repository root. @@ -18,8 +18,11 @@ should be configured under `harnesses..settings`. ## 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. +NAT components. Each integration has its own `agent.yaml` because calculator +and email phishing are distinct NAT workflows, not alternative harnesses for +the Codex and Hermes test agent. In each directory, `workflow.yml` remains the +NAT source of truth and `agent.yaml` selects the Platform-owned NAT Fabric +adapter. Run the calculator workflow: @@ -27,7 +30,7 @@ Run the calculator workflow: export NVIDIA_API_KEY="" nemo agents invoke \ - --agent-config plugins/nemo-agents/examples/calculator-agent/fabric/agent.yaml \ + --agent-config plugins/nemo-agents/examples/nemo-agent-config/nat-calculator/agent.yaml \ --input "What is 12 multiplied by 8?" ``` @@ -37,7 +40,7 @@ Run the email phishing analyzer: export NVIDIA_API_KEY="" nemo agents invoke \ - --agent-config plugins/nemo-agents/examples/email-phishing-analyzer/fabric/agent.yaml \ + --agent-config plugins/nemo-agents/examples/nemo-agent-config/nat-email-phishing/agent.yaml \ --input "Subject: Verify your account. Send your password immediately." ``` diff --git a/plugins/nemo-agents/examples/calculator-agent/fabric/agent.yaml b/plugins/nemo-agents/examples/nemo-agent-config/nat-calculator/agent.yaml similarity index 100% rename from plugins/nemo-agents/examples/calculator-agent/fabric/agent.yaml rename to plugins/nemo-agents/examples/nemo-agent-config/nat-calculator/agent.yaml diff --git a/plugins/nemo-agents/examples/calculator-agent/fabric/workflow.yml b/plugins/nemo-agents/examples/nemo-agent-config/nat-calculator/workflow.yml similarity index 100% rename from plugins/nemo-agents/examples/calculator-agent/fabric/workflow.yml rename to plugins/nemo-agents/examples/nemo-agent-config/nat-calculator/workflow.yml diff --git a/plugins/nemo-agents/examples/email-phishing-analyzer/fabric/agent.yaml b/plugins/nemo-agents/examples/nemo-agent-config/nat-email-phishing/agent.yaml similarity index 100% rename from plugins/nemo-agents/examples/email-phishing-analyzer/fabric/agent.yaml rename to plugins/nemo-agents/examples/nemo-agent-config/nat-email-phishing/agent.yaml diff --git a/plugins/nemo-agents/examples/email-phishing-analyzer/fabric/workflow.yml b/plugins/nemo-agents/examples/nemo-agent-config/nat-email-phishing/workflow.yml similarity index 100% rename from plugins/nemo-agents/examples/email-phishing-analyzer/fabric/workflow.yml rename to plugins/nemo-agents/examples/nemo-agent-config/nat-email-phishing/workflow.yml diff --git a/plugins/nemo-agents/tests/unit/test_fabric_translator.py b/plugins/nemo-agents/tests/unit/test_fabric_translator.py index 3717b71bc8..3d1528c35f 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_translator.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_translator.py @@ -83,6 +83,19 @@ def test_repository_example_uses_current_codex_and_isolated_hermes_adapters(self assert hermes_config.harness.adapter_id == "nvidia.fabric.hermes" assert hermes_config.harness.settings["python_env"] == "HERMES_ADAPTER_PYTHON" + @pytest.mark.parametrize("example_name", ["nat-calculator", "nat-email-phishing"]) + def test_repository_nat_examples_reference_colocated_workflows(self, example_name: str) -> None: + example_dir = Path(__file__).parents[2] / "examples/nemo-agent-config" / example_name + config = load_agent_config(example_dir / "agent.yaml") + + fabric_config = translate_agent_config(config) + config_file = fabric_config.harness.settings["config_file"] + workflow_path = (example_dir / config_file).resolve() + + assert fabric_config.harness.adapter_id == "nvidia.nemo.platform.nat" + assert workflow_path.is_file() + workflow_path.relative_to(example_dir.resolve()) + def test_translates_default_harness(self) -> None: config = AgentConfig.model_validate(_example_yaml_config()) From 767a58e7b575aeaba510d6aa2a9559b164b091b3 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 24 Jul 2026 10:03:31 -0700 Subject: [PATCH 06/10] chore(nemo-agents): drop unrelated calculator cleanup Signed-off-by: Ajay Thorve --- .../calculator-agent/src/calculator_agent/register.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/nemo-agents/examples/calculator-agent/src/calculator_agent/register.py b/plugins/nemo-agents/examples/calculator-agent/src/calculator_agent/register.py index d7cfdf0d56..1435c56c76 100644 --- a/plugins/nemo-agents/examples/calculator-agent/src/calculator_agent/register.py +++ b/plugins/nemo-agents/examples/calculator-agent/src/calculator_agent/register.py @@ -8,10 +8,10 @@ from collections.abc import AsyncGenerator -from nat.builder.builder import Builder -from nat.builder.function import FunctionGroup -from nat.cli.register_workflow import register_function_group -from nat.data_models.function import FunctionGroupBaseConfig +from nat.builder.builder import Builder # type: ignore +from nat.builder.function import FunctionGroup # type: ignore +from nat.cli.register_workflow import register_function_group # type: ignore +from nat.data_models.function import FunctionGroupBaseConfig # type: ignore from pydantic import Field From 8a908600fd7b3fcb825fd243ee3d00038261160b Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 24 Jul 2026 10:41:48 -0700 Subject: [PATCH 07/10] feat(nemo-agents): map shared capabilities into NAT Build on the shared Platform-to-Fabric config translation from #890 and apply NAT-native MCP and blocked-tool mappings before the workflow runtime starts. Co-authored-by: Manjesh Mogallapalli Signed-off-by: Ajay Thorve --- .../examples/nemo-agent-config/README.md | 21 ++ .../nat-calculator/agent.yaml | 4 + plugins/nemo-agents/pyproject.toml | 1 + .../fabric/adapters/nat/adapter.py | 206 +++++++++++++++++- .../fabric/adapters/nat/fabric-adapter.json | 2 +- .../nemo_agents_plugin/fabric/translator.py | 13 +- .../tests/unit/test_fabric_nat_adapter.py | 94 ++++++++ .../unit/test_fabric_nat_adapter_packaging.py | 2 +- .../tests/unit/test_fabric_translator.py | 21 +- uv.lock | 24 ++ 10 files changed, 364 insertions(+), 24 deletions(-) diff --git a/plugins/nemo-agents/examples/nemo-agent-config/README.md b/plugins/nemo-agents/examples/nemo-agent-config/README.md index c04f301c45..bcf5b07ec0 100644 --- a/plugins/nemo-agents/examples/nemo-agent-config/README.md +++ b/plugins/nemo-agents/examples/nemo-agent-config/README.md @@ -50,6 +50,27 @@ 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. +For NAT, `harness_native` MCP servers become `mcp_client` function groups and +are added to workflows that expose `tool_names`. `tools.blocked` removes named +NAT functions or function groups and can exclude a group member by its +`__` name. For example: + +```yaml +mcp: + servers: + math: + transport: streamable-http + url: http://localhost:9901/mcp + exposure: harness_native +tools: + blocked: + - calculator__divide +``` + +NAT 1.8 does not expose a runtime contract for `SKILL.md` directories, so the +NAT adapter rejects non-empty `skills.paths` rather than silently ignoring +them. Keep workflow instructions in the NAT config. + ## Codex Authenticate Codex, leave `default_harness: codex` in `agent.yaml`, and run: diff --git a/plugins/nemo-agents/examples/nemo-agent-config/nat-calculator/agent.yaml b/plugins/nemo-agents/examples/nemo-agent-config/nat-calculator/agent.yaml index 21818a920d..e240ea69ea 100644 --- a/plugins/nemo-agents/examples/nemo-agent-config/nat-calculator/agent.yaml +++ b/plugins/nemo-agents/examples/nemo-agent-config/nat-calculator/agent.yaml @@ -10,6 +10,10 @@ harnesses: settings: config_file: ./workflow.yml +tools: + blocked: + - calculator__divide + environment: workspace: ./workspace artifacts: ./artifacts diff --git a/plugins/nemo-agents/pyproject.toml b/plugins/nemo-agents/pyproject.toml index 9c288a9724..ad1c8c6ef1 100644 --- a/plugins/nemo-agents/pyproject.toml +++ b/plugins/nemo-agents/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "nemo-agents-example-email-phishing", "nvidia-nat-core>=1.8.0,<1.9", "nvidia-nat-langchain>=1.8.0,<1.9", + "nvidia-nat-mcp>=1.8.0,<1.9", "nvidia-nat-config-optimizer>=1.8.0,<1.9", # ASTD-164: tight pins avoid pip ResolutionTooDeep via aioboto3/langchain-aws. "langchain-aws==1.1.0", 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 index 1e7cd34ba2..1f65872551 100644 --- 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 @@ -11,7 +11,10 @@ import asyncio import logging -from contextlib import AsyncExitStack +import os +import shlex +from collections.abc import AsyncIterator +from contextlib import AsyncExitStack, asynccontextmanager from pathlib import Path from typing import Any @@ -22,6 +25,7 @@ LOGGER = logging.getLogger(__name__) HARNESS = "nat" MODE = "nat_workflow" +FUNCTION_GROUP_SEPARATOR = "__" def main() -> None: @@ -74,19 +78,202 @@ def resolve_config_file(payload: dict[str, Any]) -> Path: def validate_supported_fabric_config(payload: dict[str, Any]) -> None: - """Reject normalized config surfaces this config-file adapter does not map.""" + """Reject normalized config surfaces this adapter cannot map.""" config = common_utils.fabric_config(payload) - unsupported = [field for field in ("models", "mcp", "skills", "tools", "telemetry", "relay") if config.get(field)] + unsupported = [field for field in ("models", "telemetry", "relay") if config.get(field)] + skills = config.get("skills") or {} + if isinstance(skills, dict) and skills.get("paths"): + unsupported.append("skills") + + unsupported_plan = common_utils.capability_plan(payload).get("unsupported") or {} + if unsupported_plan.get("mcp_servers"): + unsupported.append("mcp") + if unsupported_plan.get("skill_paths") and "skills" not in unsupported: + unsupported.append("skills") + if unsupported: - fields = ", ".join(sorted(unsupported)) + fields = sorted(set(unsupported)) raise lifecycle.LifecycleError( "nat_unsupported_fabric_config", - f"NAT adapter does not map normalized Fabric config fields: {fields}; " + f"NAT adapter does not map normalized Fabric config fields: {', '.join(fields)}; " "configure them in the NAT config file", - metadata={"fields": sorted(unsupported)}, + metadata={"fields": fields}, + ) + + +def _native_capabilities(payload: dict[str, Any]) -> dict[str, Any]: + native = common_utils.capability_plan(payload).get("native") or {} + return native if isinstance(native, dict) else {} + + +def _nat_mcp_server_config(name: str, server: Any) -> Any: + if not isinstance(server, dict): + raise lifecycle.LifecycleError( + "nat_invalid_mcp_server", + f"NAT MCP server {name!r} must be a mapping", + ) + + transport = str(server.get("transport") or "").strip().lower().replace("_", "-") + target = os.path.expandvars(str(server.get("url") or "")).strip() + if not target: + raise lifecycle.LifecycleError( + "nat_invalid_mcp_server", + f"NAT MCP server {name!r} requires a non-empty url", + ) + + try: + from nat.plugins.mcp.client.client_config import MCPServerConfig + except ImportError as error: + raise lifecycle.LifecycleError( + "nat_mcp_dependency_missing", + "NAT MCP mapping requires the nvidia-nat-mcp package", + ) from error + + if transport in {"stdio", "command", "process"}: + try: + command = shlex.split(target) + except ValueError as error: + raise lifecycle.LifecycleError( + "nat_invalid_mcp_server", + f"NAT MCP server {name!r} has an invalid stdio command", + ) from error + if not command: + raise lifecycle.LifecycleError( + "nat_invalid_mcp_server", + f"NAT MCP server {name!r} has an empty stdio command", + ) + return MCPServerConfig(transport="stdio", command=command[0], args=command[1:]) + + if transport in {"", "http", "streamable-http", "streamablehttp"}: + transport = "streamable-http" + if transport not in {"sse", "streamable-http"}: + raise lifecycle.LifecycleError( + "nat_unsupported_mcp_transport", + f"NAT MCP server {name!r} has unsupported transport {transport!r}", + ) + return MCPServerConfig.model_validate({"transport": transport, "url": target}) + + +def _workflow_tool_names(config: Any) -> list[Any]: + tool_names = getattr(config.workflow, "tool_names", None) + if not isinstance(tool_names, list): + raise lifecycle.LifecycleError( + "nat_workflow_tools_unsupported", + "NAT MCP mapping requires a workflow with a tool_names field", + ) + return tool_names + + +def _apply_mcp_servers(config: Any, payload: dict[str, Any]) -> None: + servers = _native_capabilities(payload).get("mcp_servers") or {} + if not servers: + return + if not isinstance(servers, dict): + raise lifecycle.LifecycleError( + "nat_invalid_mcp_config", + "NAT native MCP capability plan must be a mapping", ) + try: + from nat.plugins.mcp.client.client_config import MCPClientConfig + except ImportError as error: + raise lifecycle.LifecycleError( + "nat_mcp_dependency_missing", + "NAT MCP mapping requires the nvidia-nat-mcp package", + ) from error + + tool_names = _workflow_tool_names(config) + for name, server in sorted(servers.items()): + if not isinstance(name, str) or not name: + raise lifecycle.LifecycleError( + "nat_invalid_mcp_server", + "NAT MCP server names must be non-empty strings", + ) + if name in config.functions or name in config.function_groups: + raise lifecycle.LifecycleError( + "nat_mcp_name_conflict", + f"NAT MCP server {name!r} conflicts with an existing function or function group", + ) + config.function_groups[name] = MCPClientConfig(server=_nat_mcp_server_config(name, server)) + if name not in {str(tool_name) for tool_name in tool_names}: + tool_names.append(name) + + +def _exclude_group_member(config: Any, group_name: str, member_name: str, tool_names: list[Any] | None) -> None: + group = config.function_groups.get(group_name) + if group is None: + return + + included = list(getattr(group, "include", [])) + if included: + if member_name not in included: + return + remaining = [name for name in included if name != member_name] + if remaining: + group.include = remaining + return + config.function_groups.pop(group_name, None) + if tool_names is not None: + tool_names[:] = [tool_name for tool_name in tool_names if str(tool_name) != group_name] + return + + excluded = list(getattr(group, "exclude", [])) + if member_name not in excluded: + group.exclude = [*excluded, member_name] + + +def _apply_blocked_tools(config: Any, payload: dict[str, Any]) -> None: + blocked = set(common_utils.blocked_tools(payload)) + if not blocked: + return + + tool_names = getattr(config.workflow, "tool_names", None) + if isinstance(tool_names, list): + tool_names[:] = [tool_name for tool_name in tool_names if str(tool_name) not in blocked] + else: + tool_names = None + + for name in blocked: + config.functions.pop(name, None) + config.function_groups.pop(name, None) + if FUNCTION_GROUP_SEPARATOR in name: + group_name, member_name = name.split(FUNCTION_GROUP_SEPARATOR, 1) + _exclude_group_member(config, group_name, member_name, tool_names) + + +def apply_nat_capabilities(config: Any, payload: dict[str, Any]) -> None: + """Map routed Fabric capabilities into a loaded NAT config.""" + + _apply_mcp_servers(config, payload) + _apply_blocked_tools(config, payload) + + +@asynccontextmanager +async def load_nat_workflow(config_file: Path, payload: dict[str, Any]) -> AsyncIterator[Any]: + """Load one NAT workflow, applying Fabric capabilities before it is built.""" + + native = _native_capabilities(payload) + if not native.get("mcp_servers") and not common_utils.blocked_tools(payload): + from nat.runtime.loader import load_workflow + + async with load_workflow(config_file) as sessions: + yield sessions + return + + from nat.builder.workflow_builder import WorkflowBuilder + from nat.runtime.loader import load_config + from nat.runtime.session import SessionManager + + config = load_config(config_file) + apply_nat_capabilities(config, payload) + async with WorkflowBuilder.from_config(config=config) as builder: + sessions = await SessionManager.create(config=config, shared_builder=builder) + try: + yield sessions + finally: + await sessions.shutdown() + def _runtime_id(payload: dict[str, Any]) -> str: try: @@ -176,12 +363,13 @@ async def start(self, payload: dict[str, Any]) -> None: stack = AsyncExitStack() try: - from nat.runtime.loader import load_workflow - - sessions = await stack.enter_async_context(load_workflow(config_file)) + sessions = await stack.enter_async_context(load_nat_workflow(config_file, payload)) except asyncio.CancelledError: await _close_after_failed_start(stack) raise + except lifecycle.LifecycleError: + await _close_after_failed_start(stack) + raise except Exception as error: LOGGER.exception("NAT workflow failed to load") await _close_after_failed_start(stack) 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 index 1a59553b95..4eaf207c27 100644 --- 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 @@ -7,7 +7,7 @@ "module": "nemo_agents_plugin.fabric.adapters.nat.adapter" }, "config": { - "accepts": [] + "accepts": ["mcp", "tools", "tools.blocked"] }, "capabilities": { "cancellation": 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 6660b2fcd0..74c0e7d369 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py @@ -97,11 +97,6 @@ def _validate_nat_harness(config: AgentConfig, harness_name: str, harness: Harne 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.") @@ -123,13 +118,13 @@ def _validate_untranslated_shared_fields(config: AgentConfig) -> None: ) -def _skills_config(config: AgentConfig) -> Any: +def _skills_config(config: AgentConfig) -> fabric.SkillConfig | None: if config.skills is None: return None - return fabric.SkillConfig(paths=config.skills.paths) + return fabric.SkillConfig.model_validate({"paths": config.skills.paths}) -def _mcp_config(config: AgentConfig) -> Any: +def _mcp_config(config: AgentConfig) -> fabric.McpConfig | None: if config.mcp is None: return None return fabric.McpConfig( @@ -137,7 +132,7 @@ def _mcp_config(config: AgentConfig) -> Any: ) -def _tools_config(config: AgentConfig) -> Any: +def _tools_config(config: AgentConfig) -> fabric.ToolsConfig | None: if config.tools is None: return None return fabric.ToolsConfig(blocked=config.tools.blocked) diff --git a/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter.py b/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter.py index d3c2d773ff..6efccb4be1 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter.py @@ -6,6 +6,7 @@ from __future__ import annotations from pathlib import Path +from types import SimpleNamespace from typing import Any import pytest @@ -242,6 +243,99 @@ async def test_normalized_fabric_fields_are_rejected(nat_config: Path) -> None: assert error_info.value.metadata == {"fields": ["models"]} +def test_native_mcp_server_is_added_to_workflow_tools() -> None: + config = SimpleNamespace( + workflow=SimpleNamespace(tool_names=["current_datetime"]), + functions={}, + function_groups={}, + ) + payload = { + "capability_plan": { + "native": { + "mcp_servers": { + "repo": { + "transport": "stdio", + "url": "repo-mcp --root .", + "exposure": "harness_native", + } + } + } + } + } + + nat_adapter.apply_nat_capabilities(config, payload) + + mcp_group = config.function_groups["repo"] + assert config.workflow.tool_names == ["current_datetime", "repo"] + assert mcp_group.server.transport == "stdio" + assert mcp_group.server.command == "repo-mcp" + assert mcp_group.server.args == ["--root", "."] + + +def test_blocked_tools_remove_functions_and_filter_group_members() -> None: + calculator = SimpleNamespace(include=[], exclude=[]) + config = SimpleNamespace( + workflow=SimpleNamespace(tool_names=["current_datetime", "calculator"]), + functions={"current_datetime": object()}, + function_groups={"calculator": calculator}, + ) + payload = { + "config": { + "tools": { + "blocked": ["current_datetime", "calculator__divide"], + } + } + } + + nat_adapter.apply_nat_capabilities(config, payload) + + assert config.workflow.tool_names == ["calculator"] + assert config.functions == {} + assert calculator.exclude == ["divide"] + + +@pytest.mark.asyncio +async def test_skill_paths_remain_explicitly_unsupported(nat_config: Path) -> None: + payload = _start_payload(nat_config.parent) + payload["config"]["skills"] = {"paths": ["./skills/review"]} + payload["capability_plan"] = { + "unsupported": { + "skill_paths": [str(nat_config.parent / "skills/review")], + } + } + + 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": ["skills"]} + + +@pytest.mark.asyncio +async def test_fabric_managed_mcp_remains_explicitly_unsupported(nat_config: Path) -> None: + payload = _start_payload(nat_config.parent) + payload["config"]["mcp"] = { + "servers": { + "repo": { + "transport": "streamable-http", + "url": "http://localhost:9901/mcp", + "exposure": "fabric_managed", + } + } + } + payload["capability_plan"] = { + "unsupported": { + "mcp_servers": payload["config"]["mcp"]["servers"], + } + } + + 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": ["mcp"]} + + @pytest.mark.asyncio async def test_runtime_rejects_mismatched_invocation( nat_config: Path, 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 index dd7fa41ca9..39b2f00a47 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter_packaging.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter_packaging.py @@ -25,7 +25,7 @@ def test_nat_adapter_descriptor_is_narrow_and_platform_owned() -> None: "module": "nemo_agents_plugin.fabric.adapters.nat.adapter", }, "config": { - "accepts": [], + "accepts": ["mcp", "tools", "tools.blocked"], }, "capabilities": { "cancellation": False, diff --git a/plugins/nemo-agents/tests/unit/test_fabric_translator.py b/plugins/nemo-agents/tests/unit/test_fabric_translator.py index 3d1528c35f..c778225e7a 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_translator.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_translator.py @@ -249,7 +249,7 @@ def test_nat_harness_rejects_top_level_platform_models(self) -> None: with pytest.raises(FabricTranslationError, match="cannot define Platform models"): translate_agent_config(config) - def test_nat_harness_rejects_platform_skills(self) -> None: + def test_nat_harness_translates_shared_capabilities_without_platform_model(self) -> None: payload = _example_yaml_config() payload["default_harness"] = "nat" payload["harnesses"] = { @@ -259,11 +259,24 @@ def test_nat_harness_rejects_platform_skills(self) -> None: } } payload["models"] = {} - payload["skills"] = [{"path": "./skills/example"}] + payload["skills"] = {"paths": ["./skills/example"]} + payload["mcp"] = { + "servers": { + "repo": { + "transport": "streamable-http", + "url": "http://localhost:9901/mcp", + } + } + } + payload["tools"] = {"blocked": ["calculator__divide"]} config = AgentConfig.model_validate(payload) - with pytest.raises(FabricTranslationError, match="does not map Platform skills"): - translate_agent_config(config) + fabric_config = translate_agent_config(config) + + assert fabric_config.models == {} + assert fabric_config.skills.paths == ["./skills/example"] + assert fabric_config.mcp.servers["repo"].url == "http://localhost:9901/mcp" + assert fabric_config.tools.blocked == ["calculator__divide"] def test_nat_harness_rejects_platform_telemetry(self) -> None: payload = _example_yaml_config() diff --git a/uv.lock b/uv.lock index df71a2a805..33839fe88d 100644 --- a/uv.lock +++ b/uv.lock @@ -310,6 +310,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b7/584157e43c98aa89810bc2f7099e7e01c728ecf905a66cf705106009228f/aioresponses-0.7.8-py2.py3-none-any.whl", hash = "sha256:b73bd4400d978855e55004b23a3a84cb0f018183bcf066a85ad392800b5b9a94", size = 12518, upload-time = "2025-01-19T18:13:59.633Z" }, ] +[[package]] +name = "aiorwlock" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/65/316cdc82c1b92953235ced1c71a3763f0cd9273c3bec5db60bdb5ad59bfe/aiorwlock-1.5.1.tar.gz", hash = "sha256:2729c77ec736c8d85ec305aa3827a50394fd8c6d823f4404d301cc8c59a4b7f5", size = 7288, upload-time = "2026-02-20T17:42:17.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/23/dc60c00fb9178e356d7f5fb009aabae4625ce79c3e63ed4ef3bcc73d949c/aiorwlock-1.5.1-py3-none-any.whl", hash = "sha256:a28e534a5fce4dabe437055db141369a0803c69fe61c406b6fc5cdfa8f3dda13", size = 8016, upload-time = "2026-02-20T17:42:18.095Z" }, +] + [[package]] name = "aiosignal" version = "1.4.0" @@ -3692,6 +3701,7 @@ dependencies = [ { name = "nvidia-nat-config-optimizer", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nvidia-nat-core", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nvidia-nat-langchain", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nat-mcp", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "pyyaml", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "rich", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] @@ -3738,6 +3748,7 @@ requires-dist = [ { name = "nvidia-nat-config-optimizer", specifier = ">=1.8.0,<1.9" }, { name = "nvidia-nat-core", specifier = ">=1.8.0,<1.9" }, { name = "nvidia-nat-langchain", specifier = ">=1.8.0,<1.9" }, + { name = "nvidia-nat-mcp", specifier = ">=1.8.0,<1.9" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.23" }, { name = "python-on-whales", marker = "extra == 'container'", specifier = ">=0.60" }, @@ -7763,6 +7774,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/49/eee7d8f68568e4c994a67ee0def4070e8fdeb0d603a7ef580050b3ee3d34/nvidia_nat_langchain-1.8.0-py3-none-any.whl", hash = "sha256:8120ad2b972ce2d90fae1e7f95b3daf6b463310e0105fc330896bfb069a5d403", size = 197395, upload-time = "2026-06-17T00:27:13.69Z" }, ] +[[package]] +name = "nvidia-nat-mcp" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiorwlock", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "mcp", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nat-core", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/c5/1cf428635978b8a705d7c35e816ee91278414d1607e91f211e6aa2de6507/nvidia_nat_mcp-1.8.0-py3-none-any.whl", hash = "sha256:b38983d9d94f5f47ead7acc395c86409d19533f0bf0643489bbde01f35603f71", size = 132635, upload-time = "2026-06-17T00:22:22.383Z" }, +] + [[package]] name = "nvidia-nat-opentelemetry" version = "1.8.0" From c54820e9e1757ddd50b61a7482151ec190f88ff8 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 24 Jul 2026 11:17:39 -0700 Subject: [PATCH 08/10] feat(nemo-agents): add NAT to shared harness example Signed-off-by: Ajay Thorve --- .../examples/nemo-agent-config/README.md | 25 ++++++++++++++----- .../examples/nemo-agent-config/agent.yaml | 4 +++ .../nemo_agents_plugin/fabric/translator.py | 4 +-- .../tests/unit/test_fabric_translator.py | 21 ++++++++++------ 4 files changed, 39 insertions(+), 15 deletions(-) diff --git a/plugins/nemo-agents/examples/nemo-agent-config/README.md b/plugins/nemo-agents/examples/nemo-agent-config/README.md index bcf5b07ec0..1e42a3ea24 100644 --- a/plugins/nemo-agents/examples/nemo-agent-config/README.md +++ b/plugins/nemo-agents/examples/nemo-agent-config/README.md @@ -15,16 +15,29 @@ Top-level `skills`, `mcp`, and `tools` are Platform-owned shared fields that translate into `FabricConfig`. Prompt settings are harness-specific for now and should be configured under `harnesses..settings`. +The shared `agent.yaml` configures Codex, Hermes, and NAT. Select one by setting +`default_harness` to `codex`, `hermes`, or `nat` before invoking it. + ## NAT The plugin install includes the Platform-packaged calculator and email phishing -NAT components. Each integration has its own `agent.yaml` because calculator -and email phishing are distinct NAT workflows, not alternative harnesses for -the Codex and Hermes test agent. In each directory, `workflow.yml` remains the -NAT source of truth and `agent.yaml` selects the Platform-owned NAT Fabric -adapter. +NAT components. Each has a dedicated `agent.yaml` because they are distinct NAT +workflows. For the harness comparison, the shared `agent.yaml` uses calculator +as its representative NAT harness so all three harness kinds can be exercised +from one Platform config. In every case, `workflow.yml` remains the NAT source +of truth. + +Set `default_harness: nat` in the shared `agent.yaml`, then run: + +```bash +export NVIDIA_API_KEY="" + +nemo agents invoke \ + --agent-config plugins/nemo-agents/examples/nemo-agent-config/agent.yaml \ + --input "What is 12 multiplied by 8?" +``` -Run the calculator workflow: +Run the dedicated calculator config: ```bash export NVIDIA_API_KEY="" diff --git a/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml b/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml index eb15cf8a28..d7f86cf0bc 100644 --- a/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml +++ b/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml @@ -28,6 +28,10 @@ harnesses: system_prompt: You are a concise test assistant. config_overrides: model_reasoning_effort: high + nat: + kind: nat + settings: + config_file: ./nat-calculator/workflow.yml models: default: 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 74c0e7d369..51a9c7a968 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py @@ -92,9 +92,9 @@ def _resolve_model(config: AgentConfig, harness_name: str, harness: HarnessConfi def _validate_nat_harness(config: AgentConfig, harness_name: str, harness: HarnessConfig) -> None: - if harness.model is not None or config.models: + if harness.model is not None: raise FabricTranslationError( - f"NAT harness {harness_name!r} cannot define Platform models; configure models in the NAT config file." + f"NAT harness {harness_name!r} cannot define a Platform model; configure models in the NAT config file." ) config_file = harness.settings.get("config_file") diff --git a/plugins/nemo-agents/tests/unit/test_fabric_translator.py b/plugins/nemo-agents/tests/unit/test_fabric_translator.py index c778225e7a..b19589c186 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_translator.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_translator.py @@ -71,17 +71,23 @@ def _example_yaml_config() -> dict[str, Any]: class TestTranslateAgentConfig: - def test_repository_example_uses_current_codex_and_isolated_hermes_adapters(self) -> None: + def test_repository_example_configures_codex_hermes_and_nat_adapters(self) -> None: example_path = Path(__file__).parents[2] / "examples/nemo-agent-config/agent.yaml" config = load_agent_config(example_path) - codex_config = translate_agent_config(config, harness_name="codex") - hermes_config = translate_agent_config(config, harness_name="hermes") + codex_config = translate_agent_config(config) + hermes_config = translate_agent_config(config.model_copy(update={"default_harness": "hermes"})) + nat_config = translate_agent_config(config.model_copy(update={"default_harness": "nat"})) assert codex_config.harness.adapter_id == "nvidia.fabric.codex" assert "skip_git_repo_check" not in codex_config.harness.settings assert hermes_config.harness.adapter_id == "nvidia.fabric.hermes" assert hermes_config.harness.settings["python_env"] == "HERMES_ADAPTER_PYTHON" + assert nat_config.harness.adapter_id == "nvidia.nemo.platform.nat" + assert nat_config.models == {} + nat_workflow = (example_path.parent / nat_config.harness.settings["config_file"]).resolve() + assert nat_workflow.is_file() + nat_workflow.relative_to(example_path.parent.resolve()) @pytest.mark.parametrize("example_name", ["nat-calculator", "nat-email-phishing"]) def test_repository_nat_examples_reference_colocated_workflows(self, example_name: str) -> None: @@ -232,10 +238,10 @@ def test_nat_harness_rejects_inline_platform_model(self) -> None: payload["models"] = {} config = AgentConfig.model_validate(payload) - with pytest.raises(FabricTranslationError, match="cannot define Platform models"): + with pytest.raises(FabricTranslationError, match="cannot define a Platform model"): translate_agent_config(config) - def test_nat_harness_rejects_top_level_platform_models(self) -> None: + def test_nat_harness_ignores_top_level_models_owned_by_other_harnesses(self) -> None: payload = _example_yaml_config() payload["default_harness"] = "nat" payload["harnesses"] = { @@ -246,8 +252,9 @@ def test_nat_harness_rejects_top_level_platform_models(self) -> None: } config = AgentConfig.model_validate(payload) - with pytest.raises(FabricTranslationError, match="cannot define Platform models"): - translate_agent_config(config) + fabric_config = translate_agent_config(config) + + assert fabric_config.models == {} def test_nat_harness_translates_shared_capabilities_without_platform_model(self) -> None: payload = _example_yaml_config() From dc7665d95b7378ccd2cf2b8b6f1dcd78cb40eb76 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 24 Jul 2026 11:34:57 -0700 Subject: [PATCH 09/10] feat(nemo-agents): map Platform models into NAT Signed-off-by: Ajay Thorve --- .../examples/nemo-agent-config/README.md | 31 +++- .../examples/nemo-agent-config/agent.yaml | 7 + .../fabric/adapters/nat/adapter.py | 104 +++++++++++- .../fabric/adapters/nat/fabric-adapter.json | 2 +- .../nemo_agents_plugin/fabric/translator.py | 44 ++++- .../integration/test_fabric_nat_adapter.py | 29 +++- .../tests/unit/test_fabric_nat_adapter.py | 153 +++++++++++++++++- .../unit/test_fabric_nat_adapter_packaging.py | 2 +- .../tests/unit/test_fabric_translator.py | 73 ++++++++- 9 files changed, 425 insertions(+), 20 deletions(-) diff --git a/plugins/nemo-agents/examples/nemo-agent-config/README.md b/plugins/nemo-agents/examples/nemo-agent-config/README.md index 1e42a3ea24..f0cba682e3 100644 --- a/plugins/nemo-agents/examples/nemo-agent-config/README.md +++ b/plugins/nemo-agents/examples/nemo-agent-config/README.md @@ -11,7 +11,7 @@ Install them explicitly before local Fabric smoke tests: uv pip install -e "plugins/nemo-agents[fabric]" ``` -Top-level `skills`, `mcp`, and `tools` are Platform-owned shared fields that +Top-level `models`, `skills`, `mcp`, and `tools` are Platform-owned fields that translate into `FabricConfig`. Prompt settings are harness-specific for now and should be configured under `harnesses..settings`. @@ -25,7 +25,34 @@ NAT components. Each has a dedicated `agent.yaml` because they are distinct NAT workflows. For the harness comparison, the shared `agent.yaml` uses calculator as its representative NAT harness so all three harness kinds can be exercised from one Platform config. In every case, `workflow.yml` remains the NAT source -of truth. +of truth for workflow structure and native LLM client configuration. + +For NAT model overrides, `harness.settings.llm_map` maps each NAT `llms` alias +to a Platform `models` alias. The adapter preserves the existing NAT LLM type +and settings, then applies the Platform model name, temperature, credential +environment variable, and explicit model settings during runtime start. The +Platform model provider does not replace the NAT `_type`; the referenced native +LLM entry remains responsible for selecting a compatible client. + +```yaml +harnesses: + nat: + kind: nat + settings: + config_file: ./nat-calculator/workflow.yml + llm_map: + llm: nat_default + +models: + nat_default: + provider: nvidia + model: nvidia/nemotron-3-nano-30b-a3b + api_key_env: NVIDIA_API_KEY + temperature: 0.0 +``` + +Without `llm_map`, the adapter leaves the NAT workflow's native `llms` +configuration unchanged. Set `default_harness: nat` in the shared `agent.yaml`, then run: diff --git a/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml b/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml index d7f86cf0bc..7c8a4c248e 100644 --- a/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml +++ b/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml @@ -32,11 +32,18 @@ harnesses: kind: nat settings: config_file: ./nat-calculator/workflow.yml + llm_map: + llm: nat_default models: default: provider: openai model: openai/gpt-5.4 + nat_default: + provider: nvidia + model: nvidia/nemotron-3-nano-30b-a3b + api_key_env: NVIDIA_API_KEY + temperature: 0.0 skills: paths: [] 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 index 1f65872551..80e039c16f 100644 --- 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 @@ -20,12 +20,25 @@ import nemo_fabric_adapters.common.utils as common_utils # ty: ignore[unresolved-import] from nemo_fabric_adapters.common import lifecycle # ty: ignore[unresolved-import] +from pydantic import ValidationError from pydantic_core import to_jsonable_python LOGGER = logging.getLogger(__name__) HARNESS = "nat" MODE = "nat_workflow" FUNCTION_GROUP_SEPARATOR = "__" +RESERVED_MODEL_SETTINGS = frozenset( + { + "_type", + "type", + "provider", + "model", + "model_name", + "api_key", + "api_key_env", + "temperature", + } +) def main() -> None: @@ -81,7 +94,7 @@ def validate_supported_fabric_config(payload: dict[str, Any]) -> None: """Reject normalized config surfaces this adapter cannot map.""" config = common_utils.fabric_config(payload) - unsupported = [field for field in ("models", "telemetry", "relay") if config.get(field)] + unsupported = [field for field in ("telemetry", "relay") if config.get(field)] skills = config.get("skills") or {} if isinstance(skills, dict) and skills.get("paths"): unsupported.append("skills") @@ -107,6 +120,92 @@ def _native_capabilities(payload: dict[str, Any]) -> dict[str, Any]: return native if isinstance(native, dict) else {} +def _fabric_models(payload: dict[str, Any]) -> dict[str, Any]: + models = common_utils.fabric_config(payload).get("models") or {} + if not isinstance(models, dict): + raise lifecycle.LifecycleError( + "nat_invalid_models", + "Fabric models must be a mapping", + ) + return models + + +def apply_nat_models(config: Any, payload: dict[str, Any]) -> None: + """Overlay normalized Fabric models onto existing typed NAT LLM entries.""" + + for llm_alias, model in sorted(_fabric_models(payload).items()): + if not isinstance(llm_alias, str) or not llm_alias or not isinstance(model, dict): + raise lifecycle.LifecycleError( + "nat_invalid_models", + "Fabric model aliases must be non-empty strings with model configurations", + ) + + native_llm = config.llms.get(llm_alias) + if native_llm is None: + raise lifecycle.LifecycleError( + "nat_model_alias_not_found", + f"Fabric model alias {llm_alias!r} does not match an existing NAT llms entry", + metadata={"llm": llm_alias}, + ) + + model_name = model.get("model") + if not isinstance(model_name, str) or not model_name: + raise lifecycle.LifecycleError( + "nat_invalid_models", + f"Fabric model alias {llm_alias!r} requires a non-empty model", + metadata={"llm": llm_alias}, + ) + + settings = model.get("settings") or {} + if not isinstance(settings, dict): + raise lifecycle.LifecycleError( + "nat_invalid_models", + f"Fabric model alias {llm_alias!r} settings must be a mapping", + metadata={"llm": llm_alias}, + ) + reserved = sorted(RESERVED_MODEL_SETTINGS.intersection(settings)) + if reserved: + raise lifecycle.LifecycleError( + "nat_model_settings_reserved", + f"Fabric model alias {llm_alias!r} settings cannot replace normalized or NAT type fields", + metadata={"llm": llm_alias, "fields": reserved}, + ) + + values = native_llm.model_dump(mode="python", by_alias=True) + values.update(settings) + values["model"] = model_name + + temperature = model.get("temperature") + if temperature is not None: + values["temperature"] = temperature + + api_key_env = model.get("api_key_env") + if api_key_env is not None: + if not isinstance(api_key_env, str) or not api_key_env: + raise lifecycle.LifecycleError( + "nat_invalid_models", + f"Fabric model alias {llm_alias!r} api_key_env must be a non-empty string", + metadata={"llm": llm_alias}, + ) + api_key = os.environ.get(api_key_env) + if not api_key: + raise lifecycle.LifecycleError( + "nat_model_api_key_missing", + f"Fabric model alias {llm_alias!r} requires environment variable {api_key_env!r}", + metadata={"llm": llm_alias, "api_key_env": api_key_env}, + ) + values["api_key"] = api_key + + try: + config.llms[llm_alias] = type(native_llm).model_validate(values) + except ValidationError as error: + raise lifecycle.LifecycleError( + "nat_model_override_invalid", + f"Fabric model alias {llm_alias!r} is invalid for the existing NAT LLM type", + metadata={"llm": llm_alias}, + ) from error + + def _nat_mcp_server_config(name: str, server: Any) -> Any: if not isinstance(server, dict): raise lifecycle.LifecycleError( @@ -254,7 +353,7 @@ async def load_nat_workflow(config_file: Path, payload: dict[str, Any]) -> Async """Load one NAT workflow, applying Fabric capabilities before it is built.""" native = _native_capabilities(payload) - if not native.get("mcp_servers") and not common_utils.blocked_tools(payload): + if not _fabric_models(payload) and not native.get("mcp_servers") and not common_utils.blocked_tools(payload): from nat.runtime.loader import load_workflow async with load_workflow(config_file) as sessions: @@ -266,6 +365,7 @@ async def load_nat_workflow(config_file: Path, payload: dict[str, Any]) -> Async from nat.runtime.session import SessionManager config = load_config(config_file) + apply_nat_models(config, payload) apply_nat_capabilities(config, payload) async with WorkflowBuilder.from_config(config=config) as builder: sessions = await SessionManager.create(config=config, shared_builder=builder) 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 index 4eaf207c27..7d5f68483b 100644 --- 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 @@ -7,7 +7,7 @@ "module": "nemo_agents_plugin.fabric.adapters.nat.adapter" }, "config": { - "accepts": ["mcp", "tools", "tools.blocked"] + "accepts": ["models", "mcp", "tools", "tools.blocked"] }, "capabilities": { "cancellation": 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 51a9c7a968..41ab467990 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py @@ -32,6 +32,7 @@ def translate_agent_config(config: AgentConfig, harness_name: str | None = None) models: dict[str, fabric.ModelConfig | dict[str, Any]] = {} if harness.kind == "nat": _validate_nat_harness(config, selected_harness_name, harness) + models.update(_nat_models(config, selected_harness_name, harness)) else: model = _resolve_model(config, selected_harness_name, harness) models["default"] = fabric.ModelConfig(**_model_payload(model)) @@ -41,7 +42,7 @@ def translate_agent_config(config: AgentConfig, harness_name: str | None = None) harness=fabric.HarnessConfig( adapter_id=_adapter_id_for_harness(harness), resolution="preinstalled", - settings=harness.settings, + settings=_harness_settings(harness), ), models=models, environment=fabric.EnvironmentConfig( @@ -94,7 +95,8 @@ def _resolve_model(config: AgentConfig, harness_name: str, harness: HarnessConfi def _validate_nat_harness(config: AgentConfig, harness_name: str, harness: HarnessConfig) -> None: if harness.model is not None: raise FabricTranslationError( - f"NAT harness {harness_name!r} cannot define a Platform model; configure models in the NAT config file." + f"NAT harness {harness_name!r} cannot define an inline Platform model; " + "use settings.llm_map to reference top-level models." ) config_file = harness.settings.get("config_file") @@ -107,6 +109,44 @@ def _validate_nat_harness(config: AgentConfig, harness_name: str, harness: Harne ) +def _nat_models( + config: AgentConfig, + harness_name: str, + harness: HarnessConfig, +) -> dict[str, fabric.ModelConfig]: + llm_map = harness.settings.get("llm_map") + if llm_map is None: + return {} + if not isinstance(llm_map, dict): + raise FabricTranslationError(f"NAT harness {harness_name!r} settings.llm_map must be a mapping.") + + models: dict[str, fabric.ModelConfig] = {} + for llm_alias, model_alias in llm_map.items(): + if not isinstance(llm_alias, str) or not llm_alias: + raise FabricTranslationError( + f"NAT harness {harness_name!r} settings.llm_map keys must be non-empty NAT LLM aliases." + ) + if not isinstance(model_alias, str) or not model_alias: + raise FabricTranslationError( + f"NAT harness {harness_name!r} settings.llm_map values must be non-empty Platform model aliases." + ) + + model = config.models.get(model_alias) + if model is None: + raise FabricTranslationError( + f"NAT harness {harness_name!r} settings.llm_map references unknown Platform model {model_alias!r}." + ) + models[llm_alias] = fabric.ModelConfig(**_model_payload(model)) + return models + + +def _harness_settings(harness: HarnessConfig) -> dict[str, Any]: + settings = dict(harness.settings) + if harness.kind == "nat": + settings.pop("llm_map", None) + return settings + + 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 index 5391e0d12f..aa45df07d3 100644 --- a/plugins/nemo-agents/tests/integration/test_fabric_nat_adapter.py +++ b/plugins/nemo-agents/tests/integration/test_fabric_nat_adapter.py @@ -14,11 +14,25 @@ @pytest.mark.integration @pytest.mark.asyncio -async def test_platform_invokes_installed_nat_adapter_without_local_descriptor(tmp_path: Path) -> None: +async def test_platform_invokes_installed_nat_adapter_without_local_descriptor( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: (tmp_path / "workflow.yml").write_text( - "workflow:\n _type: current_timezone\n", + "\n".join( + [ + "llms:", + " llm:", + " _type: nim", + " model_name: native-model", + "workflow:", + " _type: current_timezone", + "", + ] + ), encoding="utf-8", ) + monkeypatch.setenv("NVIDIA_API_KEY", "test-key") agent_config = AgentConfig.model_validate( { "config_format": "nemo-agents-spec-v1", @@ -29,9 +43,20 @@ async def test_platform_invokes_installed_nat_adapter_without_local_descriptor(t "kind": "nat", "settings": { "config_file": "./workflow.yml", + "llm_map": { + "llm": "nat_default", + }, }, } }, + "models": { + "nat_default": { + "provider": "nvidia", + "model": "platform-model", + "api_key_env": "NVIDIA_API_KEY", + "temperature": 0.0, + } + }, "environment": { "workspace": "./workspace", "artifacts": "./artifacts", diff --git a/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter.py b/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter.py index 6efccb4be1..372bfcf879 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter.py @@ -227,20 +227,157 @@ async def test_config_file_must_stay_within_agent_directory(tmp_path: Path) -> N @pytest.mark.asyncio -async def test_normalized_fabric_fields_are_rejected(nat_config: Path) -> None: +async def test_unsupported_normalized_fabric_fields_are_rejected(nat_config: Path) -> None: payload = _start_payload(nat_config.parent) - payload["config"]["models"] = { - "default": { - "provider": "nvidia", - "model": "example-model", - } - } + payload["config"]["telemetry"] = {"providers": {"custom": {"enabled": True}}} 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"]} + assert error_info.value.metadata == {"fields": ["telemetry"]} + + +def test_fabric_model_overrides_existing_typed_nat_llm(monkeypatch: pytest.MonkeyPatch) -> None: + from nat.llm.nim_llm import NIMModelConfig + + native_llm = NIMModelConfig( + model_name="native-model", + base_url="https://native.example/v1", + max_tokens=777, + temperature=0.8, + chat_template_kwargs={"enable_thinking": False}, + ) + config = SimpleNamespace(llms={"llm": native_llm}) + payload = { + "config": { + "models": { + "llm": { + "provider": "nvidia", + "model": "platform-model", + "api_key_env": "NVIDIA_API_KEY", + "temperature": 0.1, + "settings": { + "base_url": "https://platform.example/v1", + "top_p": 0.7, + }, + } + } + } + } + monkeypatch.setenv("NVIDIA_API_KEY", "secret-value") + + nat_adapter.apply_nat_models(config, payload) + + updated = config.llms["llm"] + assert isinstance(updated, NIMModelConfig) + assert updated is not native_llm + assert updated.model_name == "platform-model" + assert updated.api_key.get_secret_value() == "secret-value" + assert updated.temperature == 0.1 + assert updated.base_url == "https://platform.example/v1" + assert updated.top_p == 0.7 + assert updated.max_tokens == 777 + assert updated.chat_template_kwargs == {"enable_thinking": False} + + +def test_fabric_model_requires_existing_nat_llm_alias() -> None: + config = SimpleNamespace(llms={}) + payload = { + "config": { + "models": { + "missing": { + "provider": "nvidia", + "model": "platform-model", + } + } + } + } + + with pytest.raises(lifecycle.LifecycleError) as error_info: + nat_adapter.apply_nat_models(config, payload) + + assert error_info.value.code == "nat_model_alias_not_found" + assert error_info.value.metadata == {"llm": "missing"} + + +def test_fabric_model_requires_configured_api_key_environment(monkeypatch: pytest.MonkeyPatch) -> None: + from nat.llm.nim_llm import NIMModelConfig + + config = SimpleNamespace(llms={"llm": NIMModelConfig(model_name="native-model")}) + payload = { + "config": { + "models": { + "llm": { + "provider": "nvidia", + "model": "platform-model", + "api_key_env": "MISSING_API_KEY", + } + } + } + } + monkeypatch.delenv("MISSING_API_KEY", raising=False) + + with pytest.raises(lifecycle.LifecycleError) as error_info: + nat_adapter.apply_nat_models(config, payload) + + assert error_info.value.code == "nat_model_api_key_missing" + assert error_info.value.metadata == { + "llm": "llm", + "api_key_env": "MISSING_API_KEY", + } + + +def test_fabric_model_settings_cannot_replace_native_llm_contract() -> None: + from nat.llm.nim_llm import NIMModelConfig + + config = SimpleNamespace(llms={"llm": NIMModelConfig(model_name="native-model")}) + payload = { + "config": { + "models": { + "llm": { + "provider": "nvidia", + "model": "platform-model", + "settings": { + "_type": "openai", + "model_name": "settings-model", + }, + } + } + } + } + + with pytest.raises(lifecycle.LifecycleError) as error_info: + nat_adapter.apply_nat_models(config, payload) + + assert error_info.value.code == "nat_model_settings_reserved" + assert error_info.value.metadata == { + "llm": "llm", + "fields": ["_type", "model_name"], + } + + +def test_invalid_fabric_model_override_is_normalized() -> None: + from nat.llm.nim_llm import NIMModelConfig + + config = SimpleNamespace(llms={"llm": NIMModelConfig(model_name="native-model")}) + payload = { + "config": { + "models": { + "llm": { + "provider": "nvidia", + "model": "platform-model", + "temperature": -1.0, + } + } + } + } + + with pytest.raises(lifecycle.LifecycleError) as error_info: + nat_adapter.apply_nat_models(config, payload) + + assert error_info.value.code == "nat_model_override_invalid" + assert error_info.value.metadata == {"llm": "llm"} def test_native_mcp_server_is_added_to_workflow_tools() -> None: 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 index 39b2f00a47..3682ac30e3 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter_packaging.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter_packaging.py @@ -25,7 +25,7 @@ def test_nat_adapter_descriptor_is_narrow_and_platform_owned() -> None: "module": "nemo_agents_plugin.fabric.adapters.nat.adapter", }, "config": { - "accepts": ["mcp", "tools", "tools.blocked"], + "accepts": ["models", "mcp", "tools", "tools.blocked"], }, "capabilities": { "cancellation": False, diff --git a/plugins/nemo-agents/tests/unit/test_fabric_translator.py b/plugins/nemo-agents/tests/unit/test_fabric_translator.py index b19589c186..88cd5f1ec6 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_translator.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_translator.py @@ -84,7 +84,9 @@ def test_repository_example_configures_codex_hermes_and_nat_adapters(self) -> No assert hermes_config.harness.adapter_id == "nvidia.fabric.hermes" assert hermes_config.harness.settings["python_env"] == "HERMES_ADAPTER_PYTHON" assert nat_config.harness.adapter_id == "nvidia.nemo.platform.nat" - assert nat_config.models == {} + assert nat_config.models["llm"].provider == "nvidia" + assert nat_config.models["llm"].model == "nvidia/nemotron-3-nano-30b-a3b" + assert "llm_map" not in nat_config.harness.settings nat_workflow = (example_path.parent / nat_config.harness.settings["config_file"]).resolve() assert nat_workflow.is_file() nat_workflow.relative_to(example_path.parent.resolve()) @@ -238,7 +240,7 @@ def test_nat_harness_rejects_inline_platform_model(self) -> None: payload["models"] = {} config = AgentConfig.model_validate(payload) - with pytest.raises(FabricTranslationError, match="cannot define a Platform model"): + with pytest.raises(FabricTranslationError, match="cannot define an inline Platform model"): translate_agent_config(config) def test_nat_harness_ignores_top_level_models_owned_by_other_harnesses(self) -> None: @@ -256,6 +258,73 @@ def test_nat_harness_ignores_top_level_models_owned_by_other_harnesses(self) -> assert fabric_config.models == {} + def test_nat_harness_maps_platform_models_to_nat_llm_aliases(self) -> None: + payload = _example_yaml_config() + payload["default_harness"] = "nat" + payload["harnesses"] = { + "nat": { + "kind": "nat", + "settings": { + "config_file": "./workflow.yml", + "llm_map": { + "primary": "default", + "judge": "default", + }, + }, + } + } + config = AgentConfig.model_validate(payload) + + fabric_config = translate_agent_config(config) + + assert set(fabric_config.models) == {"primary", "judge"} + assert fabric_config.models["primary"].provider == "openai" + assert fabric_config.models["primary"].model == "openai/gpt-5.4" + assert fabric_config.models["judge"] == fabric_config.models["primary"] + assert fabric_config.harness.settings == {"config_file": "./workflow.yml"} + + def test_nat_harness_rejects_unknown_platform_model_alias(self) -> None: + payload = _example_yaml_config() + payload["default_harness"] = "nat" + payload["harnesses"] = { + "nat": { + "kind": "nat", + "settings": { + "config_file": "./workflow.yml", + "llm_map": {"llm": "missing"}, + }, + } + } + config = AgentConfig.model_validate(payload) + + with pytest.raises(FabricTranslationError, match="references unknown Platform model 'missing'"): + translate_agent_config(config) + + @pytest.mark.parametrize( + "llm_map", + [ + "default", + {"": "default"}, + {"llm": ""}, + ], + ) + def test_nat_harness_rejects_invalid_llm_map(self, llm_map: Any) -> None: + payload = _example_yaml_config() + payload["default_harness"] = "nat" + payload["harnesses"] = { + "nat": { + "kind": "nat", + "settings": { + "config_file": "./workflow.yml", + "llm_map": llm_map, + }, + } + } + config = AgentConfig.model_validate(payload) + + with pytest.raises(FabricTranslationError, match="llm_map"): + translate_agent_config(config) + def test_nat_harness_translates_shared_capabilities_without_platform_model(self) -> None: payload = _example_yaml_config() payload["default_harness"] = "nat" From ab817e0bc16111c9f4ad8ee93edfa73fbeabcdd0 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 24 Jul 2026 12:14:22 -0700 Subject: [PATCH 10/10] refactor(nemo-agents): build NAT config from agent spec Signed-off-by: Ajay Thorve --- .../examples/nemo-agent-config/README.md | 44 ++- .../examples/nemo-agent-config/agent.yaml | 22 +- .../nat-calculator/agent.yaml | 16 +- .../nat-calculator/workflow.yml | 26 -- .../nat-email-phishing/agent.yaml | 15 +- .../nat-email-phishing/workflow.yml | 23 -- .../src/nemo_agents_plugin/agent_config.py | 18 + .../fabric/adapters/nat/adapter.py | 273 +++++++------- .../nemo_agents_plugin/fabric/translator.py | 69 +--- .../integration/test_fabric_nat_adapter.py | 29 +- .../tests/unit/test_fabric_nat_adapter.py | 343 +++++++++--------- .../tests/unit/test_fabric_translator.py | 130 +++---- 12 files changed, 497 insertions(+), 511 deletions(-) delete mode 100644 plugins/nemo-agents/examples/nemo-agent-config/nat-calculator/workflow.yml delete mode 100644 plugins/nemo-agents/examples/nemo-agent-config/nat-email-phishing/workflow.yml diff --git a/plugins/nemo-agents/examples/nemo-agent-config/README.md b/plugins/nemo-agents/examples/nemo-agent-config/README.md index f0cba682e3..b1f9794f2b 100644 --- a/plugins/nemo-agents/examples/nemo-agent-config/README.md +++ b/plugins/nemo-agents/examples/nemo-agent-config/README.md @@ -24,35 +24,40 @@ The plugin install includes the Platform-packaged calculator and email phishing NAT components. Each has a dedicated `agent.yaml` because they are distinct NAT workflows. For the harness comparison, the shared `agent.yaml` uses calculator as its representative NAT harness so all three harness kinds can be exercised -from one Platform config. In every case, `workflow.yml` remains the NAT source -of truth for workflow structure and native LLM client configuration. +from one Platform config. `agent.yaml` is the only configuration source: the +adapter translates its normalized Fabric model and NAT harness settings into an +in-memory typed NAT configuration. -For NAT model overrides, `harness.settings.llm_map` maps each NAT `llms` alias -to a Platform `models` alias. The adapter preserves the existing NAT LLM type -and settings, then applies the Platform model name, temperature, credential -environment variable, and explicit model settings during runtime start. The -Platform model provider does not replace the NAT `_type`; the referenced native -LLM entry remains responsible for selecting a compatible client. +The initial NAT contract supports `react` and `current_timezone` workflows. A +`react` workflow accepts the Platform-packaged `calculator`, +`current_datetime`, and `email_phishing_analyzer` tools. The adapter maps those +names to NAT functions and function groups; those native details are not part of +the public Platform config. ```yaml harnesses: nat: kind: nat settings: - config_file: ./nat-calculator/workflow.yml - llm_map: - llm: nat_default + workflow: react + tools: + - calculator + - current_datetime models: - nat_default: + default: provider: nvidia model: nvidia/nemotron-3-nano-30b-a3b api_key_env: NVIDIA_API_KEY temperature: 0.0 + settings: + max_tokens: 1024 ``` -Without `llm_map`, the adapter leaves the NAT workflow's native `llms` -configuration unchanged. +For `react`, the adapter translates `models.default` into NAT's `default` LLM. +The `nvidia` and `nim` providers select NAT's NIM client, while `openai` selects +its OpenAI-compatible client. Provider-specific fields such as `base_url` and +`max_tokens` belong in `models.default.settings`. Set `default_harness: nat` in the shared `agent.yaml`, then run: @@ -84,11 +89,9 @@ nemo agents invoke \ --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. +Unsupported workflow and tool names fail during Platform translation. Expanding +the supported set requires an explicit Platform-to-NAT mapping in the adapter; +the adapter does not accept arbitrary native NAT configuration. For NAT, `harness_native` MCP servers become `mcp_client` function groups and are added to workflows that expose `tool_names`. `tools.blocked` removes named @@ -109,7 +112,8 @@ tools: NAT 1.8 does not expose a runtime contract for `SKILL.md` directories, so the NAT adapter rejects non-empty `skills.paths` rather than silently ignoring -them. Keep workflow instructions in the NAT config. +them. Put React-specific instructions in +`harnesses..settings.instructions`. ## Codex diff --git a/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml b/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml index 7c8a4c248e..658948e7e2 100644 --- a/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml +++ b/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml @@ -30,21 +30,25 @@ harnesses: model_reasoning_effort: high nat: kind: nat + model: + provider: nvidia + model: nvidia/nemotron-3-nano-30b-a3b + api_key_env: NVIDIA_API_KEY + temperature: 0.0 + settings: + max_tokens: 1024 + chat_template_kwargs: + enable_thinking: false settings: - config_file: ./nat-calculator/workflow.yml - llm_map: - llm: nat_default + workflow: react + tools: + - calculator + - current_datetime models: default: provider: openai model: openai/gpt-5.4 - nat_default: - provider: nvidia - model: nvidia/nemotron-3-nano-30b-a3b - api_key_env: NVIDIA_API_KEY - temperature: 0.0 - skills: paths: [] diff --git a/plugins/nemo-agents/examples/nemo-agent-config/nat-calculator/agent.yaml b/plugins/nemo-agents/examples/nemo-agent-config/nat-calculator/agent.yaml index e240ea69ea..3ed145563a 100644 --- a/plugins/nemo-agents/examples/nemo-agent-config/nat-calculator/agent.yaml +++ b/plugins/nemo-agents/examples/nemo-agent-config/nat-calculator/agent.yaml @@ -8,7 +8,21 @@ harnesses: nat: kind: nat settings: - config_file: ./workflow.yml + workflow: react + tools: + - calculator + - current_datetime + +models: + default: + provider: nvidia + model: nvidia/nemotron-3-nano-30b-a3b + api_key_env: NVIDIA_API_KEY + temperature: 0.0 + settings: + max_tokens: 1024 + chat_template_kwargs: + enable_thinking: false tools: blocked: diff --git a/plugins/nemo-agents/examples/nemo-agent-config/nat-calculator/workflow.yml b/plugins/nemo-agents/examples/nemo-agent-config/nat-calculator/workflow.yml deleted file mode 100644 index b8299c8a6c..0000000000 --- a/plugins/nemo-agents/examples/nemo-agent-config/nat-calculator/workflow.yml +++ /dev/null @@ -1,26 +0,0 @@ -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/nemo-agent-config/nat-email-phishing/agent.yaml b/plugins/nemo-agents/examples/nemo-agent-config/nat-email-phishing/agent.yaml index e4bed5d1fc..2df214c58b 100644 --- a/plugins/nemo-agents/examples/nemo-agent-config/nat-email-phishing/agent.yaml +++ b/plugins/nemo-agents/examples/nemo-agent-config/nat-email-phishing/agent.yaml @@ -8,7 +8,20 @@ harnesses: nat: kind: nat settings: - config_file: ./workflow.yml + workflow: react + tools: + - email_phishing_analyzer + instructions: The final response should indicate that the email is either "phishing" or "benign". + +models: + default: + provider: openai + 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_tokens: 1024 environment: workspace: ./workspace diff --git a/plugins/nemo-agents/examples/nemo-agent-config/nat-email-phishing/workflow.yml b/plugins/nemo-agents/examples/nemo-agent-config/nat-email-phishing/workflow.yml deleted file mode 100644 index d8751be218..0000000000 --- a/plugins/nemo-agents/examples/nemo-agent-config/nat-email-phishing/workflow.yml +++ /dev/null @@ -1,23 +0,0 @@ -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/src/nemo_agents_plugin/agent_config.py b/plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py index 9c1319f404..a551fbee0d 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py @@ -42,6 +42,24 @@ class HarnessConfig(BaseModel): settings: dict[str, Any] = Field(default_factory=dict) +class NatHarnessSettings(BaseModel): + """Platform-owned NAT workflow settings supported by the Fabric adapter.""" + + model_config = ConfigDict(extra="forbid") + + workflow: Literal["react", "current_timezone"] + tools: list[Literal["calculator", "current_datetime", "email_phishing_analyzer"]] = Field(default_factory=list) + instructions: str | None = Field(default=None, min_length=1) + + @model_validator(mode="after") + def _validate_workflow(self) -> Self: + if len(self.tools) != len(set(self.tools)): + raise ValueError("tools must not contain duplicates") + if self.workflow == "current_timezone" and (self.tools or self.instructions): + raise ValueError("current_timezone does not accept tools or instructions") + return self + + class EnvironmentConfig(BaseModel): model_config = ConfigDict(extra="forbid") 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 index 80e039c16f..f7d4a28e01 100644 --- 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 @@ -4,7 +4,7 @@ """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. +Fabric runtime. Platform's normalized Fabric config is the source of truth. """ from __future__ import annotations @@ -15,10 +15,10 @@ import shlex from collections.abc import AsyncIterator from contextlib import AsyncExitStack, asynccontextmanager -from pathlib import Path from typing import Any import nemo_fabric_adapters.common.utils as common_utils # ty: ignore[unresolved-import] +from nemo_agents_plugin.agent_config import NatHarnessSettings from nemo_fabric_adapters.common import lifecycle # ty: ignore[unresolved-import] from pydantic import ValidationError from pydantic_core import to_jsonable_python @@ -27,6 +27,23 @@ HARNESS = "nat" MODE = "nat_workflow" FUNCTION_GROUP_SEPARATOR = "__" +DEFAULT_LLM_NAME = "default" +PROVIDER_TO_NAT_LLM = { + "nim": "nim", + "nvidia": "nim", + "openai": "openai", +} +TOOL_COMPONENTS: dict[str, tuple[str, dict[str, Any]]] = { + "calculator": ("function_groups", {"_type": "calculator"}), + "current_datetime": ("functions", {"_type": "current_datetime"}), + "email_phishing_analyzer": ( + "functions", + { + "_type": "email_phishing_analyzer", + "llm": DEFAULT_LLM_NAME, + }, + ), +} RESERVED_MODEL_SETTINGS = frozenset( { "_type", @@ -47,49 +64,6 @@ def main() -> None: 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 adapter cannot map.""" @@ -109,8 +83,7 @@ def validate_supported_fabric_config(payload: dict[str, Any]) -> None: fields = sorted(set(unsupported)) raise lifecycle.LifecycleError( "nat_unsupported_fabric_config", - f"NAT adapter does not map normalized Fabric config fields: {', '.join(fields)}; " - "configure them in the NAT config file", + f"NAT adapter does not map normalized Fabric config fields: {', '.join(fields)}", metadata={"fields": fields}, ) @@ -130,80 +103,144 @@ def _fabric_models(payload: dict[str, Any]) -> dict[str, Any]: return models -def apply_nat_models(config: Any, payload: dict[str, Any]) -> None: - """Overlay normalized Fabric models onto existing typed NAT LLM entries.""" +def _nat_settings(payload: dict[str, Any]) -> NatHarnessSettings: + """Validate the Platform-owned NAT harness contract.""" + + try: + return NatHarnessSettings.model_validate(common_utils.settings_payload(payload)) + except ValidationError as error: + raise lifecycle.LifecycleError( + "nat_invalid_harness_settings", + "NAT harness settings are invalid", + ) from error - for llm_alias, model in sorted(_fabric_models(payload).items()): - if not isinstance(llm_alias, str) or not llm_alias or not isinstance(model, dict): + +def _nat_llm_config(payload: dict[str, Any]) -> dict[str, Any]: + """Translate the normalized default Fabric model into a native NAT LLM config.""" + + models = _fabric_models(payload) + if set(models) != {DEFAULT_LLM_NAME}: + raise lifecycle.LifecycleError( + "nat_invalid_models", + "NAT react workflows require exactly one Fabric model named 'default'", + metadata={"models": sorted(models)}, + ) + + model = models[DEFAULT_LLM_NAME] + if not isinstance(model, dict): + raise lifecycle.LifecycleError( + "nat_invalid_models", + "Fabric model 'default' must be a mapping", + ) + + provider = model.get("provider") + if not isinstance(provider, str) or provider not in PROVIDER_TO_NAT_LLM: + raise lifecycle.LifecycleError( + "nat_model_provider_unsupported", + "NAT supports Fabric model providers: nim, nvidia, openai", + metadata={"provider": provider}, + ) + + model_name = model.get("model") + if not isinstance(model_name, str) or not model_name: + raise lifecycle.LifecycleError( + "nat_invalid_models", + "Fabric model 'default' requires a non-empty model", + ) + + settings = model.get("settings") or {} + if not isinstance(settings, dict): + raise lifecycle.LifecycleError( + "nat_invalid_models", + "Fabric model 'default' settings must be a mapping", + ) + reserved = sorted(RESERVED_MODEL_SETTINGS.intersection(settings)) + if reserved: + raise lifecycle.LifecycleError( + "nat_model_settings_reserved", + "Fabric model 'default' settings cannot replace normalized or NAT type fields", + metadata={"llm": DEFAULT_LLM_NAME, "fields": reserved}, + ) + + values = dict(settings) + values["_type"] = PROVIDER_TO_NAT_LLM[provider] + values["model_name"] = model_name + + temperature = model.get("temperature") + if temperature is not None: + values["temperature"] = temperature + + api_key_env = model.get("api_key_env") + if api_key_env is not None: + if not isinstance(api_key_env, str) or not api_key_env: raise lifecycle.LifecycleError( "nat_invalid_models", - "Fabric model aliases must be non-empty strings with model configurations", + "Fabric model 'default' api_key_env must be a non-empty string", ) - - native_llm = config.llms.get(llm_alias) - if native_llm is None: + api_key = os.environ.get(api_key_env) + if not api_key: raise lifecycle.LifecycleError( - "nat_model_alias_not_found", - f"Fabric model alias {llm_alias!r} does not match an existing NAT llms entry", - metadata={"llm": llm_alias}, + "nat_model_api_key_missing", + f"Fabric model 'default' requires environment variable {api_key_env!r}", + metadata={"llm": DEFAULT_LLM_NAME, "api_key_env": api_key_env}, ) + values["api_key"] = api_key + return values - model_name = model.get("model") - if not isinstance(model_name, str) or not model_name: - raise lifecycle.LifecycleError( - "nat_invalid_models", - f"Fabric model alias {llm_alias!r} requires a non-empty model", - metadata={"llm": llm_alias}, - ) - settings = model.get("settings") or {} - if not isinstance(settings, dict): +def build_nat_config(payload: dict[str, Any]) -> Any: + """Translate normalized Fabric config into a validated typed NAT Config.""" + + settings = _nat_settings(payload) + raw_config: dict[str, Any] = {} + + if settings.workflow == "current_timezone": + if _fabric_models(payload): raise lifecycle.LifecycleError( "nat_invalid_models", - f"Fabric model alias {llm_alias!r} settings must be a mapping", - metadata={"llm": llm_alias}, - ) - reserved = sorted(RESERVED_MODEL_SETTINGS.intersection(settings)) - if reserved: - raise lifecycle.LifecycleError( - "nat_model_settings_reserved", - f"Fabric model alias {llm_alias!r} settings cannot replace normalized or NAT type fields", - metadata={"llm": llm_alias, "fields": reserved}, + "NAT current_timezone workflow does not accept Fabric models", ) + raw_config["workflow"] = {"_type": "current_timezone"} + else: + functions: dict[str, Any] = {} + function_groups: dict[str, Any] = {} + for tool_name in settings.tools: + section, component = TOOL_COMPONENTS[tool_name] + target = functions if section == "functions" else function_groups + target[tool_name] = dict(component) + + raw_config.update( + { + "functions": functions, + "function_groups": function_groups, + "llms": { + DEFAULT_LLM_NAME: _nat_llm_config(payload), + }, + "workflow": { + "_type": "react_agent", + "tool_names": list(settings.tools), + "llm_name": DEFAULT_LLM_NAME, + "verbose": False, + "parse_agent_response_max_retries": 3, + "use_native_tool_calling": True, + }, + } + ) + if settings.instructions is not None: + raw_config["workflow"]["additional_instructions"] = settings.instructions - values = native_llm.model_dump(mode="python", by_alias=True) - values.update(settings) - values["model"] = model_name - - temperature = model.get("temperature") - if temperature is not None: - values["temperature"] = temperature - - api_key_env = model.get("api_key_env") - if api_key_env is not None: - if not isinstance(api_key_env, str) or not api_key_env: - raise lifecycle.LifecycleError( - "nat_invalid_models", - f"Fabric model alias {llm_alias!r} api_key_env must be a non-empty string", - metadata={"llm": llm_alias}, - ) - api_key = os.environ.get(api_key_env) - if not api_key: - raise lifecycle.LifecycleError( - "nat_model_api_key_missing", - f"Fabric model alias {llm_alias!r} requires environment variable {api_key_env!r}", - metadata={"llm": llm_alias, "api_key_env": api_key_env}, - ) - values["api_key"] = api_key + from nat.data_models.config import Config + from nat.runtime.loader import PluginTypes, discover_and_register_plugins + from nat.utils.data_models.schema_validator import validate_schema - try: - config.llms[llm_alias] = type(native_llm).model_validate(values) - except ValidationError as error: - raise lifecycle.LifecycleError( - "nat_model_override_invalid", - f"Fabric model alias {llm_alias!r} is invalid for the existing NAT LLM type", - metadata={"llm": llm_alias}, - ) from error + discover_and_register_plugins(PluginTypes.CONFIG_OBJECT) + try: + return validate_schema(raw_config, Config) + except ValueError as error: + raise lifecycle.LifecycleError( + "nat_config_translation_failed", + "Normalized Fabric config could not be translated into a valid NAT config", + ) from error def _nat_mcp_server_config(name: str, server: Any) -> Any: @@ -349,23 +386,13 @@ def apply_nat_capabilities(config: Any, payload: dict[str, Any]) -> None: @asynccontextmanager -async def load_nat_workflow(config_file: Path, payload: dict[str, Any]) -> AsyncIterator[Any]: - """Load one NAT workflow, applying Fabric capabilities before it is built.""" - - native = _native_capabilities(payload) - if not _fabric_models(payload) and not native.get("mcp_servers") and not common_utils.blocked_tools(payload): - from nat.runtime.loader import load_workflow - - async with load_workflow(config_file) as sessions: - yield sessions - return +async def load_nat_workflow(payload: dict[str, Any]) -> AsyncIterator[Any]: + """Build one NAT workflow from normalized Fabric config.""" from nat.builder.workflow_builder import WorkflowBuilder - from nat.runtime.loader import load_config from nat.runtime.session import SessionManager - config = load_config(config_file) - apply_nat_models(config, payload) + config = build_nat_config(payload) apply_nat_capabilities(config, payload) async with WorkflowBuilder.from_config(config=config) as builder: sessions = await SessionManager.create(config=config, shared_builder=builder) @@ -459,11 +486,10 @@ async def start(self, payload: dict[str, Any]) -> None: runtime_id = _runtime_id(payload) validate_supported_fabric_config(payload) - config_file = resolve_config_file(payload) stack = AsyncExitStack() try: - sessions = await stack.enter_async_context(load_nat_workflow(config_file, payload)) + sessions = await stack.enter_async_context(load_nat_workflow(payload)) except asyncio.CancelledError: await _close_after_failed_start(stack) raise @@ -476,7 +502,6 @@ async def start(self, payload: dict[str, Any]) -> None: 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 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 41ab467990..805ae2d231 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py @@ -9,7 +9,8 @@ # 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 +from nemo_agents_plugin.agent_config import AgentConfig, HarnessConfig, ModelConfig, NatHarnessSettings +from pydantic import ValidationError HARNESS_ADAPTER_IDS = { "claude": "nvidia.fabric.claude", @@ -31,8 +32,10 @@ def translate_agent_config(config: AgentConfig, harness_name: str | None = None) model: ModelConfig | None = None models: dict[str, fabric.ModelConfig | dict[str, Any]] = {} if harness.kind == "nat": - _validate_nat_harness(config, selected_harness_name, harness) - models.update(_nat_models(config, selected_harness_name, harness)) + nat_settings = _validate_nat_harness(config, selected_harness_name, harness) + if nat_settings.workflow == "react": + model = _resolve_model(config, selected_harness_name, harness) + models["default"] = fabric.ModelConfig(**_model_payload(model)) else: model = _resolve_model(config, selected_harness_name, harness) models["default"] = fabric.ModelConfig(**_model_payload(model)) @@ -92,59 +95,25 @@ 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: - raise FabricTranslationError( - f"NAT harness {harness_name!r} cannot define an inline Platform model; " - "use settings.llm_map to reference top-level models." - ) - - 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 _nat_models( +def _validate_nat_harness( config: AgentConfig, harness_name: str, harness: HarnessConfig, -) -> dict[str, fabric.ModelConfig]: - llm_map = harness.settings.get("llm_map") - if llm_map is None: - return {} - if not isinstance(llm_map, dict): - raise FabricTranslationError(f"NAT harness {harness_name!r} settings.llm_map must be a mapping.") - - models: dict[str, fabric.ModelConfig] = {} - for llm_alias, model_alias in llm_map.items(): - if not isinstance(llm_alias, str) or not llm_alias: - raise FabricTranslationError( - f"NAT harness {harness_name!r} settings.llm_map keys must be non-empty NAT LLM aliases." - ) - if not isinstance(model_alias, str) or not model_alias: - raise FabricTranslationError( - f"NAT harness {harness_name!r} settings.llm_map values must be non-empty Platform model aliases." - ) - - model = config.models.get(model_alias) - if model is None: - raise FabricTranslationError( - f"NAT harness {harness_name!r} settings.llm_map references unknown Platform model {model_alias!r}." - ) - models[llm_alias] = fabric.ModelConfig(**_model_payload(model)) - return models +) -> NatHarnessSettings: + try: + settings = NatHarnessSettings.model_validate(harness.settings) + except ValidationError as error: + raise FabricTranslationError(f"NAT harness {harness_name!r} has invalid settings: {error}") from error + + if settings.workflow == "current_timezone" and harness.model is not None: + raise FabricTranslationError(f"NAT harness {harness_name!r} current_timezone workflow does not accept a model.") + if config.telemetry.enabled: + raise FabricTranslationError(f"NAT harness {harness_name!r} does not map Platform telemetry.") + return settings def _harness_settings(harness: HarnessConfig) -> dict[str, Any]: - settings = dict(harness.settings) - if harness.kind == "nat": - settings.pop("llm_map", None) - return settings + return dict(harness.settings) def _model_payload(model: ModelConfig) -> dict[str, Any]: diff --git a/plugins/nemo-agents/tests/integration/test_fabric_nat_adapter.py b/plugins/nemo-agents/tests/integration/test_fabric_nat_adapter.py index aa45df07d3..031c4e3c55 100644 --- a/plugins/nemo-agents/tests/integration/test_fabric_nat_adapter.py +++ b/plugins/nemo-agents/tests/integration/test_fabric_nat_adapter.py @@ -16,23 +16,7 @@ @pytest.mark.asyncio async def test_platform_invokes_installed_nat_adapter_without_local_descriptor( tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, ) -> None: - (tmp_path / "workflow.yml").write_text( - "\n".join( - [ - "llms:", - " llm:", - " _type: nim", - " model_name: native-model", - "workflow:", - " _type: current_timezone", - "", - ] - ), - encoding="utf-8", - ) - monkeypatch.setenv("NVIDIA_API_KEY", "test-key") agent_config = AgentConfig.model_validate( { "config_format": "nemo-agents-spec-v1", @@ -42,21 +26,10 @@ async def test_platform_invokes_installed_nat_adapter_without_local_descriptor( "nat": { "kind": "nat", "settings": { - "config_file": "./workflow.yml", - "llm_map": { - "llm": "nat_default", - }, + "workflow": "current_timezone", }, } }, - "models": { - "nat_default": { - "provider": "nvidia", - "model": "platform-model", - "api_key_env": "NVIDIA_API_KEY", - "temperature": 0.0, - } - }, "environment": { "workspace": "./workspace", "artifacts": "./artifacts", diff --git a/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter.py b/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter.py index 372bfcf879..5a311e1626 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter.py @@ -10,7 +10,9 @@ from typing import Any import pytest +from nemo_agents_plugin.agent_config import load_agent_config from nemo_agents_plugin.fabric.adapters.nat import adapter as nat_adapter +from nemo_agents_plugin.fabric.translator import translate_agent_config from nemo_fabric_adapters.common import lifecycle # ty: ignore[unresolved-import] @@ -19,7 +21,7 @@ def __init__(self, result: Any = "done", error: Exception | None = None) -> None self.result_value = result self.error = error - async def __aenter__(self) -> "_FakeRunner": + async def __aenter__(self) -> _FakeRunner: return self async def __aexit__( @@ -37,10 +39,10 @@ async def result(self) -> Any: class _FakeSession: - def __init__(self, sessions: "_FakeSessions") -> None: + def __init__(self, sessions: _FakeSessions) -> None: self.sessions = sessions - async def __aenter__(self) -> "_FakeSession": + async def __aenter__(self) -> _FakeSession: return self async def __aexit__( @@ -89,15 +91,19 @@ async def __aexit__( self.exited = True -def _start_payload(base_dir: Path, *, runtime_id: str = "runtime-1") -> dict[str, Any]: +def _start_payload( + *, + settings: dict[str, Any] | None = None, + models: dict[str, Any] | None = None, + runtime_id: str = "runtime-1", +) -> dict[str, Any]: return { - "base_dir": str(base_dir), + "base_dir": "/tmp/agent", "config": { "harness": { - "settings": { - "config_file": "./workflow.yml", - } - } + "settings": settings or {"workflow": "current_timezone"}, + }, + "models": models or {}, }, "runtime_context": { "runtime_id": runtime_id, @@ -105,6 +111,33 @@ def _start_payload(base_dir: Path, *, runtime_id: str = "runtime-1") -> dict[str } +def _react_payload( + *, + tools: list[str], + provider: str = "nvidia", + model_settings: dict[str, Any] | None = None, + instructions: str | None = None, +) -> dict[str, Any]: + settings: dict[str, Any] = { + "workflow": "react", + "tools": tools, + } + if instructions is not None: + settings["instructions"] = instructions + return _start_payload( + settings=settings, + models={ + "default": { + "provider": provider, + "model": "platform-model", + "api_key_env": "NVIDIA_API_KEY", + "temperature": 0.1, + "settings": model_settings or {}, + } + }, + ) + + def _invoke_payload(*, runtime_id: str = "runtime-1") -> dict[str, Any]: return { "runtime_context": { @@ -121,24 +154,14 @@ def _invoke_payload(*, runtime_id: str = "runtime-1") -> dict[str, Any]: } -@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: +async def test_runtime_owns_nat_workflow_across_invoke(monkeypatch: pytest.MonkeyPatch) -> None: sessions = _FakeSessions(runner=_FakeRunner(result={"answer": "hello"})) workflow = _FakeWorkflowContext(sessions) - monkeypatch.setattr("nat.runtime.loader.load_workflow", lambda path: workflow) + monkeypatch.setattr(nat_adapter, "load_nat_workflow", lambda payload: workflow) runtime = nat_adapter.NatRuntime() - await runtime.start(_start_payload(nat_config.parent)) + await runtime.start(_start_payload()) output = await runtime.invoke(_invoke_payload()) await runtime.stop() @@ -171,16 +194,13 @@ async def test_runtime_owns_nat_workflow_across_invoke( @pytest.mark.asyncio -async def test_invoke_failure_is_normalized_without_exception_details( - nat_config: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: +async def test_invoke_failure_is_normalized_without_exception_details(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) + monkeypatch.setattr(nat_adapter, "load_nat_workflow", lambda payload: workflow) runtime = nat_adapter.NatRuntime() - await runtime.start(_start_payload(nat_config.parent)) + await runtime.start(_start_payload()) output = await runtime.invoke(_invoke_payload()) await runtime.stop() @@ -195,16 +215,13 @@ async def test_invoke_failure_is_normalized_without_exception_details( @pytest.mark.asyncio -async def test_start_failure_is_actionable_and_stop_remains_safe( - nat_config: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: +async def test_start_failure_is_actionable_and_stop_remains_safe(monkeypatch: pytest.MonkeyPatch) -> None: workflow = _FakeWorkflowContext(_FakeSessions(), enter_error=RuntimeError("invalid workflow")) - monkeypatch.setattr("nat.runtime.loader.load_workflow", lambda path: workflow) + monkeypatch.setattr(nat_adapter, "load_nat_workflow", lambda payload: workflow) runtime = nat_adapter.NatRuntime() with pytest.raises(lifecycle.LifecycleError) as error_info: - await runtime.start(_start_payload(nat_config.parent)) + await runtime.start(_start_payload()) assert error_info.value.code == "nat_workflow_start_failed" assert "invalid workflow" not in error_info.value.message @@ -212,23 +229,22 @@ async def test_start_failure_is_actionable_and_stop_remains_safe( @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) +async def test_runtime_rejects_second_start(monkeypatch: pytest.MonkeyPatch) -> None: + workflow = _FakeWorkflowContext(_FakeSessions()) + monkeypatch.setattr(nat_adapter, "load_nat_workflow", lambda payload: workflow) + runtime = nat_adapter.NatRuntime() + await runtime.start(_start_payload()) with pytest.raises(lifecycle.LifecycleError) as error_info: - await nat_adapter.NatRuntime().start(payload) + await runtime.start(_start_payload()) - assert error_info.value.code == "nat_config_file_outside_base_dir" + assert error_info.value.code == "nat_runtime_already_started" + await runtime.stop() @pytest.mark.asyncio -async def test_unsupported_normalized_fabric_fields_are_rejected(nat_config: Path) -> None: - payload = _start_payload(nat_config.parent) +async def test_unsupported_normalized_fabric_fields_are_rejected() -> None: + payload = _start_payload() payload["config"]["telemetry"] = {"providers": {"custom": {"enabled": True}}} with pytest.raises(lifecycle.LifecycleError) as error_info: @@ -238,146 +254,145 @@ async def test_unsupported_normalized_fabric_fields_are_rejected(nat_config: Pat assert error_info.value.metadata == {"fields": ["telemetry"]} -def test_fabric_model_overrides_existing_typed_nat_llm(monkeypatch: pytest.MonkeyPatch) -> None: - from nat.llm.nim_llm import NIMModelConfig +def test_calculator_config_is_built_from_fabric(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("NVIDIA_API_KEY", "secret-value") + payload = _react_payload( + tools=["calculator", "current_datetime"], + model_settings={ + "base_url": "https://platform.example/v1", + "max_tokens": 777, + "chat_template_kwargs": {"enable_thinking": False}, + }, + ) + + config = nat_adapter.build_nat_config(payload) + + assert set(config.function_groups) == {"calculator"} + assert set(config.functions) == {"current_datetime"} + assert config.workflow.tool_names == ["calculator", "current_datetime"] + assert config.workflow.llm_name == "default" + assert config.workflow.use_native_tool_calling is True + llm = config.llms["default"] + assert llm.model_name == "platform-model" + assert llm.api_key.get_secret_value() == "secret-value" + assert llm.temperature == 0.1 + assert llm.base_url == "https://platform.example/v1" + assert llm.max_tokens == 777 + assert llm.chat_template_kwargs == {"enable_thinking": False} + - native_llm = NIMModelConfig( - model_name="native-model", - base_url="https://native.example/v1", - max_tokens=777, - temperature=0.8, - chat_template_kwargs={"enable_thinking": False}, +def test_phishing_config_is_built_from_fabric(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("NVIDIA_API_KEY", "secret-value") + payload = _react_payload( + tools=["email_phishing_analyzer"], + provider="openai", + model_settings={ + "base_url": "https://integrate.api.nvidia.com/v1", + "max_tokens": 1024, + }, + instructions='Classify the email as "phishing" or "benign".', ) - config = SimpleNamespace(llms={"llm": native_llm}) - payload = { - "config": { - "models": { - "llm": { - "provider": "nvidia", - "model": "platform-model", - "api_key_env": "NVIDIA_API_KEY", - "temperature": 0.1, - "settings": { - "base_url": "https://platform.example/v1", - "top_p": 0.7, - }, - } - } - } - } + + config = nat_adapter.build_nat_config(payload) + + assert set(config.functions) == {"email_phishing_analyzer"} + assert config.functions["email_phishing_analyzer"].llm == "default" + assert config.workflow.tool_names == ["email_phishing_analyzer"] + assert config.workflow.additional_instructions == 'Classify the email as "phishing" or "benign".' + assert config.llms["default"].base_url == "https://integrate.api.nvidia.com/v1" + + +@pytest.mark.parametrize( + ("example_name", "expected_tools"), + [ + ("nat-calculator", ["calculator", "current_datetime"]), + ("nat-email-phishing", ["email_phishing_analyzer"]), + ], +) +def test_repository_agent_yaml_builds_typed_nat_config( + example_name: str, + expected_tools: list[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + example_path = Path(__file__).parents[2] / "examples/nemo-agent-config" / example_name / "agent.yaml" + agent_config = load_agent_config(example_path) + fabric_config = translate_agent_config(agent_config) monkeypatch.setenv("NVIDIA_API_KEY", "secret-value") - nat_adapter.apply_nat_models(config, payload) + nat_config = nat_adapter.build_nat_config({"config": fabric_config.to_mapping()}) - updated = config.llms["llm"] - assert isinstance(updated, NIMModelConfig) - assert updated is not native_llm - assert updated.model_name == "platform-model" - assert updated.api_key.get_secret_value() == "secret-value" - assert updated.temperature == 0.1 - assert updated.base_url == "https://platform.example/v1" - assert updated.top_p == 0.7 - assert updated.max_tokens == 777 - assert updated.chat_template_kwargs == {"enable_thinking": False} + assert nat_config.workflow.tool_names == expected_tools + assert set(nat_config.llms) == {"default"} -def test_fabric_model_requires_existing_nat_llm_alias() -> None: - config = SimpleNamespace(llms={}) - payload = { - "config": { - "models": { - "missing": { - "provider": "nvidia", - "model": "platform-model", - } +def test_native_nat_config_fields_are_rejected() -> None: + payload = _start_payload(settings={"config_file": "./workflow.yml"}) + + with pytest.raises(lifecycle.LifecycleError) as error_info: + nat_adapter.build_nat_config(payload) + + assert error_info.value.code == "nat_invalid_harness_settings" + + +def test_current_timezone_rejects_fabric_models() -> None: + payload = _start_payload( + models={ + "default": { + "provider": "nvidia", + "model": "unused", } } - } + ) with pytest.raises(lifecycle.LifecycleError) as error_info: - nat_adapter.apply_nat_models(config, payload) + nat_adapter.build_nat_config(payload) - assert error_info.value.code == "nat_model_alias_not_found" - assert error_info.value.metadata == {"llm": "missing"} + assert error_info.value.code == "nat_invalid_models" def test_fabric_model_requires_configured_api_key_environment(monkeypatch: pytest.MonkeyPatch) -> None: - from nat.llm.nim_llm import NIMModelConfig - - config = SimpleNamespace(llms={"llm": NIMModelConfig(model_name="native-model")}) - payload = { - "config": { - "models": { - "llm": { - "provider": "nvidia", - "model": "platform-model", - "api_key_env": "MISSING_API_KEY", - } - } - } - } - monkeypatch.delenv("MISSING_API_KEY", raising=False) + payload = _react_payload(tools=["calculator"]) + monkeypatch.delenv("NVIDIA_API_KEY", raising=False) with pytest.raises(lifecycle.LifecycleError) as error_info: - nat_adapter.apply_nat_models(config, payload) + nat_adapter.build_nat_config(payload) assert error_info.value.code == "nat_model_api_key_missing" assert error_info.value.metadata == { - "llm": "llm", - "api_key_env": "MISSING_API_KEY", + "llm": "default", + "api_key_env": "NVIDIA_API_KEY", } -def test_fabric_model_settings_cannot_replace_native_llm_contract() -> None: - from nat.llm.nim_llm import NIMModelConfig - - config = SimpleNamespace(llms={"llm": NIMModelConfig(model_name="native-model")}) - payload = { - "config": { - "models": { - "llm": { - "provider": "nvidia", - "model": "platform-model", - "settings": { - "_type": "openai", - "model_name": "settings-model", - }, - } - } - } - } +def test_fabric_model_settings_cannot_replace_nat_contract(monkeypatch: pytest.MonkeyPatch) -> None: + payload = _react_payload( + tools=["calculator"], + model_settings={ + "_type": "openai", + "model_name": "settings-model", + }, + ) + monkeypatch.setenv("NVIDIA_API_KEY", "secret-value") with pytest.raises(lifecycle.LifecycleError) as error_info: - nat_adapter.apply_nat_models(config, payload) + nat_adapter.build_nat_config(payload) assert error_info.value.code == "nat_model_settings_reserved" assert error_info.value.metadata == { - "llm": "llm", + "llm": "default", "fields": ["_type", "model_name"], } -def test_invalid_fabric_model_override_is_normalized() -> None: - from nat.llm.nim_llm import NIMModelConfig - - config = SimpleNamespace(llms={"llm": NIMModelConfig(model_name="native-model")}) - payload = { - "config": { - "models": { - "llm": { - "provider": "nvidia", - "model": "platform-model", - "temperature": -1.0, - } - } - } - } +def test_unsupported_fabric_model_provider_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + payload = _react_payload(tools=["calculator"], provider="anthropic") + monkeypatch.setenv("NVIDIA_API_KEY", "secret-value") with pytest.raises(lifecycle.LifecycleError) as error_info: - nat_adapter.apply_nat_models(config, payload) + nat_adapter.build_nat_config(payload) - assert error_info.value.code == "nat_model_override_invalid" - assert error_info.value.metadata == {"llm": "llm"} + assert error_info.value.code == "nat_model_provider_unsupported" + assert error_info.value.metadata == {"provider": "anthropic"} def test_native_mcp_server_is_added_to_workflow_tools() -> None: @@ -432,12 +447,12 @@ def test_blocked_tools_remove_functions_and_filter_group_members() -> None: @pytest.mark.asyncio -async def test_skill_paths_remain_explicitly_unsupported(nat_config: Path) -> None: - payload = _start_payload(nat_config.parent) +async def test_skill_paths_remain_explicitly_unsupported() -> None: + payload = _start_payload() payload["config"]["skills"] = {"paths": ["./skills/review"]} payload["capability_plan"] = { "unsupported": { - "skill_paths": [str(nat_config.parent / "skills/review")], + "skill_paths": ["/tmp/agent/skills/review"], } } @@ -449,8 +464,8 @@ async def test_skill_paths_remain_explicitly_unsupported(nat_config: Path) -> No @pytest.mark.asyncio -async def test_fabric_managed_mcp_remains_explicitly_unsupported(nat_config: Path) -> None: - payload = _start_payload(nat_config.parent) +async def test_fabric_managed_mcp_remains_explicitly_unsupported() -> None: + payload = _start_payload() payload["config"]["mcp"] = { "servers": { "repo": { @@ -474,14 +489,11 @@ async def test_fabric_managed_mcp_remains_explicitly_unsupported(nat_config: Pat @pytest.mark.asyncio -async def test_runtime_rejects_mismatched_invocation( - nat_config: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: +async def test_runtime_rejects_mismatched_invocation(monkeypatch: pytest.MonkeyPatch) -> None: workflow = _FakeWorkflowContext(_FakeSessions()) - monkeypatch.setattr("nat.runtime.loader.load_workflow", lambda path: workflow) + monkeypatch.setattr(nat_adapter, "load_nat_workflow", lambda payload: workflow) runtime = nat_adapter.NatRuntime() - await runtime.start(_start_payload(nat_config.parent)) + await runtime.start(_start_payload()) with pytest.raises(lifecycle.LifecycleError) as error_info: await runtime.invoke(_invoke_payload(runtime_id="runtime-2")) @@ -491,14 +503,11 @@ async def test_runtime_rejects_mismatched_invocation( @pytest.mark.asyncio -async def test_stop_is_idempotent( - nat_config: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: +async def test_stop_is_idempotent(monkeypatch: pytest.MonkeyPatch) -> None: workflow = _FakeWorkflowContext(_FakeSessions()) - monkeypatch.setattr("nat.runtime.loader.load_workflow", lambda path: workflow) + monkeypatch.setattr(nat_adapter, "load_nat_workflow", lambda payload: workflow) runtime = nat_adapter.NatRuntime() - await runtime.start(_start_payload(nat_config.parent)) + await runtime.start(_start_payload()) await runtime.stop() await runtime.stop() diff --git a/plugins/nemo-agents/tests/unit/test_fabric_translator.py b/plugins/nemo-agents/tests/unit/test_fabric_translator.py index 88cd5f1ec6..61666e85fb 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_translator.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_translator.py @@ -84,25 +84,26 @@ def test_repository_example_configures_codex_hermes_and_nat_adapters(self) -> No assert hermes_config.harness.adapter_id == "nvidia.fabric.hermes" assert hermes_config.harness.settings["python_env"] == "HERMES_ADAPTER_PYTHON" assert nat_config.harness.adapter_id == "nvidia.nemo.platform.nat" - assert nat_config.models["llm"].provider == "nvidia" - assert nat_config.models["llm"].model == "nvidia/nemotron-3-nano-30b-a3b" - assert "llm_map" not in nat_config.harness.settings - nat_workflow = (example_path.parent / nat_config.harness.settings["config_file"]).resolve() - assert nat_workflow.is_file() - nat_workflow.relative_to(example_path.parent.resolve()) + assert nat_config.models["default"].provider == "nvidia" + assert nat_config.models["default"].model == "nvidia/nemotron-3-nano-30b-a3b" + assert nat_config.harness.settings == { + "workflow": "react", + "tools": ["calculator", "current_datetime"], + } @pytest.mark.parametrize("example_name", ["nat-calculator", "nat-email-phishing"]) - def test_repository_nat_examples_reference_colocated_workflows(self, example_name: str) -> None: + def test_repository_nat_examples_are_self_contained_agent_configs(self, example_name: str) -> None: example_dir = Path(__file__).parents[2] / "examples/nemo-agent-config" / example_name config = load_agent_config(example_dir / "agent.yaml") fabric_config = translate_agent_config(config) - config_file = fabric_config.harness.settings["config_file"] - workflow_path = (example_dir / config_file).resolve() assert fabric_config.harness.adapter_id == "nvidia.nemo.platform.nat" - assert workflow_path.is_file() - workflow_path.relative_to(example_dir.resolve()) + assert fabric_config.harness.settings["workflow"] == "react" + assert "config_file" not in fabric_config.harness.settings + assert "llm_map" not in fabric_config.harness.settings + assert not (example_dir / "workflow.yml").exists() + assert set(fabric_config.models) == {"default"} def test_translates_default_harness(self) -> None: config = AgentConfig.model_validate(_example_yaml_config()) @@ -183,7 +184,7 @@ def test_supported_harness_kinds_translate_to_adapter_ids( payload["harnesses"] = { "selected": { "kind": kind, - "settings": {"config_file": "./workflow.yml"} if kind == "nat" else {}, + "settings": {"workflow": "current_timezone"} if kind == "nat" else {}, } } if kind == "nat": @@ -194,13 +195,13 @@ def test_supported_harness_kinds_translate_to_adapter_ids( assert fabric_config.harness.adapter_id == adapter_id - def test_nat_harness_uses_native_config_without_platform_model(self) -> None: + def test_nat_current_timezone_does_not_require_model(self) -> None: payload = _example_yaml_config() payload["default_harness"] = "nat" payload["harnesses"] = { "nat": { "kind": "nat", - "settings": {"config_file": "./workflow.yml"}, + "settings": {"workflow": "current_timezone"}, } } payload["models"] = {} @@ -209,22 +210,12 @@ def test_nat_harness_uses_native_config_without_platform_model(self) -> None: 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.harness.settings == {"workflow": "current_timezone"} 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: + def test_nat_current_timezone_rejects_inline_model(self) -> None: payload = _example_yaml_config() payload["default_harness"] = "nat" payload["harnesses"] = { @@ -232,44 +223,59 @@ def test_nat_harness_rejects_inline_platform_model(self) -> None: "kind": "nat", "model": { "provider": "nvidia", - "model": "nvidia/example-model", + "model": "unused", }, - "settings": {"config_file": "./workflow.yml"}, + "settings": {"workflow": "current_timezone"}, } } + config = AgentConfig.model_validate(payload) + + with pytest.raises(FabricTranslationError, match="does not accept a model"): + translate_agent_config(config) + + def test_nat_harness_requires_supported_workflow(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="cannot define an inline Platform model"): + with pytest.raises(FabricTranslationError, match="invalid settings"): translate_agent_config(config) - def test_nat_harness_ignores_top_level_models_owned_by_other_harnesses(self) -> None: + def test_nat_react_harness_uses_inline_platform_model(self) -> None: payload = _example_yaml_config() payload["default_harness"] = "nat" payload["harnesses"] = { "nat": { "kind": "nat", - "settings": {"config_file": "./workflow.yml"}, + "model": { + "provider": "nvidia", + "model": "nvidia/example-model", + }, + "settings": { + "workflow": "react", + "tools": ["calculator"], + }, } } + payload["models"] = {} config = AgentConfig.model_validate(payload) fabric_config = translate_agent_config(config) - assert fabric_config.models == {} + assert fabric_config.models["default"].provider == "nvidia" + assert fabric_config.models["default"].model == "nvidia/example-model" - def test_nat_harness_maps_platform_models_to_nat_llm_aliases(self) -> None: + def test_nat_react_harness_uses_top_level_default_model(self) -> None: payload = _example_yaml_config() payload["default_harness"] = "nat" payload["harnesses"] = { "nat": { "kind": "nat", "settings": { - "config_file": "./workflow.yml", - "llm_map": { - "primary": "default", - "judge": "default", - }, + "workflow": "react", + "tools": ["calculator", "current_datetime"], }, } } @@ -277,64 +283,64 @@ def test_nat_harness_maps_platform_models_to_nat_llm_aliases(self) -> None: fabric_config = translate_agent_config(config) - assert set(fabric_config.models) == {"primary", "judge"} - assert fabric_config.models["primary"].provider == "openai" - assert fabric_config.models["primary"].model == "openai/gpt-5.4" - assert fabric_config.models["judge"] == fabric_config.models["primary"] - assert fabric_config.harness.settings == {"config_file": "./workflow.yml"} + assert set(fabric_config.models) == {"default"} + assert fabric_config.models["default"].provider == "openai" + assert fabric_config.models["default"].model == "openai/gpt-5.4" - def test_nat_harness_rejects_unknown_platform_model_alias(self) -> None: + def test_nat_react_harness_requires_platform_model(self) -> None: payload = _example_yaml_config() payload["default_harness"] = "nat" payload["harnesses"] = { "nat": { "kind": "nat", "settings": { - "config_file": "./workflow.yml", - "llm_map": {"llm": "missing"}, + "workflow": "react", + "tools": ["calculator"], }, } } + payload["models"] = {} config = AgentConfig.model_validate(payload) - with pytest.raises(FabricTranslationError, match="references unknown Platform model 'missing'"): + with pytest.raises(FabricTranslationError, match="no models.default is configured"): translate_agent_config(config) @pytest.mark.parametrize( - "llm_map", + "settings", [ - "default", - {"": "default"}, - {"llm": ""}, + {"config_file": "./workflow.yml"}, + {"workflow": "react", "llm_map": {"llm": "default"}}, + {"workflow": "native_workflow"}, + {"workflow": "react", "tools": ["unknown"]}, + {"workflow": "react", "tools": ["calculator", "calculator"]}, ], ) - def test_nat_harness_rejects_invalid_llm_map(self, llm_map: Any) -> None: + def test_nat_harness_rejects_native_or_invalid_settings(self, settings: dict[str, Any]) -> None: payload = _example_yaml_config() payload["default_harness"] = "nat" payload["harnesses"] = { "nat": { "kind": "nat", - "settings": { - "config_file": "./workflow.yml", - "llm_map": llm_map, - }, + "settings": settings, } } config = AgentConfig.model_validate(payload) - with pytest.raises(FabricTranslationError, match="llm_map"): + with pytest.raises(FabricTranslationError, match="invalid settings"): translate_agent_config(config) - def test_nat_harness_translates_shared_capabilities_without_platform_model(self) -> None: + def test_nat_harness_translates_shared_capabilities(self) -> None: payload = _example_yaml_config() payload["default_harness"] = "nat" payload["harnesses"] = { "nat": { "kind": "nat", - "settings": {"config_file": "./workflow.yml"}, + "settings": { + "workflow": "react", + "tools": ["calculator"], + }, } } - payload["models"] = {} payload["skills"] = {"paths": ["./skills/example"]} payload["mcp"] = { "servers": { @@ -349,7 +355,7 @@ def test_nat_harness_translates_shared_capabilities_without_platform_model(self) fabric_config = translate_agent_config(config) - assert fabric_config.models == {} + assert set(fabric_config.models) == {"default"} assert fabric_config.skills.paths == ["./skills/example"] assert fabric_config.mcp.servers["repo"].url == "http://localhost:9901/mcp" assert fabric_config.tools.blocked == ["calculator__divide"] @@ -360,7 +366,7 @@ def test_nat_harness_rejects_platform_telemetry(self) -> None: payload["harnesses"] = { "nat": { "kind": "nat", - "settings": {"config_file": "./workflow.yml"}, + "settings": {"workflow": "current_timezone"}, } } payload["models"] = {}