Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 <agent>``
``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)
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Every plugin capability is a "surface" — a typed contract registered via a Pyt
|---|---|---|---|---|
| **HTTP service** ★ | `nemo.services` | `NemoService` | `/apis/<name>/...` | wraps in `NemoServiceAdapter`, mounts FastAPI router |
| **CLI** ★ | `nemo.cli` | `NemoCLI` | `nemo <name> <cmd>` | calls `get_cli()`, mounts as Typer subcommand |
| **Agent CLI** | `nemo.cli.agents` | `NemoCLI` | `nemo agents <agent> <verb>` | mounts an agent command group under the shared `agents` namespace |
| **Job** ★ | `nemo.jobs` | `NemoJob` | key: `<plugin>.<job>` | 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.<name>` on hub | instantiated as attribute on the `NeMo` hub |
Expand All @@ -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
Expand Down
25 changes: 25 additions & 0 deletions packages/nemo_platform_plugin/tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
8 changes: 8 additions & 0 deletions plugins/nemo-agents/src/nemo_agents_plugin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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


Expand Down
52 changes: 52 additions & 0 deletions plugins/nemo-agents/tests/unit/test_cli_extensions.py
Original file line number Diff line number Diff line change
@@ -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
Loading