diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/discovery.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/discovery.py index 9d3c8c8727..9adcfbf106 100644 --- a/packages/nemo_platform_plugin/src/nemo_platform_plugin/discovery.py +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/discovery.py @@ -12,6 +12,7 @@ ``nemo.services`` → :func:`discover_services` — :class:`~nemo_platform_plugin.service.NemoService` subclass (typed, platform instantiates) ``nemo.cli`` → :func:`discover_cli` — :class:`~nemo_platform_plugin.cli.NemoCLI` subclass (typed, platform instantiates) +``nemo.cli.agents`` → :func:`discover_agent_cli` — :class:`~nemo_platform_plugin.cli.NemoCLI` subclass mounted at ``nemo agents `` ``nemo.jobs`` → :func:`discover_jobs` — :class:`~nemo_platform_plugin.job.NemoJob` subclass (typed, platform instantiates) ``nemo.functions`` → :func:`discover_functions` — :class:`~nemo_platform_plugin.function.NemoFunction` subclass (typed, platform instantiates) ``nemo.controllers`` → :func:`discover_controllers` — :class:`~nemo_platform_plugin.controller.NemoController` subclass (typed, platform instantiates) @@ -66,6 +67,8 @@ logger = logging.getLogger(__name__) +AGENT_CLI_GROUP = "nemo.cli.agents" + # All surface groups the platform recognises. Scanning these is sufficient to # know whether a plugin is installed — no separate ``nemo.plugins`` group needed. _ALL_SURFACE_GROUPS = ( @@ -293,6 +296,11 @@ def discover_cli() -> dict[str, type[NemoCLI]]: return result +def discover_agent_cli() -> dict[str, type[NemoCLI]]: + """Discover ``NemoCLI`` subclasses contributed beneath ``nemo agents``.""" + return {key: cast(type[NemoCLI], cls) for key, cls in discover(AGENT_CLI_GROUP).items()} + + def discover_jobs() -> dict[str, type[NemoJob]]: """Typed wrapper: discover ``nemo.jobs`` → :class:`~nemo_platform_plugin.job.NemoJob` subclass. diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/docs/ARCHITECTURE.md b/packages/nemo_platform_plugin/src/nemo_platform_plugin/docs/ARCHITECTURE.md index 1f7a900e0e..d90847b992 100644 --- a/packages/nemo_platform_plugin/src/nemo_platform_plugin/docs/ARCHITECTURE.md +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/docs/ARCHITECTURE.md @@ -8,6 +8,7 @@ Every plugin capability is a "surface" — a typed contract registered via a Pyt |---|---|---|---|---| | **HTTP service** ★ | `nemo.services` | `NemoService` | `/apis//...` | wraps in `NemoServiceAdapter`, mounts FastAPI router | | **CLI** ★ | `nemo.cli` | `NemoCLI` | `nemo ` | calls `get_cli()`, mounts as Typer subcommand | +| **Agent CLI** | `nemo.cli.agents` | `NemoCLI` | `nemo agents ` | mounts an agent command group under the shared `agents` namespace | | **Job** ★ | `nemo.jobs` | `NemoJob` | key: `.` | auto-generates `run` / `submit` / `explain` CLI verbs; the scheduler drives local runs and remote submission | | **Controller** ★ | `nemo.controllers` | `NemoController` | (background) | wraps in `NemoControllerAdapter`, runs reconcile loop | | SDK | `nemo.sdk` | (any class) | `nemo.` on hub | instantiated as attribute on the `NeMo` hub | @@ -29,6 +30,20 @@ Platform wraps each surface: - `NemoJob` → job scheduler - `NemoController` → `NemoControllerAdapter` → async reconcile loop +## Agent CLI extensions + +Plugins that provide an agent register one `NemoCLI` subclass under +`nemo.cli.agents`. The entry-point key is the agent's name: + +```toml +[project.entry-points."nemo.cli.agents"] +"analyst" = "nemo_insights_plugin.analyst.cli:AnalystCLI" +``` + +`AnalystCLI` must be a `NemoCLI` subclass with `name = "analyst"`. This +registers `nemo agents analyst ...`. Agent names must be unique kebab-case +nouns, and commands beneath an agent must be verbs. + ## Startup sequence 1. `NMP_SERVICES` env var consulted — if set, only listed services start diff --git a/packages/nemo_platform_plugin/tests/test_discovery.py b/packages/nemo_platform_plugin/tests/test_discovery.py index d52151aa89..e9abef1766 100644 --- a/packages/nemo_platform_plugin/tests/test_discovery.py +++ b/packages/nemo_platform_plugin/tests/test_discovery.py @@ -13,8 +13,10 @@ from nemo_platform_plugin.cli import NemoCLI from nemo_platform_plugin.discovery import ( _ALL_SURFACE_GROUPS, + AGENT_CLI_GROUP, CUSTOMIZATION_CONTRIBUTORS_GROUP, discover, + discover_agent_cli, discover_cli, discover_customization_contributors, discover_entry_points, @@ -355,6 +357,29 @@ def test_failing_cli_is_skipped(self) -> None: assert "good" in result +# --------------------------------------------------------------------------- +# discover_agent_cli +# --------------------------------------------------------------------------- + + +class TestDiscoverAgentCLI: + def test_uses_agent_cli_group(self) -> None: + with patch("nemo_platform_plugin.discovery.entry_points", return_value=[]) as mock_eps: + discover_agent_cli() + mock_eps.assert_called_once_with(group=AGENT_CLI_GROUP) + + def test_loads_cli_class_under_agent_name(self) -> None: + class _AnalystCLI(_MinimalPluginCLI): + name = "analyst" + + ep = _make_ep("analyst", _AnalystCLI) + with patch("nemo_platform_plugin.discovery.entry_points", return_value=[ep]): + result = discover_agent_cli() + + assert result["analyst"] is _AnalystCLI + assert isinstance(result["analyst"]().get_cli(), typer.Typer) + + # --------------------------------------------------------------------------- # discover_jobs # --------------------------------------------------------------------------- diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py index 8f5ffcc6c5..53ce7afffa 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py @@ -71,6 +71,7 @@ from nemo_platform_plugin.cli import NemoCLI from nemo_platform_plugin.cli_errors import print_http_request_error, print_http_status_error from nemo_platform_plugin.cli_progress import request_progress +from nemo_platform_plugin.discovery import discover_agent_cli logger = logging.getLogger(__name__) @@ -117,6 +118,13 @@ def agents_callback(ctx: typer.Context) -> None: _register_platform_commands(app) register_leaderboard_commands(app) register_usage_commands(app) + for name, cli_cls in discover_agent_cli().items(): + try: + cli = cli_cls().get_cli() + except Exception: + logger.warning("Failed to load agent CLI extension %r; skipping", name, exc_info=True) + continue + app.add_typer(cli, name=name, rich_help_panel="Platform agents") return app diff --git a/plugins/nemo-agents/tests/unit/test_cli_extensions.py b/plugins/nemo-agents/tests/unit/test_cli_extensions.py new file mode 100644 index 0000000000..b270ae109e --- /dev/null +++ b/plugins/nemo-agents/tests/unit/test_cli_extensions.py @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import logging +from unittest.mock import patch + +import typer +from nemo_agents_plugin.cli import AgentsCLI +from nemo_platform_plugin.cli import NemoCLI +from typer.testing import CliRunner + + +class _AnalystCLI(NemoCLI): + name = "analyst" + + def get_cli(self) -> typer.Typer: + app = typer.Typer(help="Analyst agent commands.") + + @app.callback() + def _root() -> None: + """Force subcommand dispatch.""" + + @app.command() + def run() -> None: + typer.echo("analysis complete") + + return app + + +class _BrokenCLI(NemoCLI): + name = "broken" + + def get_cli(self) -> typer.Typer: + raise RuntimeError("broken extension") + + +def test_broken_plugin_does_not_hide_other_agent_clis(caplog) -> None: + with ( + caplog.at_level(logging.WARNING, logger="nemo_agents_plugin.cli"), + patch( + "nemo_agents_plugin.cli.discover_agent_cli", + return_value={"broken": _BrokenCLI, "analyst": _AnalystCLI}, + ), + ): + app = AgentsCLI().get_cli() + result = CliRunner().invoke(app, ["analyst", "run"]) + + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "analysis complete" + assert "Failed to load agent CLI extension 'broken'; skipping" in caplog.text