diff --git a/plugins/nemo-agents/examples/nemo-agent-config/README.md b/plugins/nemo-agents/examples/nemo-agent-config/README.md index f15d904b05..b1f9794f2b 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 +# Fabric-Backed Agent Examples -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. @@ -11,10 +11,110 @@ 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`. +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 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. `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. + +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: + 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 +``` + +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: + +```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 dedicated calculator config: + +```bash +export NVIDIA_API_KEY="" + +nemo agents invoke \ + --agent-config plugins/nemo-agents/examples/nemo-agent-config/nat-calculator/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/nemo-agent-config/nat-email-phishing/agent.yaml \ + --input "Subject: Verify your account. Send your password immediately." +``` + +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 +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. Put React-specific instructions in +`harnesses..settings.instructions`. + ## Codex Authenticate Codex, leave `default_harness: codex` in `agent.yaml`, and run: @@ -34,8 +134,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" diff --git a/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml b/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml index eb15cf8a28..658948e7e2 100644 --- a/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml +++ b/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml @@ -28,12 +28,27 @@ harnesses: system_prompt: You are a concise test assistant. config_overrides: 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: + workflow: react + tools: + - calculator + - current_datetime models: default: provider: openai model: openai/gpt-5.4 - 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 new file mode 100644 index 0000000000..3ed145563a --- /dev/null +++ b/plugins/nemo-agents/examples/nemo-agent-config/nat-calculator/agent.yaml @@ -0,0 +1,36 @@ +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: + 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: + - calculator__divide + +environment: + workspace: ./workspace + artifacts: ./artifacts + +telemetry: + enabled: false 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 new file mode 100644 index 0000000000..2df214c58b --- /dev/null +++ b/plugins/nemo-agents/examples/nemo-agent-config/nat-email-phishing/agent.yaml @@ -0,0 +1,31 @@ +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: + 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 + artifacts: ./artifacts + +telemetry: + enabled: false diff --git a/plugins/nemo-agents/pyproject.toml b/plugins/nemo-agents/pyproject.toml index e4ab5c05c9..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", @@ -65,6 +66,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 +96,9 @@ packages = [ "vendor/openclaw_agent_adapter/src/nat_openclaw_agent_adapter", ] +[tool.hatch.build.targets.wheel.shared-data] +"src/nemo_agents_plugin/fabric/adapters/nat/fabric-adapter.json" = "share/nemo-fabric/adapters/nemo-platform-nat/fabric-adapter.json" + [tool.uv.sources] nemo-platform = { workspace = true } @@ -105,6 +110,9 @@ nemo-agents-example-email-phishing = { workspace = true } [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" +markers = [ + "integration: high-level tests that exercise installed runtime boundaries", +] # "src" puts nemo_agents_plugin on sys.path. nemo-platform is resolved # from the workspace — run `uv sync` at the repo root before running these tests. pythonpath = ["src"] diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/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 new file mode 100644 index 0000000000..f7d4a28e01 --- /dev/null +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/adapters/nat/adapter.py @@ -0,0 +1,573 @@ +# 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. Platform's normalized Fabric config is the source of truth. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import shlex +from collections.abc import AsyncIterator +from contextlib import AsyncExitStack, asynccontextmanager +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 + +LOGGER = logging.getLogger(__name__) +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", + "type", + "provider", + "model", + "model_name", + "api_key", + "api_key_env", + "temperature", + } +) + + +def main() -> None: + """Serve the persistent local-host lifecycle protocol.""" + + lifecycle.serve(NatRuntime) + + +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 ("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 = sorted(set(unsupported)) + raise lifecycle.LifecycleError( + "nat_unsupported_fabric_config", + f"NAT adapter does not map normalized Fabric config fields: {', '.join(fields)}", + 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 _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 _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 + + +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 'default' api_key_env must be a non-empty string", + ) + api_key = os.environ.get(api_key_env) + if not api_key: + raise lifecycle.LifecycleError( + "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 + + +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", + "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 + + 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 + + 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: + 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(payload: dict[str, Any]) -> AsyncIterator[Any]: + """Build one NAT workflow from normalized Fabric config.""" + + from nat.builder.workflow_builder import WorkflowBuilder + from nat.runtime.session import SessionManager + + 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) + try: + yield sessions + finally: + await sessions.shutdown() + + +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) + stack = AsyncExitStack() + + try: + sessions = await stack.enter_async_context(load_nat_workflow(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) + raise lifecycle.LifecycleError( + "nat_workflow_start_failed", + "NAT workflow failed to load; inspect adapter stderr for details", + ) 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..7d5f68483b --- /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": ["models", "mcp", "tools", "tools.blocked"] + }, + "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..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,13 +9,15 @@ # 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", "codex": "nvidia.fabric.codex", "deepagents": "nvidia.fabric.langchain.deepagents", "hermes": "nvidia.fabric.hermes", + "nat": "nvidia.nemo.platform.nat", } @@ -26,19 +28,26 @@ 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": + 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)) fabric_config = fabric.FabricConfig( metadata=fabric.MetadataConfig(name=config.name, description=config.description or None), harness=fabric.HarnessConfig( adapter_id=_adapter_id_for_harness(harness), resolution="preinstalled", - settings=harness.settings, + settings=_harness_settings(harness), ), - models={ - "default": fabric.ModelConfig(**_model_payload(model)), - }, + models=models, environment=fabric.EnvironmentConfig( provider=config.environment.provider, workspace=config.environment.workspace, @@ -50,7 +59,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 +95,27 @@ def _resolve_model(config: AgentConfig, harness_name: str, harness: HarnessConfi return model +def _validate_nat_harness( + config: AgentConfig, + harness_name: str, + harness: HarnessConfig, +) -> 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]: + return dict(harness.settings) + + def _model_payload(model: ModelConfig) -> dict[str, Any]: return model.model_dump(exclude_none=True) @@ -96,13 +127,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( @@ -110,7 +141,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/integration/test_fabric_nat_adapter.py b/plugins/nemo-agents/tests/integration/test_fabric_nat_adapter.py new file mode 100644 index 0000000000..031c4e3c55 --- /dev/null +++ b/plugins/nemo-agents/tests/integration/test_fabric_nat_adapter.py @@ -0,0 +1,53 @@ +# 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: + agent_config = AgentConfig.model_validate( + { + "config_format": "nemo-agents-spec-v1", + "name": "nat-installed-discovery", + "default_harness": "nat", + "harnesses": { + "nat": { + "kind": "nat", + "settings": { + "workflow": "current_timezone", + }, + } + }, + "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..5a311e1626 --- /dev/null +++ b/plugins/nemo-agents/tests/unit/test_fabric_nat_adapter.py @@ -0,0 +1,515 @@ +# 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 types import SimpleNamespace +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] + + +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( + *, + settings: dict[str, Any] | None = None, + models: dict[str, Any] | None = None, + runtime_id: str = "runtime-1", +) -> dict[str, Any]: + return { + "base_dir": "/tmp/agent", + "config": { + "harness": { + "settings": settings or {"workflow": "current_timezone"}, + }, + "models": models or {}, + }, + "runtime_context": { + "runtime_id": runtime_id, + }, + } + + +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": { + "runtime_id": runtime_id, + }, + "request": { + "input": "hello", + "request_id": "request-1", + "context": { + "user_id": "user-1", + "conversation_id": "conversation-1", + }, + }, + } + + +@pytest.mark.asyncio +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_adapter, "load_nat_workflow", lambda payload: workflow) + runtime = nat_adapter.NatRuntime() + + await runtime.start(_start_payload()) + 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(monkeypatch: pytest.MonkeyPatch) -> None: + sessions = _FakeSessions(runner=_FakeRunner(error=RuntimeError("credential secret"))) + workflow = _FakeWorkflowContext(sessions) + monkeypatch.setattr(nat_adapter, "load_nat_workflow", lambda payload: workflow) + runtime = nat_adapter.NatRuntime() + + await runtime.start(_start_payload()) + 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(monkeypatch: pytest.MonkeyPatch) -> None: + workflow = _FakeWorkflowContext(_FakeSessions(), enter_error=RuntimeError("invalid 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()) + + 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_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 runtime.start(_start_payload()) + + assert error_info.value.code == "nat_runtime_already_started" + await runtime.stop() + + +@pytest.mark.asyncio +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: + await nat_adapter.NatRuntime().start(payload) + + assert error_info.value.code == "nat_unsupported_fabric_config" + assert error_info.value.metadata == {"fields": ["telemetry"]} + + +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} + + +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 = 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_config = nat_adapter.build_nat_config({"config": fabric_config.to_mapping()}) + + assert nat_config.workflow.tool_names == expected_tools + assert set(nat_config.llms) == {"default"} + + +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.build_nat_config(payload) + + assert error_info.value.code == "nat_invalid_models" + + +def test_fabric_model_requires_configured_api_key_environment(monkeypatch: pytest.MonkeyPatch) -> None: + payload = _react_payload(tools=["calculator"]) + monkeypatch.delenv("NVIDIA_API_KEY", raising=False) + + with pytest.raises(lifecycle.LifecycleError) as error_info: + nat_adapter.build_nat_config(payload) + + assert error_info.value.code == "nat_model_api_key_missing" + assert error_info.value.metadata == { + "llm": "default", + "api_key_env": "NVIDIA_API_KEY", + } + + +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.build_nat_config(payload) + + assert error_info.value.code == "nat_model_settings_reserved" + assert error_info.value.metadata == { + "llm": "default", + "fields": ["_type", "model_name"], + } + + +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.build_nat_config(payload) + + 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: + 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() -> None: + payload = _start_payload() + payload["config"]["skills"] = {"paths": ["./skills/review"]} + payload["capability_plan"] = { + "unsupported": { + "skill_paths": ["/tmp/agent/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() -> None: + payload = _start_payload() + 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(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 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(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()) + + 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..3682ac30e3 --- /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": ["models", "mcp", "tools", "tools.blocked"], + }, + "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..61666e85fb 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_translator.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_translator.py @@ -71,17 +71,39 @@ 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["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_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) + + assert fabric_config.harness.adapter_id == "nvidia.nemo.platform.nat" + 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()) @@ -149,6 +171,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 +181,201 @@ 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": {"workflow": "current_timezone"} 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_current_timezone_does_not_require_model(self) -> None: + payload = _example_yaml_config() + payload["default_harness"] = "nat" + payload["harnesses"] = { + "nat": { + "kind": "nat", + "settings": {"workflow": "current_timezone"}, + } + } + 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 == {"workflow": "current_timezone"} + assert fabric_config.models == {} + assert fabric_config.telemetry is None + assert fabric_config.relay is None + + def test_nat_current_timezone_rejects_inline_model(self) -> None: + payload = _example_yaml_config() + payload["default_harness"] = "nat" + payload["harnesses"] = { + "nat": { + "kind": "nat", + "model": { + "provider": "nvidia", + "model": "unused", + }, + "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="invalid settings"): + translate_agent_config(config) + + def test_nat_react_harness_uses_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": { + "workflow": "react", + "tools": ["calculator"], + }, + } + } + payload["models"] = {} + config = AgentConfig.model_validate(payload) + + fabric_config = translate_agent_config(config) + + assert fabric_config.models["default"].provider == "nvidia" + assert fabric_config.models["default"].model == "nvidia/example-model" + + 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": { + "workflow": "react", + "tools": ["calculator", "current_datetime"], + }, + } + } + config = AgentConfig.model_validate(payload) + + fabric_config = translate_agent_config(config) + + 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_react_harness_requires_platform_model(self) -> None: + payload = _example_yaml_config() + payload["default_harness"] = "nat" + payload["harnesses"] = { + "nat": { + "kind": "nat", + "settings": { + "workflow": "react", + "tools": ["calculator"], + }, + } + } + payload["models"] = {} + config = AgentConfig.model_validate(payload) + + with pytest.raises(FabricTranslationError, match="no models.default is configured"): + translate_agent_config(config) + + @pytest.mark.parametrize( + "settings", + [ + {"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_native_or_invalid_settings(self, settings: dict[str, Any]) -> None: + payload = _example_yaml_config() + payload["default_harness"] = "nat" + payload["harnesses"] = { + "nat": { + "kind": "nat", + "settings": settings, + } + } + config = AgentConfig.model_validate(payload) + + with pytest.raises(FabricTranslationError, match="invalid settings"): + translate_agent_config(config) + + def test_nat_harness_translates_shared_capabilities(self) -> None: + payload = _example_yaml_config() + payload["default_harness"] = "nat" + payload["harnesses"] = { + "nat": { + "kind": "nat", + "settings": { + "workflow": "react", + "tools": ["calculator"], + }, + } + } + 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) + + fabric_config = translate_agent_config(config) + + 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"] + + def test_nat_harness_rejects_platform_telemetry(self) -> None: + payload = _example_yaml_config() + payload["default_harness"] = "nat" + payload["harnesses"] = { + "nat": { + "kind": "nat", + "settings": {"workflow": "current_timezone"}, + } + } + 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..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')" }, ] @@ -3703,6 +3713,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 +3740,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" }, @@ -3736,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" }, @@ -7761,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"