From 6dfc901fe90bfd109d97cbb36a234192a4571d2e Mon Sep 17 00:00:00 2001 From: Yamini Date: Sat, 18 Jul 2026 10:19:17 -0400 Subject: [PATCH 1/3] spike(nemo-agents): NAT-to-Fabric agent transpile (Stage 1) Translates a NAT (NVIDIA NeMo Agent Toolkit) workflow config into a Fabric-native agent.yaml for the LangChain Deep Agents harness, rather than running NAT config through a permanent compatibility shim. - Walks the NAT composition graph; nested agents are preserved as Deep Agents subagents (with delegates_to), not flattened. Cycle-safe. - Carries mcp_client function groups to Fabric mcp.servers one-to-one (stdio + streamable-http). - Flags rather than guesses: OAuth2/custom-header auth (Deep Agents adapter gap), NAT builtins needing an MCP equivalent, env vars to set, dangling refs. - 21 pytest cases; output validated against the Fabric agent schema (structural check committed; full JSON Schema via --schema against a local Fabric checkout). Stage 1 only: emits and validates the config; does not run it on a Fabric runtime. Signed-off-by: Yamini --- .../nat-to-fabric-spike/MIGRATION_REPORT.md | 28 ++ .../examples/nat-to-fabric-spike/README.md | 57 +++ .../examples/nat-to-fabric-spike/conftest.py | 7 + .../nat-to-fabric-spike/fabric/agent.yaml | 60 +++ .../nat-to-fabric-spike/nat_agent/config.yml | 100 +++++ .../nat-to-fabric-spike/test_transpile.py | 252 +++++++++++ .../examples/nat-to-fabric-spike/transpile.py | 419 ++++++++++++++++++ 7 files changed, 923 insertions(+) create mode 100644 plugins/nemo-agents/examples/nat-to-fabric-spike/MIGRATION_REPORT.md create mode 100644 plugins/nemo-agents/examples/nat-to-fabric-spike/README.md create mode 100644 plugins/nemo-agents/examples/nat-to-fabric-spike/conftest.py create mode 100644 plugins/nemo-agents/examples/nat-to-fabric-spike/fabric/agent.yaml create mode 100644 plugins/nemo-agents/examples/nat-to-fabric-spike/nat_agent/config.yml create mode 100644 plugins/nemo-agents/examples/nat-to-fabric-spike/test_transpile.py create mode 100644 plugins/nemo-agents/examples/nat-to-fabric-spike/transpile.py diff --git a/plugins/nemo-agents/examples/nat-to-fabric-spike/MIGRATION_REPORT.md b/plugins/nemo-agents/examples/nat-to-fabric-spike/MIGRATION_REPORT.md new file mode 100644 index 0000000000..cf2050773e --- /dev/null +++ b/plugins/nemo-agents/examples/nat-to-fabric-spike/MIGRATION_REPORT.md @@ -0,0 +1,28 @@ +# NAT -> Fabric migration report + +**Status: ready after 4 manual step(s)** + +## MCP servers carried across +- mcp_math: streamable-http, url via env var +- mcp_time: stdio, no credentials +- mcp_jira: streamable-http, url via env var + +## Environment variables to set before running +- `CORPORATE_MCP_JIRA_URL` +- `MATH_MCP_URL` +- `NAT_REDIRECT_URI` + +## Auth requiring action (Fabric adapter gap) +- mcp_jira: NAT used auth_provider 'mcp_oauth2_jira' (_type mcp_oauth2). Deep Agents carries only ${ENV} URLs, so this needs a token-in-URL gateway or Fabric adapter OAuth2 support. + +## Builtin tools requiring an MCP equivalent +- `code_generation`: NAT in-process tool. Needs a prebuilt MCP server equivalent before it runs under Deep Agents. +- `current_timezone`: NAT in-process tool. Needs a prebuilt MCP server equivalent before it runs under Deep Agents. + +## Errors (must resolve) +- None. + +## Notes +- Unwrapped reasoning_agent onto 'research_orchestrator' as the main Deep Agent. +- Main agent 'react_agent' had no explicit system_prompt; its default lives in NAT Python and must be resolved via WorkflowBuilder. +- Main-agent direct tools (not sub-agents): code_generation. diff --git a/plugins/nemo-agents/examples/nat-to-fabric-spike/README.md b/plugins/nemo-agents/examples/nat-to-fabric-spike/README.md new file mode 100644 index 0000000000..522783a6da --- /dev/null +++ b/plugins/nemo-agents/examples/nat-to-fabric-spike/README.md @@ -0,0 +1,57 @@ +# NAT to Fabric transpile spike + +Stage 1 proof that an existing NAT (NVIDIA NeMo Agent Toolkit) agent can be migrated to a Fabric-native agent by translation, not by a permanent NAT compatibility shim. It reads a NAT workflow config and emits a Fabric `agent.yaml` for the LangChain Deep Agents harness, plus a migration report that carries MCP servers across and flags what needs auth. + +## Why + +A NAT agent can be moved to Fabric by translating it into a Fabric-native agent, rather than running its config through a permanent compatibility shim. This spike shows that path end to end for a non-trivial agent, and it makes the boundaries visible, including the tools that need more than a config rewrite. + +## What it does + +Input: `nat_agent/config.yml`, a deliberately non-trivial NAT topology. + +``` +reasoning_agent (workflow) + └─ research_orchestrator (react_agent) + ├─ math_agent (tool_calling_agent) -> mcp_math (streamable-http, ${ENV} url) + ├─ time_agent (tool_calling_agent) -> mcp_time (stdio) + current_timezone + ├─ jira_agent (tool_calling_agent) -> mcp_jira (streamable-http, OAuth2) + └─ code_generation (builtin) +``` + +Output: `fabric/agent.yaml` (Deep Agents harness) and `MIGRATION_REPORT.md`. + +The transpiler does four things: + +1. Walks the NAT composition graph. Sub-agents used as tools become Deep Agents subagents, so the topology survives instead of flattening to one agent. The `reasoning_agent` wrapper is unwrapped onto the executing agent. +2. Maps each NAT LLM to a Fabric model. The main agent's model becomes `models.default`; the rest are emitted as aliases. +3. Carries MCP servers across. NAT `mcp_client` function groups map one-to-one to Fabric `mcp.servers`. stdio becomes a command URL; streamable-http keeps its URL and any `${ENV}` reference. +4. Reports instead of guessing. Env-var URLs are listed for the user to set. NAT builtins are flagged as needing a prebuilt MCP equivalent. Auth that Fabric's adapter cannot carry today is called out. + +## Result + +Running the transpiler on the fixture produces a config that passes the Fabric contract check, carries all three MCP servers, preserves all three sub-agents, and flags one auth gap. See `MIGRATION_REPORT.md` for the generated findings. + +Two findings worth reading before anyone promises "seamless": + +- **Auth gap.** Fabric's Deep Agents adapter forwards only `transport` and `url` per MCP server. A `${ENV}` in the URL is the one credential path that reaches the server. NAT servers using OAuth2 providers or custom headers (the `mcp_jira` case here) cannot carry as-is. Closing that is Fabric adapter work. +- **Builtins.** NAT builtin tools (`current_timezone`, `code_generation`) are in-process Python, not MCP servers. Each needs a prebuilt MCP equivalent before it runs under Deep Agents. + +## Scope and honesty + +This is Stage 1. It does not run the migrated agent against a live Fabric runtime; that is Stage 2 and needs a Fabric environment plus an API key. + +The transpiler reads the NAT YAML directly so it runs with only PyYAML. A production version resolves the config through NAT's `WorkflowBuilder.from_config()` to also recover default prompts (which live in NAT's Python, not the YAML) and each tool's resolved schema. The one place this shows up is the main agent's `system_prompt`, emitted here as a `[RESOLVE]` marker. + +Validation is a self-contained structural check of the Fabric contract. For full JSON Schema validation, point `--schema` at a local Fabric checkout: + +```bash +python3 transpile.py --schema /path/to/nemo-fabric/schemas/agent.schema.json +``` + +## Run + +```bash +pip install pyyaml # jsonschema optional, only for --schema +python3 transpile.py # reads nat_agent/config.yml, writes fabric/agent.yaml + MIGRATION_REPORT.md +``` diff --git a/plugins/nemo-agents/examples/nat-to-fabric-spike/conftest.py b/plugins/nemo-agents/examples/nat-to-fabric-spike/conftest.py new file mode 100644 index 0000000000..8910b2ad1e --- /dev/null +++ b/plugins/nemo-agents/examples/nat-to-fabric-spike/conftest.py @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Make the spike's transpile module importable when tests run from any cwd.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) diff --git a/plugins/nemo-agents/examples/nat-to-fabric-spike/fabric/agent.yaml b/plugins/nemo-agents/examples/nat-to-fabric-spike/fabric/agent.yaml new file mode 100644 index 0000000000..68405ee6a8 --- /dev/null +++ b/plugins/nemo-agents/examples/nat-to-fabric-spike/fabric/agent.yaml @@ -0,0 +1,60 @@ +# Generated by transpile.py from nat_agent/config.yml. Do not edit by hand. +# schema_version: fabric.agent/v1alpha1 (NeMo Fabric). +schema_version: fabric.agent/v1alpha1 +metadata: + name: research_orchestrator + description: Migrated from a NAT reasoning_agent wrapping react_agent 'research_orchestrator'. +harness: + adapter_id: nvidia.fabric.langchain.deepagents + resolution: preinstalled + settings: + system_prompt: '[RESOLVE] Default react_agent prompt (recover via NAT WorkflowBuilder).' + deepagents: + subagents: + - name: math_agent + description: Handles arithmetic and calculation requests. + tools: + - mcp_math + - name: time_agent + description: Answers date and time questions. Resolve the timezone before + asking for the time. + tools: + - mcp_time + - current_timezone + - name: jira_agent + description: Looks up and summarizes Jira issues. + tools: + - mcp_jira +models: + default: + provider: nvidia + model: nvidia/llama-3.3-nemotron-super-49b-v1 + temperature: 0.0 + api_key_env: NVIDIA_API_KEY + settings: + thinking: true + max_tokens: 2000 + worker_llm: + provider: nvidia + model: nvidia/nemotron-3-nano-30b-a3b + temperature: 0.0 + api_key_env: NVIDIA_API_KEY + settings: + max_tokens: 1024 +runtime: + input_schema: chat + output_schema: message +mcp: + servers: + mcp_math: + transport: streamable-http + url: ${MATH_MCP_URL} + exposure: harness_native + mcp_time: + transport: stdio + url: python -m mcp_server_time --local-timezone=America/Los_Angeles + exposure: harness_native + mcp_jira: + transport: streamable-http + url: ${CORPORATE_MCP_JIRA_URL} + exposure: harness_native diff --git a/plugins/nemo-agents/examples/nat-to-fabric-spike/nat_agent/config.yml b/plugins/nemo-agents/examples/nat-to-fabric-spike/nat_agent/config.yml new file mode 100644 index 0000000000..38806c1cb1 --- /dev/null +++ b/plugins/nemo-agents/examples/nat-to-fabric-spike/nat_agent/config.yml @@ -0,0 +1,100 @@ +# NAT (NVIDIA NeMo Agent Toolkit) source agent for the NAT-to-Fabric transpile spike. +# +# Topology (deliberately non-trivial): +# +# reasoning_agent (workflow entry) +# └─ augmented_fn -> research_orchestrator (react_agent) +# ├─ math_agent (tool_calling_agent) -> mcp_math (streamable-http, ${ENV} url) +# ├─ time_agent (tool_calling_agent) -> mcp_time (stdio) + current_timezone (builtin) +# ├─ jira_agent (tool_calling_agent) -> mcp_jira (streamable-http, OAuth2 auth) +# └─ code_generation (builtin) +# +# This exercises: a reasoning wrapper, an orchestrator, three sub-agents used as +# tools, two builtin tools, and three MCP servers across all three auth cases +# (none, env-var url, OAuth2). The OAuth2 case is the one Fabric's Deep Agents +# adapter cannot carry today, so the transpiler flags it instead of dropping it. + +llms: + orchestrator_llm: + _type: nim + model_name: nvidia/llama-3.3-nemotron-super-49b-v1 + thinking: true + temperature: 0.0 + max_tokens: 2000 + worker_llm: + _type: nim + model_name: nvidia/nemotron-3-nano-30b-a3b + temperature: 0.0 + max_tokens: 1024 + +function_groups: + mcp_time: + _type: mcp_client + server: + transport: stdio + command: python + args: ["-m", "mcp_server_time", "--local-timezone=America/Los_Angeles"] + + mcp_math: + _type: mcp_client + server: + transport: streamable-http + url: ${MATH_MCP_URL} + include: + - calculator__add + - calculator__subtract + - calculator__multiply + - calculator__divide + + mcp_jira: + _type: mcp_client + server: + transport: streamable-http + url: ${CORPORATE_MCP_JIRA_URL} + auth_provider: mcp_oauth2_jira + +authentication: + mcp_oauth2_jira: + _type: mcp_oauth2 + server_url: ${CORPORATE_MCP_JIRA_URL} + redirect_uri: ${NAT_REDIRECT_URI:-http://localhost:8000/auth/redirect} + +functions: + current_timezone: + _type: current_timezone + + code_generation: + _type: code_generation + programming_language: Python + description: "Generate Python code for a task. Use this for any code-writing request." + llm_name: worker_llm + + math_agent: + _type: tool_calling_agent + tool_names: [mcp_math] + llm_name: worker_llm + description: "Handles arithmetic and calculation requests." + + time_agent: + _type: tool_calling_agent + tool_names: [mcp_time, current_timezone] + llm_name: worker_llm + description: "Answers date and time questions. Resolve the timezone before asking for the time." + + jira_agent: + _type: tool_calling_agent + tool_names: [mcp_jira] + llm_name: worker_llm + description: "Looks up and summarizes Jira issues." + + research_orchestrator: + _type: react_agent + tool_names: [math_agent, time_agent, jira_agent, code_generation] + llm_name: orchestrator_llm + verbose: true + +workflow: + _type: reasoning_agent + llm_name: orchestrator_llm + augmented_fn: research_orchestrator + verbose: true diff --git a/plugins/nemo-agents/examples/nat-to-fabric-spike/test_transpile.py b/plugins/nemo-agents/examples/nat-to-fabric-spike/test_transpile.py new file mode 100644 index 0000000000..69e0cc1465 --- /dev/null +++ b/plugins/nemo-agents/examples/nat-to-fabric-spike/test_transpile.py @@ -0,0 +1,252 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Tests for the NAT -> Fabric transpile spike. + +Run: pytest test_transpile.py +These assert the behaviors the spike claims (topology preserved as Deep Agents +subagents, MCP servers carried one-to-one, credentials flagged not guessed, NAT +builtins surfaced) and the unhappy paths a Stage 1 spike will hit off its fixture +(cycles, missing/dangling refs, stdio edge cases, non-MCP groups). +""" +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +import transpile as T + +HERE = Path(__file__).parent +FIXTURE = HERE / "nat_agent" / "config.yml" + + +@pytest.fixture() +def fixture_result(): + config = yaml.safe_load(FIXTURE.read_text()) + report = T.Report() + fabric = T.transpile(config, report) + return fabric, report + + +def _config(**over) -> dict: + base = { + "llms": {"m": {"_type": "nim", "model_name": "x", "temperature": 0.0}}, + "functions": {}, + "function_groups": {}, + "workflow": {"_type": "react_agent", "tool_names": [], "llm_name": "m"}, + } + base.update(over) + return base + + +# --- topology ------------------------------------------------------------- + +def test_reasoning_wrapper_maps_wrapped_agent_as_main(fixture_result): + fabric, _ = fixture_result + # The wrapped react_agent, not the reasoning_agent, drives metadata + subagents. + assert fabric["metadata"]["name"] == "research_orchestrator" + assert "wrapping react_agent 'research_orchestrator'" in fabric["metadata"]["description"] + + +def test_subagents_preserved_with_names_and_tools(fixture_result): + fabric, _ = fixture_result + subs = {s["name"]: s for s in fabric["harness"]["settings"]["deepagents"]["subagents"]} + assert set(subs) == {"math_agent", "time_agent", "jira_agent"} + assert subs["math_agent"]["tools"] == ["mcp_math"] + assert subs["time_agent"]["tools"] == ["mcp_time", "current_timezone"] + assert "arithmetic" in subs["math_agent"]["description"].lower() + + +def test_nested_agents_keep_identity_not_flattened(): + # orchestrator -> team_lead(agent) -> worker(agent) -> mcp_srv + config = _config( + functions={ + "worker": {"_type": "tool_calling_agent", "tool_names": ["srv"], "llm_name": "m", "description": "W"}, + "team_lead": {"_type": "react_agent", "tool_names": ["worker"], "llm_name": "m", "description": "TL"}, + "root": {"_type": "react_agent", "tool_names": ["team_lead"], "llm_name": "m"}, + }, + function_groups={"srv": {"_type": "mcp_client", "server": {"transport": "streamable-http", "url": "${U}"}}}, + workflow={"_type": "react_agent", "tool_names": ["team_lead"], "llm_name": "m"}, + ) + report = T.Report() + fabric = T.transpile(config, report) + subs = {s["name"]: s for s in fabric["harness"]["settings"]["deepagents"]["subagents"]} + # every nested agent survives as its own subagent, not hoisted into its parent + assert set(subs) == {"team_lead", "worker"} + assert subs["team_lead"]["delegates_to"] == ["worker"] + assert subs["worker"]["tools"] == ["srv"] + + +def test_cycle_does_not_recurse_forever(): + config = _config( + functions={ + "a": {"_type": "react_agent", "tool_names": ["b"], "llm_name": "m", "description": "A"}, + "b": {"_type": "react_agent", "tool_names": ["a"], "llm_name": "m", "description": "B"}, + }, + workflow={"_type": "react_agent", "tool_names": ["a"], "llm_name": "m"}, + ) + report = T.Report() + fabric = T.transpile(config, report) # must not raise RecursionError + names = {s["name"] for s in fabric["harness"]["settings"]["deepagents"]["subagents"]} + assert names == {"a", "b"} # each emitted once + + +def test_router_agent_branches_become_subagents_and_metadata_derived(): + config = _config( + functions={ + "a": {"_type": "react_agent", "tool_names": [], "llm_name": "m", "description": "A"}, + "b": {"_type": "react_agent", "tool_names": [], "llm_name": "m", "description": "B"}, + }, + workflow={"_type": "router_agent", "branches": ["a", "b"], "llm_name": "m"}, + ) + report = T.Report() + fabric = T.transpile(config, report) + names = {s["name"] for s in fabric["harness"]["settings"]["deepagents"]["subagents"]} + assert names == {"a", "b"} + # metadata is derived from the input, not hardcoded to the fixture agent. + assert fabric["metadata"]["name"] == "router-agent" + assert "router_agent" in fabric["metadata"]["description"] + + +# --- mcp carryover -------------------------------------------------------- + +def test_all_mcp_servers_carried(fixture_result): + fabric, _ = fixture_result + servers = fabric["mcp"]["servers"] + assert set(servers) == {"mcp_math", "mcp_time", "mcp_jira"} + for spec in servers.values(): + assert {"transport", "url", "exposure"} <= set(spec) + assert spec["exposure"] == "harness_native" + + +def test_stdio_server_becomes_command_url(fixture_result): + fabric, _ = fixture_result + time_server = fabric["mcp"]["servers"]["mcp_time"] + assert time_server["transport"] == "stdio" + assert time_server["url"] == "python -m mcp_server_time --local-timezone=America/Los_Angeles" + + +def test_stdio_missing_command_is_error_not_garbage(): + config = _config( + function_groups={"srv": {"_type": "mcp_client", "server": {"transport": "stdio"}}}, + workflow={"_type": "react_agent", "tool_names": ["srv"], "llm_name": "m"}, + ) + report = T.Report() + fabric = T.transpile(config, report) + assert fabric["mcp"]["servers"]["srv"]["url"] != "''" + assert any("stdio server has no command" in e for e in report.errors) + + +def test_env_vars_detected_including_auth_block(fixture_result): + _, report = fixture_result + # the Jira redirect_uri lives in the authentication block; it must still be caught. + assert report.env_vars == {"MATH_MCP_URL", "CORPORATE_MCP_JIRA_URL", "NAT_REDIRECT_URI"} + + +def test_env_default_syntax_captured(): + config = _config( + function_groups={ + "srv": {"_type": "mcp_client", "server": {"transport": "streamable-http", "url": "${HOST:-localhost}/mcp"}}, + }, + workflow={"_type": "react_agent", "tool_names": ["srv"], "llm_name": "m"}, + ) + report = T.Report() + T.transpile(config, report) + assert "HOST" in report.env_vars + + +# --- credentials: flag, do not guess -------------------------------------- + +def test_oauth2_server_flagged_as_gap(fixture_result): + _, report = fixture_result + assert len(report.auth_gaps) == 1 + assert "mcp_jira" in report.auth_gaps[0] + assert "mcp_oauth2" in report.auth_gaps[0] + + +def test_custom_headers_flagged_as_gap(): + config = _config( + function_groups={ + "srv": { + "_type": "mcp_client", + "server": {"transport": "streamable-http", "url": "${U}", "custom_headers": {"X-Token": "${TOK}"}}, + }, + }, + workflow={"_type": "react_agent", "tool_names": ["srv"], "llm_name": "m"}, + ) + report = T.Report() + T.transpile(config, report) + assert any("custom_headers" in g for g in report.auth_gaps) + + +# --- builtins & error surfacing ------------------------------------------- + +def test_builtins_flagged_for_mcp_equivalent(fixture_result): + _, report = fixture_result + assert report.builtins == {"current_timezone", "code_generation"} + + +def test_dangling_ref_is_error_not_builtin(): + config = _config(workflow={"_type": "react_agent", "tool_names": ["typo_tool"], "llm_name": "m"}) + report = T.Report() + T.transpile(config, report) + assert "typo_tool" not in report.builtins + assert any("typo_tool" in e and "not defined" in e for e in report.errors) + + +def test_non_mcp_function_group_is_error(): + config = _config( + function_groups={"calc": {"_type": "calculator"}}, + workflow={"_type": "react_agent", "tool_names": ["calc"], "llm_name": "m"}, + ) + report = T.Report() + T.transpile(config, report) + assert any("calc" in e and "not MCP" in e for e in report.errors) + + +# --- models --------------------------------------------------------------- + +def test_models_default_is_main_agent_model(fixture_result): + fabric, _ = fixture_result + models = fabric["models"] + assert models["default"]["model"] == "nvidia/llama-3.3-nemotron-super-49b-v1" + assert models["default"]["provider"] == "nvidia" + assert models["default"]["api_key_env"] == "NVIDIA_API_KEY" + assert models["default"]["settings"]["max_tokens"] == 2000 + assert "worker_llm" in models + + +def test_missing_llm_name_is_graceful(): + config = _config(workflow={"_type": "sequential_executor", "tool_list": [], "llm_name": None}) + report = T.Report() + fabric = T.transpile(config, report) # must not KeyError + assert fabric["models"]["default"]["provider"] == T.RESOLVE + assert any("no llm_name" in e for e in report.errors) + + +def test_non_nim_llm_not_mislabeled_nvidia(): + config = _config(llms={"m": {"_type": "openai", "model_name": "gpt-4o", "temperature": 0.0}}) + report = T.Report() + fabric = T.transpile(config, report) + assert fabric["models"]["default"]["provider"] == "openai" + assert "api_key_env" not in fabric["models"]["default"] + + +# --- contract validation -------------------------------------------------- + +def test_output_passes_structural_check(fixture_result): + fabric, _ = fixture_result + T.structural_check(fabric) + + +def test_structural_check_rejects_bad_mcp_server(fixture_result): + fabric, _ = fixture_result + del fabric["mcp"]["servers"]["mcp_math"]["exposure"] + with pytest.raises(ValueError, match="exposure"): + T.structural_check(fabric) + + +def test_fixture_has_no_errors(fixture_result): + _, report = fixture_result + assert report.errors == [] diff --git a/plugins/nemo-agents/examples/nat-to-fabric-spike/transpile.py b/plugins/nemo-agents/examples/nat-to-fabric-spike/transpile.py new file mode 100644 index 0000000000..4a0a6ccc1b --- /dev/null +++ b/plugins/nemo-agents/examples/nat-to-fabric-spike/transpile.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +""" +NAT -> Fabric transpile spike (Stage 1). + +Reads a NAT (NVIDIA NeMo Agent Toolkit) workflow config and emits a Fabric-native +`agent.yaml` targeting the LangChain Deep Agents harness, plus a migration report. + +Scope note: this Stage 1 spike reads the NAT YAML directly so it runs with only +PyYAML. A production version would resolve the config through NAT's +`WorkflowBuilder.from_config()` to also recover default prompts (which live in +NAT's Python, not the YAML) and each tool's resolved input/output schema. Points +where that matters are flagged in the report rather than guessed. + +What it demonstrates: + * A non-trivial NAT topology (reasoning wrapper -> orchestrator -> sub-agents) + maps onto Deep Agents subagents. Every agent in the tree keeps its identity; + nested agents are emitted as their own subagents with a `delegates_to` list, + not flattened into their parent. + * NAT MCP servers (function_groups of _type mcp_client) carry across to Fabric's + `mcp.servers` one-to-one, including stdio and streamable-http. + * Credentials are never guessed. Env-var URLs carry across; NAT OAuth2 / custom + header auth is flagged as a Fabric adapter gap. Dangling refs, non-MCP groups, + and missing models are reported as errors, not silently mishandled. +""" +from __future__ import annotations + +import argparse +import re +import shlex +import sys +from collections import deque +from pathlib import Path + +import yaml + +# NAT agent archetypes and the field each uses to reference its children. +AGENT_CHILD_FIELD = { + "react_agent": "tool_names", + "tool_calling_agent": "tool_names", + "rewoo_agent": "tool_names", + "router_agent": "branches", + "sequential_executor": "tool_list", + "reasoning_agent": "augmented_fn", # single ref, not a list +} +AGENT_TYPES = set(AGENT_CHILD_FIELD) +MCP_TYPES = {"mcp_client", "per_user_mcp_client"} +ENV_REF = re.compile(r"\$\{(\w+)(?::[-=]?[^}]*)?\}") +RESOLVE = "[RESOLVE]" + + +class Report: + """Collects human-facing findings emitted alongside the config.""" + + def __init__(self) -> None: + self.carried: list[str] = [] + self.env_vars: set[str] = set() + self.auth_gaps: list[str] = [] + self.builtins: set[str] = set() + self.errors: list[str] = [] + self.notes: list[str] = [] + + +def child_refs(entry: dict) -> list[str]: + """Return the names an agent entry points at, normalized to a list.""" + field = AGENT_CHILD_FIELD[entry["_type"]] + value = entry.get(field) + if value is None: + return [] + return [value] if isinstance(value, str) else list(value) + + +def scan_env(text: object, report: Report) -> list[str]: + """Record every ${VAR} reference in a string; return the names found.""" + found = ENV_REF.findall(str(text)) + report.env_vars.update(found) + return found + + +def classify(ref: str, functions: dict, groups: dict) -> str: + """Bucket a referenced name: agent | mcp | builtin | group | dangling.""" + if ref in groups: + return "mcp" if groups[ref].get("_type") in MCP_TYPES else "group" + if ref in functions: + return "agent" if functions[ref].get("_type") in AGENT_TYPES else "builtin" + return "dangling" + + +def llm_to_model(llm: dict, report: Report, alias: str) -> dict: + """Map a NAT LLM entry to a Fabric ModelConfig. Only `nim` maps to NVIDIA.""" + ltype = llm.get("_type") + model_name = llm.get("model_name") + if not model_name: + model_name = RESOLVE + report.errors.append(f"LLM '{alias}' has no model_name; emitted {RESOLVE}.") + + if ltype == "nim": + provider, api_key_env = "nvidia", "NVIDIA_API_KEY" + else: + provider, api_key_env = (ltype or RESOLVE), None + report.notes.append( + f"LLM '{alias}' is _type '{ltype}', not nim. Set provider '{provider}' " + f"and its api key env var manually." + ) + + reserved = {"_type", "model_name", "temperature"} + settings = {k: v for k, v in llm.items() if k not in reserved} + model: dict = {"provider": provider, "model": model_name, "temperature": llm.get("temperature", 0.0)} + if api_key_env: + model["api_key_env"] = api_key_env + if settings: + model["settings"] = settings + return model + + +def mcp_server_to_fabric(name: str, group: dict, auth_block: dict, report: Report) -> dict: + """Map one NAT mcp_client function group to a Fabric McpServerConfig and record findings.""" + server = group.get("server", {}) + transport = str(server.get("transport", "streamable-http")) + + if transport == "stdio": + command = server.get("command") + args = server.get("args", []) or [] + if command: + url = shlex.join([str(command), *[str(a) for a in args]]) + report.carried.append(f"{name}: stdio, no credentials") + elif server.get("url"): + url = str(server["url"]) + scan_env(url, report) + report.carried.append(f"{name}: stdio (command supplied via url)") + else: + url = RESOLVE + report.errors.append(f"{name}: stdio server has no command; emitted {RESOLVE}.") + else: + url = str(server.get("url", "")) + if not url: + report.errors.append(f"{name}: {transport} server has no url.") + found = scan_env(url, report) + report.carried.append(f"{name}: {transport}, {'url via env var' if found else 'static url'}") + + fabric = {"transport": transport, "url": url, "exposure": "harness_native"} + + # Auth the Deep Agents adapter cannot carry today (it forwards only transport + + # url; a ${ENV} in the url is the one credential path that reaches the server). + provider_ref = server.get("auth_provider") + if provider_ref: + provider = auth_block.get(provider_ref, {}) + report.auth_gaps.append( + f"{name}: NAT used auth_provider '{provider_ref}' (_type " + f"{provider.get('_type', 'unknown')}). Deep Agents carries only ${{ENV}} " + f"URLs, so this needs a token-in-URL gateway or Fabric adapter OAuth2 support." + ) + if server.get("custom_headers"): + report.auth_gaps.append( + f"{name}: NAT used custom_headers, which the Deep Agents adapter ignores. " + f"Move the credential into the url via ${{ENV}} or extend the adapter." + ) + return fabric + + +def transpile(config: dict, report: Report, name_override: str | None = None) -> dict: + llms = config.get("llms", {}) + functions = config.get("functions", {}) + groups = config.get("function_groups", {}) + auth_block = config.get("authentication", {}) + workflow = config["workflow"] + + # MCP auth providers carry env-var URLs and redirect URIs the user must set. + for provider in auth_block.values(): + if isinstance(provider, dict): + for value in provider.values(): + if isinstance(value, str): + scan_env(value, report) + + # Unwrap reasoning_agent wrappers down to the executing agent (loop-safe). + entry = workflow + main_key: str | None = None + seen_wrap: set[str] = set() + while entry.get("_type") == "reasoning_agent": + refs = child_refs(entry) + if not refs: + break + wrapped = refs[0] + if wrapped not in functions: + report.errors.append(f"reasoning_agent augmented_fn '{wrapped}' is not defined in functions.") + break + if wrapped in seen_wrap: + report.errors.append(f"reasoning_agent chain loops at '{wrapped}'; stopped unwrapping.") + break + seen_wrap.add(wrapped) + main_key = wrapped + report.notes.append(f"Unwrapped reasoning_agent onto '{wrapped}' as the main Deep Agent.") + entry = functions[wrapped] + + used_groups: list[str] = [] + + def leaf_and_children(agent_entry: dict) -> tuple[list[str], list[str]]: + """Split an agent's direct children into leaf tools and sub-agent names.""" + leaf: list[str] = [] + children: list[str] = [] + for ref in child_refs(agent_entry): + kind = classify(ref, functions, groups) + if kind == "agent": + children.append(ref) + elif kind == "mcp": + leaf.append(ref) + used_groups.append(ref) + elif kind == "builtin": + leaf.append(ref) + report.builtins.add(ref) + elif kind == "group": + leaf.append(ref) + report.errors.append( + f"'{ref}': function_group _type '{groups[ref].get('_type')}' is not MCP; not carried as a server." + ) + else: # dangling + report.errors.append(f"'{ref}' is referenced but not defined in functions or function_groups.") + return leaf, children + + # Main agent's own tools, then a cycle-safe walk of the sub-agent graph. + main_tools, main_children = leaf_and_children(entry) + subagents: list[dict] = [] + seen_agents: set[str] = set() + if main_key: + seen_agents.add(main_key) + queue: deque[str] = deque(main_children) + while queue: + ref = queue.popleft() + if ref in seen_agents: + continue # cycle or shared sub-agent; emit once + seen_agents.add(ref) + agent_entry = functions[ref] + leaf, children = leaf_and_children(agent_entry) + sub: dict = {"name": ref, "description": agent_entry.get("description", f"{ref} sub-agent"), "tools": leaf} + if children: + sub["delegates_to"] = children # preserve nested topology instead of flattening + subagents.append(sub) + queue.extend(children) + + # Prompt resolution: NAT archetypes carry a default prompt in Python when the + # config leaves system_prompt unset. Flag rather than invent it. + system_prompt = entry.get("system_prompt") + if system_prompt is None: + system_prompt = f"{RESOLVE} Default {entry['_type']} prompt (recover via NAT WorkflowBuilder)." + report.notes.append( + f"Main agent '{entry['_type']}' had no explicit system_prompt; its default " + f"lives in NAT Python and must be resolved via WorkflowBuilder." + ) + + # Models: main agent's llm becomes default; every NAT llm is also a named alias. + main_llm = entry.get("llm_name") + if main_llm and main_llm in llms: + models = {"default": llm_to_model(llms[main_llm], report, "default")} + else: + if main_llm: + report.errors.append(f"Main agent references llm '{main_llm}' not defined in llms.") + else: + report.errors.append(f"Main agent '{entry['_type']}' has no llm_name; emitted {RESOLVE} model.") + models = {"default": {"provider": RESOLVE, "model": RESOLVE, "temperature": 0.0}} + for name, llm in llms.items(): + if name != main_llm: + models[name] = llm_to_model(llm, report, name) + + # MCP servers: every group actually referenced by the agent tree. + mcp_servers = {} + for name in dict.fromkeys(used_groups): # dedupe, preserve order + mcp_servers[name] = mcp_server_to_fabric(name, groups[name], auth_block, report) + + harness_settings: dict = {"system_prompt": system_prompt} + if subagents: + harness_settings["deepagents"] = {"subagents": subagents} + + agent_name = name_override or main_key or entry["_type"].replace("_", "-") + description = f"Migrated from a NAT {workflow['_type']}" + if main_key: + description += f" wrapping {entry['_type']} '{main_key}'" + description += "." + + fabric: dict = { + "schema_version": "fabric.agent/v1alpha1", + "metadata": {"name": agent_name, "description": description}, + "harness": { + "adapter_id": "nvidia.fabric.langchain.deepagents", + "resolution": "preinstalled", + "settings": harness_settings, + }, + "models": models, + "runtime": {"input_schema": "chat", "output_schema": "message"}, + } + if mcp_servers: + fabric["mcp"] = {"servers": mcp_servers} + if main_tools: + report.notes.append(f"Main-agent direct tools (not sub-agents): {', '.join(main_tools)}.") + return fabric + + +def render_report(report: Report) -> str: + lines = ["# NAT -> Fabric migration report", ""] + + status = "ready to run" + if report.errors: + status = f"blocked: {len(report.errors)} issue(s)" + elif report.auth_gaps or report.builtins or report.env_vars: + manual = len(report.auth_gaps) + len(report.builtins) + (1 if report.env_vars else 0) + status = f"ready after {manual} manual step(s)" + lines += [f"**Status: {status}**", ""] + + lines.append("## MCP servers carried across") + lines += [f"- {item}" for item in report.carried] or ["- None."] + + if report.env_vars: + lines += ["", "## Environment variables to set before running"] + lines += [f"- `{env}`" for env in sorted(report.env_vars)] + + lines += ["", "## Auth requiring action (Fabric adapter gap)"] + lines += [f"- {gap}" for gap in report.auth_gaps] or ["- None."] + + lines += ["", "## Builtin tools requiring an MCP equivalent"] + if report.builtins: + lines += [ + f"- `{name}`: NAT in-process tool. Needs a prebuilt MCP server equivalent before it runs under Deep Agents." + for name in sorted(report.builtins) + ] + else: + lines.append("- None. All tools are MCP servers.") + + lines += ["", "## Errors (must resolve)"] + lines += [f"- {err}" for err in report.errors] or ["- None."] + + lines += ["", "## Notes"] + lines += [f"- {note}" for note in report.notes] or ["- None."] + lines.append("") + return "\n".join(lines) + + +def structural_check(fabric: dict) -> None: + """Minimal, self-contained validation of the Fabric contract. + + Does not embed the Fabric JSON Schema (NeMo-Fabric is a separate repo). For full + validation, point --schema at a local Fabric checkout's schemas/agent.schema.json. + """ + for key in ("schema_version", "metadata", "harness", "runtime"): + if key not in fabric: + raise ValueError(f"missing required top-level key: {key}") + if "name" not in fabric["metadata"]: + raise ValueError("metadata.name is required") + if "adapter_id" not in fabric["harness"]: + raise ValueError("harness.adapter_id is required") + for name, model in fabric.get("models", {}).items(): + for key in ("provider", "model"): + if key not in model: + raise ValueError(f"models.{name}.{key} is required") + for name, server in fabric.get("mcp", {}).get("servers", {}).items(): + for key in ("transport", "url", "exposure"): + if key not in server: + raise ValueError(f"mcp.servers.{name}.{key} is required") + + +def validate(fabric: dict, schema_path: Path | None) -> str: + # Full JSON Schema validation only when a local Fabric schema is supplied. + # Otherwise fall back to the self-contained structural check so the spike + # carries no private Fabric artifact. + if schema_path and schema_path.is_file(): + try: + import json + + import jsonschema + + schema = json.loads(schema_path.read_text()) + jsonschema.validate(instance=fabric, schema=schema) + return f"Valid against {schema_path.name} (FabricConfig, fabric.agent/v1alpha1)." + except ImportError: + pass + structural_check(fabric) + return "Passed structural check (Fabric contract: required keys + mcp server shape)." + + +def main() -> int: + here = Path(__file__).parent + parser = argparse.ArgumentParser(description="Transpile a NAT agent config to a Fabric agent.yaml") + parser.add_argument("--in", dest="src", default=str(here / "nat_agent" / "config.yml")) + parser.add_argument("--out", dest="out", default=str(here / "fabric" / "agent.yaml")) + parser.add_argument("--report", dest="report", default=str(here / "MIGRATION_REPORT.md")) + parser.add_argument("--name", dest="name", default=None, help="Override the emitted metadata.name.") + parser.add_argument( + "--schema", + dest="schema", + default="", + help="Optional path to a local NeMo-Fabric schemas/agent.schema.json for full JSON Schema validation.", + ) + args = parser.parse_args() + + config = yaml.safe_load(Path(args.src).read_text()) + report = Report() + fabric = transpile(config, report, name_override=args.name) + + out_path = Path(args.out) + out_path.parent.mkdir(parents=True, exist_ok=True) + header = ( + "# Generated by transpile.py from nat_agent/config.yml. Do not edit by hand.\n" + "# schema_version: fabric.agent/v1alpha1 (NeMo Fabric).\n" + ) + out_path.write_text(header + yaml.safe_dump(fabric, sort_keys=False, default_flow_style=False)) + Path(args.report).write_text(render_report(report)) + + result = validate(fabric, Path(args.schema) if args.schema else None) + print(f"Wrote {out_path}") + print(f"Wrote {args.report}") + print(f"Schema check: {result}") + print(f"MCP servers carried: {len(fabric.get('mcp', {}).get('servers', {}))}") + print(f"Sub-agents preserved: {len(fabric['harness']['settings'].get('deepagents', {}).get('subagents', []))}") + print(f"Auth gaps flagged: {len(report.auth_gaps)}") + print(f"Errors: {len(report.errors)}") + return 1 if report.errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 8cac716b3bc2276c9a03dbf263618e3f10a4e1b5 Mon Sep 17 00:00:00 2001 From: Yamini Date: Sat, 18 Jul 2026 10:39:00 -0400 Subject: [PATCH 2/3] spike(nemo-agents): handle custom/unknown NAT agent types without crashing Running the transpiler on a real blueprint (AI-Q deep researcher) surfaced that production NAT agents use custom registered _types (chat_deepresearcher_agent, deep_research_agent, ...), not stock archetypes. child_refs no longer KeyErrors on an unknown _type; a custom top-level agent type is reported as a blocking error pointing at NAT WorkflowBuilder resolution. Adds a regression test. Signed-off-by: Yamini --- .../nat-to-fabric-spike/test_transpile.py | 9 +++++++++ .../examples/nat-to-fabric-spike/transpile.py | 17 +++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/plugins/nemo-agents/examples/nat-to-fabric-spike/test_transpile.py b/plugins/nemo-agents/examples/nat-to-fabric-spike/test_transpile.py index 69e0cc1465..1188bbb4a1 100644 --- a/plugins/nemo-agents/examples/nat-to-fabric-spike/test_transpile.py +++ b/plugins/nemo-agents/examples/nat-to-fabric-spike/test_transpile.py @@ -250,3 +250,12 @@ def test_structural_check_rejects_bad_mcp_server(fixture_result): def test_fixture_has_no_errors(fixture_result): _, report = fixture_result assert report.errors == [] + + +def test_custom_top_level_agent_type_is_reported_not_crash(): + # Real blueprints (e.g. AI-Q) use custom registered _types, not stock archetypes. + config = _config(workflow={"_type": "chat_deepresearcher_agent", "enable_clarifier": True}) + report = T.Report() + fabric = T.transpile(config, report) # must not KeyError on the unknown _type + assert any("custom NAT type" in e for e in report.errors) + T.structural_check(fabric) # still emits a valid skeleton diff --git a/plugins/nemo-agents/examples/nat-to-fabric-spike/transpile.py b/plugins/nemo-agents/examples/nat-to-fabric-spike/transpile.py index 4a0a6ccc1b..bf96ea19a9 100644 --- a/plugins/nemo-agents/examples/nat-to-fabric-spike/transpile.py +++ b/plugins/nemo-agents/examples/nat-to-fabric-spike/transpile.py @@ -63,8 +63,14 @@ def __init__(self) -> None: def child_refs(entry: dict) -> list[str]: - """Return the names an agent entry points at, normalized to a list.""" - field = AGENT_CHILD_FIELD[entry["_type"]] + """Return the names an agent entry points at, normalized to a list. + + Returns [] for a custom/unknown _type (we don't know its child field). Callers + that need to flag custom types check AGENT_TYPES separately. + """ + field = AGENT_CHILD_FIELD.get(entry.get("_type") or "") + if field is None: + return [] value = entry.get(field) if value is None: return [] @@ -193,6 +199,13 @@ def transpile(config: dict, report: Report, name_override: str | None = None) -> report.notes.append(f"Unwrapped reasoning_agent onto '{wrapped}' as the main Deep Agent.") entry = functions[wrapped] + if entry.get("_type") not in AGENT_TYPES: + report.errors.append( + f"Top-level agent type '{entry.get('_type')}' is a custom NAT type, not a stock " + f"archetype. The spike maps stock agents; resolve custom registered types via NAT " + f"WorkflowBuilder before transpiling." + ) + used_groups: list[str] = [] def leaf_and_children(agent_entry: dict) -> tuple[list[str], list[str]]: From a847501d3c7488d1d75fd5d8341add33b85235db Mon Sep 17 00:00:00 2001 From: Yamini Date: Sat, 18 Jul 2026 10:55:45 -0400 Subject: [PATCH 3/3] spike(nemo-agents): add analyze mode + feature-gap detection - --analyze: read-only "what this agent is and how it's composed" report (ANALYSIS.md): composition tree, models, tools, and open items. No Fabric emission. The low-friction on-ramp for looking at a NAT agent. - Flags NAT sections the transpiler doesn't carry (middleware/NASSE -> Relay; memory, retrievers, etc. -> Fabric/Platform) instead of dropping them. - metadata.name now defaults to the input filename when there's no reasoning wrapper, so distinct agents don't all come out named "react-agent". - 25 tests (adds analyze, middleware, filename-name cases). Signed-off-by: Yamini --- .../examples/nat-to-fabric-spike/ANALYSIS.md | 34 ++++++ .../nat-to-fabric-spike/MIGRATION_REPORT.md | 3 + .../examples/nat-to-fabric-spike/README.md | 12 ++ .../nat-to-fabric-spike/test_transpile.py | 25 +++++ .../examples/nat-to-fabric-spike/transpile.py | 103 +++++++++++++++++- 5 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 plugins/nemo-agents/examples/nat-to-fabric-spike/ANALYSIS.md diff --git a/plugins/nemo-agents/examples/nat-to-fabric-spike/ANALYSIS.md b/plugins/nemo-agents/examples/nat-to-fabric-spike/ANALYSIS.md new file mode 100644 index 0000000000..60b22122e1 --- /dev/null +++ b/plugins/nemo-agents/examples/nat-to-fabric-spike/ANALYSIS.md @@ -0,0 +1,34 @@ +# NAT agent analysis: research_orchestrator + +Migrated from a NAT reasoning_agent wrapping react_agent 'research_orchestrator'. + +## Composition + +- **research_orchestrator** (react_agent, main agent) + - tool: code_generation + - **math_agent** (tool_calling_agent, sub-agent) + - tool: mcp_math + - **time_agent** (tool_calling_agent, sub-agent) + - tool: mcp_time + - tool: current_timezone + - **jira_agent** (tool_calling_agent, sub-agent) + - tool: mcp_jira + +## Models + +- `default`: nvidia / nvidia/llama-3.3-nemotron-super-49b-v1 +- `worker_llm`: nvidia / nvidia/nemotron-3-nano-30b-a3b + +## Tools + +MCP servers: +- `mcp_math` (streamable-http) +- `mcp_time` (stdio) +- `mcp_jira` (streamable-http) +NAT builtins (would need an MCP equivalent to run under Deep Agents): +- `code_generation` +- `current_timezone` + +## Summary + +4 agent(s), 3 MCP server(s), 2 NAT builtin(s), 0 feature(s) needing another home, 0 unresolved item(s). diff --git a/plugins/nemo-agents/examples/nat-to-fabric-spike/MIGRATION_REPORT.md b/plugins/nemo-agents/examples/nat-to-fabric-spike/MIGRATION_REPORT.md index cf2050773e..ce0685d89f 100644 --- a/plugins/nemo-agents/examples/nat-to-fabric-spike/MIGRATION_REPORT.md +++ b/plugins/nemo-agents/examples/nat-to-fabric-spike/MIGRATION_REPORT.md @@ -22,6 +22,9 @@ ## Errors (must resolve) - None. +## Features not carried (need a Fabric, Platform, or Relay home) +- None. + ## Notes - Unwrapped reasoning_agent onto 'research_orchestrator' as the main Deep Agent. - Main agent 'react_agent' had no explicit system_prompt; its default lives in NAT Python and must be resolved via WorkflowBuilder. diff --git a/plugins/nemo-agents/examples/nat-to-fabric-spike/README.md b/plugins/nemo-agents/examples/nat-to-fabric-spike/README.md index 522783a6da..e7c0c3e010 100644 --- a/plugins/nemo-agents/examples/nat-to-fabric-spike/README.md +++ b/plugins/nemo-agents/examples/nat-to-fabric-spike/README.md @@ -49,9 +49,21 @@ Validation is a self-contained structural check of the Fabric contract. For full python3 transpile.py --schema /path/to/nemo-fabric/schemas/agent.schema.json ``` +## Analyze mode + +The same introspection also runs read-only. `--analyze` skips the Fabric emission and writes `ANALYSIS.md`: the agent's composition tree, models, tools, and anything that would need work to move. It's the low-friction on-ramp: install NeMo Platform, hand it a NAT workflow, and it tells you what your agent does and how it's composed, with no migration commitment. + +```bash +python3 transpile.py --analyze # writes ANALYSIS.md +python3 transpile.py --in path/to/agent.yml --analyze +``` + +The migration report and the analysis both call out NAT features this transpiler does not carry (a top-level `middleware:` section such as NASSE maps to NeMo Relay; `memory`, `retrievers`, and similar need a Fabric or Platform equivalent). Those aren't dropped silently; they're listed as work that needs a home. + ## Run ```bash pip install pyyaml # jsonschema optional, only for --schema python3 transpile.py # reads nat_agent/config.yml, writes fabric/agent.yaml + MIGRATION_REPORT.md +python3 transpile.py --analyze # read-only analysis -> ANALYSIS.md ``` diff --git a/plugins/nemo-agents/examples/nat-to-fabric-spike/test_transpile.py b/plugins/nemo-agents/examples/nat-to-fabric-spike/test_transpile.py index 1188bbb4a1..9f467ec7bf 100644 --- a/plugins/nemo-agents/examples/nat-to-fabric-spike/test_transpile.py +++ b/plugins/nemo-agents/examples/nat-to-fabric-spike/test_transpile.py @@ -252,6 +252,31 @@ def test_fixture_has_no_errors(fixture_result): assert report.errors == [] +def test_middleware_flagged_as_feature_needing_a_home(): + # NASSE is NAT middleware; it maps to Relay, not carried by the transpiler. + config = _config(middleware={"nasse": {"_type": "nasse_guard"}}) + report = T.Report() + T.transpile(config, report) + assert any(f.startswith("middleware:") and "Relay" in f for f in report.features) + + +def test_default_name_from_filename_when_no_wrapper(): + # A plain react_agent (no reasoning wrapper) should take the input filename, not "react-agent". + config = _config(workflow={"_type": "react_agent", "tool_names": [], "llm_name": "m"}) + report = T.Report() + fabric = T.transpile(config, report, default_name="scout") + assert fabric["metadata"]["name"] == "scout" + + +def test_analyze_renders_composition(fixture_result): + fabric, report = fixture_result + analysis = T.render_analysis(fabric, report) + assert "# NAT agent analysis: research_orchestrator" in analysis + assert "math_agent" in analysis and "tool_calling_agent" in analysis + assert "mcp_math" in analysis + assert "4 agent(s)" in analysis + + def test_custom_top_level_agent_type_is_reported_not_crash(): # Real blueprints (e.g. AI-Q) use custom registered _types, not stock archetypes. config = _config(workflow={"_type": "chat_deepresearcher_agent", "enable_clarifier": True}) diff --git a/plugins/nemo-agents/examples/nat-to-fabric-spike/transpile.py b/plugins/nemo-agents/examples/nat-to-fabric-spike/transpile.py index bf96ea19a9..8b86344119 100644 --- a/plugins/nemo-agents/examples/nat-to-fabric-spike/transpile.py +++ b/plugins/nemo-agents/examples/nat-to-fabric-spike/transpile.py @@ -49,6 +49,18 @@ ENV_REF = re.compile(r"\$\{(\w+)(?::[-=]?[^}]*)?\}") RESOLVE = "[RESOLVE]" +# NAT top-level sections this transpiler does not carry; each needs a home elsewhere. +UNMAPPED_SECTIONS = ["middleware", "memory", "retrievers", "embedders", "object_stores", "ttc_strategies", "optimizer"] +FEATURE_HINTS = { + "middleware": "NAT middleware (e.g. NASSE) maps to NeMo Relay", + "memory": "needs a Fabric or Platform memory equivalent", + "retrievers": "needs a retrieval equivalent (Platform or an MCP server)", + "embedders": "map to Fabric models or a retrieval service", + "object_stores": "needs a Platform storage equivalent", + "ttc_strategies": "test-time-compute strategies need a Fabric or Relay equivalent", + "optimizer": "config optimization is a separate Platform or Fabric concern", +} + class Report: """Collects human-facing findings emitted alongside the config.""" @@ -59,7 +71,10 @@ def __init__(self) -> None: self.auth_gaps: list[str] = [] self.builtins: set[str] = set() self.errors: list[str] = [] + self.features: list[str] = [] self.notes: list[str] = [] + self.main_tools: list[str] = [] + self.agent_types: dict[str, str] = {} # agent name -> NAT _type, for analysis def child_refs(entry: dict) -> list[str]: @@ -165,13 +180,18 @@ def mcp_server_to_fabric(name: str, group: dict, auth_block: dict, report: Repor return fabric -def transpile(config: dict, report: Report, name_override: str | None = None) -> dict: +def transpile(config: dict, report: Report, name_override: str | None = None, default_name: str | None = None) -> dict: llms = config.get("llms", {}) functions = config.get("functions", {}) groups = config.get("function_groups", {}) auth_block = config.get("authentication", {}) workflow = config["workflow"] + # NAT sections this transpiler does not carry; each needs a home in Fabric/Platform/Relay. + for section in UNMAPPED_SECTIONS: + if config.get(section): + report.features.append(f"{section}: {FEATURE_HINTS.get(section, 'needs a Fabric or Platform equivalent')}") + # MCP auth providers carry env-var URLs and redirect URIs the user must set. for provider in auth_block.values(): if isinstance(provider, dict): @@ -244,6 +264,7 @@ def leaf_and_children(agent_entry: dict) -> tuple[list[str], list[str]]: continue # cycle or shared sub-agent; emit once seen_agents.add(ref) agent_entry = functions[ref] + report.agent_types[ref] = agent_entry.get("_type", "") leaf, children = leaf_and_children(agent_entry) sub: dict = {"name": ref, "description": agent_entry.get("description", f"{ref} sub-agent"), "tools": leaf} if children: @@ -284,12 +305,15 @@ def leaf_and_children(agent_entry: dict) -> tuple[list[str], list[str]]: if subagents: harness_settings["deepagents"] = {"subagents": subagents} - agent_name = name_override or main_key or entry["_type"].replace("_", "-") + agent_name = name_override or main_key or default_name or entry["_type"].replace("_", "-") description = f"Migrated from a NAT {workflow['_type']}" if main_key: description += f" wrapping {entry['_type']} '{main_key}'" description += "." + report.main_tools = main_tools + report.agent_types[agent_name] = entry.get("_type", "") + fabric: dict = { "schema_version": "fabric.agent/v1alpha1", "metadata": {"name": agent_name, "description": description}, @@ -341,12 +365,73 @@ def render_report(report: Report) -> str: lines += ["", "## Errors (must resolve)"] lines += [f"- {err}" for err in report.errors] or ["- None."] + lines += ["", "## Features not carried (need a Fabric, Platform, or Relay home)"] + lines += [f"- {feat}" for feat in report.features] or ["- None."] + lines += ["", "## Notes"] lines += [f"- {note}" for note in report.notes] or ["- None."] lines.append("") return "\n".join(lines) +def render_analysis(fabric: dict, report: Report) -> str: + """Read-only "what this NAT agent is and how it's composed" report. + + Same introspection as the transpile pass, without emitting a Fabric config. This + is the analyzer surface: install NeMo Platform, hand it a NAT workflow, and it + tells you the agent's topology, models, tools, and what would need work to move. + """ + meta = fabric["metadata"] + name = meta["name"] + settings = fabric["harness"]["settings"] + subagents = settings.get("deepagents", {}).get("subagents", []) + models = fabric.get("models", {}) + servers = fabric.get("mcp", {}).get("servers", {}) + + lines = [f"# NAT agent analysis: {name}", "", meta.get("description", ""), ""] + + lines += ["## Composition", ""] + lines.append(f"- **{name}** ({report.agent_types.get(name, '?')}, main agent)") + for tool in report.main_tools: + lines.append(f" - tool: {tool}") + for sub in subagents: + lines.append(f" - **{sub['name']}** ({report.agent_types.get(sub['name'], '?')}, sub-agent)") + for tool in sub.get("tools", []): + lines.append(f" - tool: {tool}") + for delegate in sub.get("delegates_to", []): + lines.append(f" - delegates to: {delegate}") + + lines += ["", "## Models", ""] + for alias, model in models.items(): + lines.append(f"- `{alias}`: {model.get('provider')} / {model.get('model')}") + + lines += ["", "## Tools", ""] + if servers: + lines.append("MCP servers:") + lines += [f"- `{sname}` ({spec.get('transport')})" for sname, spec in servers.items()] + if report.builtins: + lines.append("NAT builtins (would need an MCP equivalent to run under Deep Agents):") + lines += [f"- `{b}`" for b in sorted(report.builtins)] + if not servers and not report.builtins: + lines.append("- No tools resolved.") + + if report.features: + lines += ["", "## Features needing another home", ""] + lines += [f"- {feat}" for feat in report.features] + if report.errors: + lines += ["", "## Unresolved (custom types or missing refs)", ""] + lines += [f"- {err}" for err in report.errors] + + lines += ["", "## Summary", ""] + lines.append( + f"{1 + len(subagents)} agent(s), {len(servers)} MCP server(s), {len(report.builtins)} NAT " + f"builtin(s), {len(report.features)} feature(s) needing another home, {len(report.errors)} " + f"unresolved item(s)." + ) + lines.append("") + return "\n".join(lines) + + def structural_check(fabric: dict) -> None: """Minimal, self-contained validation of the Fabric contract. @@ -402,11 +487,23 @@ def main() -> int: default="", help="Optional path to a local NeMo-Fabric schemas/agent.schema.json for full JSON Schema validation.", ) + parser.add_argument("--analyze", dest="analyze", action="store_true", help="Analyze the NAT agent and write ANALYSIS.md instead of emitting a Fabric config.") + parser.add_argument("--analyze-out", dest="analyze_out", default=str(here / "ANALYSIS.md")) args = parser.parse_args() config = yaml.safe_load(Path(args.src).read_text()) report = Report() - fabric = transpile(config, report, name_override=args.name) + fabric = transpile(config, report, name_override=args.name, default_name=Path(args.src).stem) + + if args.analyze: + apath = Path(args.analyze_out) + apath.parent.mkdir(parents=True, exist_ok=True) + apath.write_text(render_analysis(fabric, report)) + print(f"Wrote {apath}") + print(f"Agents: {1 + len(fabric['harness']['settings'].get('deepagents', {}).get('subagents', []))}") + print(f"MCP servers: {len(fabric.get('mcp', {}).get('servers', {}))}") + print(f"Builtins: {len(report.builtins)} Features needing another home: {len(report.features)} Unresolved: {len(report.errors)}") + return 0 out_path = Path(args.out) out_path.parent.mkdir(parents=True, exist_ok=True)