From e3080e06406ee0c6bf69afc6b104c594abeb8a78 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 22:16:26 +0000 Subject: [PATCH 1/6] feat(agents): support plugin CLI extensions Signed-off-by: Cursor Agent Co-authored-by: Nico Tonozzi --- .../cli/commands/use_cases/agent.py | 4 +- .../tests/cli/commands/test_agent.py | 12 ++ .../src/nemo_platform_plugin/README.md | 1 + .../src/nemo_platform_plugin/cli.py | 17 ++- .../src/nemo_platform_plugin/discovery.py | 20 ++- .../nemo_platform_plugin/docs/ARCHITECTURE.md | 34 +++++ .../tests/test_discovery.py | 56 ++++++++ .../nemo-agents/src/nemo_agents_plugin/cli.py | 80 +++++++++++ .../tests/unit/test_cli_extensions.py | 133 ++++++++++++++++++ 9 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 plugins/nemo-agents/tests/unit/test_cli_extensions.py diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/use_cases/agent.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/use_cases/agent.py index 337315f1e9..0e4ad40e4a 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/use_cases/agent.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/use_cases/agent.py @@ -30,6 +30,7 @@ _SURFACE_GROUPS: tuple[tuple[str, str], ...] = ( ("nemo.cli", "CLI"), + ("nemo.cli.agents", "Agent CLI"), ("nemo.controllers", "Controllers"), ("nemo.docs", "Docs"), ("nemo.executors", "Executors"), @@ -42,6 +43,7 @@ ("nemo.skills", "Skills"), ("nemo.studio", "Studio"), ) +_DOT_SCOPED_SURFACE_GROUPS = frozenset({"nemo.cli.agents", "nemo.functions", "nemo.jobs"}) def _normalize_cell(value: object) -> str: @@ -51,7 +53,7 @@ def _normalize_cell(value: object) -> str: def _plugin_name_for_entry_point(entry_point_name: str, entry_point_group: str) -> str: - if entry_point_group == "nemo.jobs": + if entry_point_group in _DOT_SCOPED_SURFACE_GROUPS: return entry_point_name.split(".", 1)[0] return entry_point_name diff --git a/packages/nemo_platform_ext/tests/cli/commands/test_agent.py b/packages/nemo_platform_ext/tests/cli/commands/test_agent.py index 4fc204b1a0..69f5d20643 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/test_agent.py +++ b/packages/nemo_platform_ext/tests/cli/commands/test_agent.py @@ -47,6 +47,18 @@ def test_context_maps_job_entry_points_to_plugin_surface(self): assert "Tasks" in result.stdout assert "test-plugin.some-job" in result.stdout + def test_context_maps_agent_cli_entry_points_to_owning_plugin(self): + with patch( + "nemo_platform_plugin.discovery.discover_entry_points", + side_effect=lambda group: {"insights.analyst": object()} if group == "nemo.cli.agents" else {}, + ): + result = _invoke("agent", "context") + + assert result.exit_code == 0 + assert "| insights |" in result.stdout + assert "Agent CLI" in result.stdout + assert "insights.analyst" in result.stdout + def test_context_sections_present(self): result = _invoke("agent", "context") assert result.exit_code == 0 diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/README.md b/packages/nemo_platform_plugin/src/nemo_platform_plugin/README.md index 01f219ebcc..23f1654af3 100644 --- a/packages/nemo_platform_plugin/src/nemo_platform_plugin/README.md +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/README.md @@ -14,6 +14,7 @@ Build NeMo Platform plugins in Python. |---|---|---|---| | HTTP service | `NemoService` | `nemo.services` | Contributes FastAPI routers mounted at `/apis//...` | | CLI | `NemoCLI` | `nemo.cli` | Contributes `nemo ` subcommands | +| Agent CLI | `() -> typer.Typer` | `nemo.cli.agents` | Contributes `nemo agents ` subcommands | | Job | `NemoJob` | `nemo.jobs` | Contributes schedulable, container-executable jobs. Auto-generates `run` / `submit` / `explain` CLI verbs. | | Controller | `NemoController` | `nemo.controllers` | Contributes background reconcile-loop controllers | | Configuration | `NemoConfig` | — | Typed plugin configuration with env var / YAML loading | diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/cli.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/cli.py index f91f070cb5..31547d6b49 100644 --- a/packages/nemo_platform_plugin/src/nemo_platform_plugin/cli.py +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/cli.py @@ -31,12 +31,24 @@ def run(model: str) -> None: # pyproject.toml: # [project.entry-points."nemo.cli"] # my-plugin = "my_plugin.cli:MyCLI" + +Agent CLIs use a separate factory entry point so multiple plugins can extend +the shared ``nemo agents`` namespace without colliding on ``nemo.cli``:: + + def create_analyst_cli() -> typer.Typer: + app = typer.Typer() + ... + return app + + # [project.entry-points."nemo.cli.agents"] + # "my-plugin.analyst" = "my_plugin.cli:create_analyst_cli" """ from __future__ import annotations from abc import abstractmethod -from typing import ClassVar, Literal +from collections.abc import Callable +from typing import ClassVar, Literal, TypeAlias import typer from nemo_platform_plugin._base import _NamedPlugin @@ -44,6 +56,9 @@ def run(model: str) -> None: from nemo_platform_plugin.function import NemoFunction from nemo_platform_plugin.job import NemoJob +AgentCLIFactory: TypeAlias = Callable[[], typer.Typer] +"""Factory for a ``nemo agents `` Typer command group.""" + class NemoCLI(_NamedPlugin): """Abstract base class for plugin-contributed CLI commands. 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..e39d787f06 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` — ``() -> typer.Typer`` callable 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) @@ -51,7 +52,7 @@ from importlib.metadata import EntryPoint, entry_points from typing import Any, cast -from nemo_platform_plugin.cli import NemoCLI +from nemo_platform_plugin.cli import AgentCLIFactory, NemoCLI from nemo_platform_plugin.controller import NemoController from nemo_platform_plugin.customization_contributor import ( CustomizationContributor, @@ -66,11 +67,14 @@ 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 = ( "nemo.services", "nemo.cli", + AGENT_CLI_GROUP, "nemo.jobs", "nemo.functions", "nemo.controllers", @@ -89,11 +93,12 @@ # ``.`` rather than the bare plugin name. Used by the # manifest builder to map a key like ``example.greet`` back to the # plugin name ``example``. -_DOT_SCOPED_GROUPS: frozenset[str] = frozenset({"nemo.jobs", "nemo.functions"}) +_DOT_SCOPED_GROUPS: frozenset[str] = frozenset({AGENT_CLI_GROUP, "nemo.jobs", "nemo.functions"}) _SURFACE_ALLOWLIST_ENV_VARS: dict[str, str] = { "nemo.services": "NEMO_PLUGIN_SERVICES_ALLOWLIST", "nemo.cli": "NEMO_PLUGIN_CLI_ALLOWLIST", + AGENT_CLI_GROUP: "NEMO_PLUGIN_AGENT_CLI_ALLOWLIST", "nemo.jobs": "NEMO_PLUGIN_JOBS_ALLOWLIST", "nemo.functions": "NEMO_PLUGIN_FUNCTIONS_ALLOWLIST", "nemo.controllers": "NEMO_PLUGIN_CONTROLLERS_ALLOWLIST", @@ -293,6 +298,17 @@ def discover_cli() -> dict[str, type[NemoCLI]]: return result +def discover_agent_cli() -> dict[str, AgentCLIFactory]: + """Discover agent CLI factories contributed beneath ``nemo agents``. + + Entry-point keys use ``.`` so plugin discovery + and allowlists retain the owning plugin while the CLI exposes only the + agent-name suffix. Each value is a zero-argument callable returning the + :class:`typer.Typer` app mounted at ``nemo agents ``. + """ + return {key: cast(AgentCLIFactory, factory) for key, factory 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..745bc1c6b1 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` | `() -> typer.Typer` | `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,39 @@ Platform wraps each surface: - `NemoJob` → job scheduler - `NemoController` → `NemoControllerAdapter` → async reconcile loop +## Agent CLI extensions + +Plugins that provide an agent register a Typer factory under +`nemo.cli.agents`. The entry-point key is `.`: + +```toml +[project.entry-points."nemo.cli.agents"] +"insights.analyst" = "nemo_insights_plugin.analyst.cli:create_cli" +``` + +```python +import typer + + +def create_cli() -> typer.Typer: + app = typer.Typer(help="Analyze agent telemetry.") + + @app.command() + def run(agent: str) -> None: + """Run the analyst against AGENT.""" + + return app +``` + +The plugin prefix preserves ownership for manifests and +`NEMO_PLUGIN_AGENT_CLI_ALLOWLIST`; only the kebab-case agent-name suffix is +public. The example registers `nemo agents analyst run`. + +Agent names must be nouns. Commands beneath an agent must be verbs. A +contribution cannot replace an existing `nemo agents` command or an injected +job/function command. If multiple plugins claim the same agent name, neither +contribution is mounted. + ## 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..92c6838b7f 100644 --- a/packages/nemo_platform_plugin/tests/test_discovery.py +++ b/packages/nemo_platform_plugin/tests/test_discovery.py @@ -12,9 +12,11 @@ from fastapi import APIRouter from nemo_platform_plugin.cli import NemoCLI from nemo_platform_plugin.discovery import ( + AGENT_CLI_GROUP, _ALL_SURFACE_GROUPS, CUSTOMIZATION_CONTRIBUTORS_GROUP, discover, + discover_agent_cli, discover_cli, discover_customization_contributors, discover_entry_points, @@ -207,6 +209,16 @@ def test_allowlist_filters_dot_scoped_entry_points_by_plugin_name(self, monkeypa assert result == {"alpha.job": alpha_job} + def test_agent_cli_allowlist_filters_by_owning_plugin(self, monkeypatch) -> None: + analyst = _make_ep("insights.analyst", object()) + experimentalist = _make_ep("experimentalist.experimentalist", object()) + monkeypatch.setenv("NEMO_PLUGIN_AGENT_CLI_ALLOWLIST", "insights") + + with patch("nemo_platform_plugin.discovery.entry_points", return_value=[analyst, experimentalist]): + result = discover_entry_points(AGENT_CLI_GROUP) + + assert result == {"insights.analyst": analyst} + # --------------------------------------------------------------------------- # discover — generic @@ -355,6 +367,39 @@ 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_factory_under_plugin_scoped_key(self) -> None: + def factory() -> typer.Typer: + return typer.Typer() + + ep = _make_ep("insights.analyst", factory) + with patch("nemo_platform_plugin.discovery.entry_points", return_value=[ep]): + result = discover_agent_cli() + + assert result["insights.analyst"] is factory + assert isinstance(result["insights.analyst"](), typer.Typer) + + def test_failing_factory_import_is_skipped(self) -> None: + bad = _make_ep("bad.broken", None) + bad.load.side_effect = RuntimeError("broken") + good = _make_ep("good.analyst", lambda: typer.Typer()) + with patch("nemo_platform_plugin.discovery.entry_points", return_value=[bad, good]): + result = discover_agent_cli() + + assert "bad.broken" not in result + assert "good.analyst" in result + + # --------------------------------------------------------------------------- # discover_jobs # --------------------------------------------------------------------------- @@ -568,6 +613,17 @@ def test_function_only_plugins_use_plugin_name_not_function_name(self) -> None: assert list(result.keys()) == ["example"] assert result["example"].version == "1.2.3" + def test_agent_cli_only_plugins_use_plugin_name_not_agent_name(self) -> None: + ep = _make_ep("insights.analyst", None, version="1.2.3", description="Agent CLI only plugin") + with patch( + "nemo_platform_plugin.discovery.entry_points", + side_effect=_eps_by_group({AGENT_CLI_GROUP: [ep]}), + ): + result = discover_manifests() + + assert list(result.keys()) == ["insights"] + assert result["insights"].version == "1.2.3" + class TestDiscoverCustomizationContributors: def test_group_in_all_surface_groups(self) -> None: diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py index 8f5ffcc6c5..96b2fe2f25 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py @@ -45,6 +45,7 @@ from pathlib import Path from typing import Any, ClassVar, Literal, Optional, cast +import click import httpx import typer import yaml @@ -71,6 +72,8 @@ 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, discover_entry_points +from typer.main import get_command as typer_get_command logger = logging.getLogger(__name__) @@ -92,6 +95,12 @@ Column("created_at"), ] +_AGENT_CLI_ENTRY_POINT_PATTERN = re.compile( + r"^(?P[a-z0-9]+(?:-[a-z0-9]+)*)\.(?P[a-z0-9]+(?:-[a-z0-9]+)*)$" +) +_AGENT_CLI_HELP_PANEL = "Platform agents" +_INJECTED_COMMAND_ENTRY_POINT_GROUPS = ("nemo.jobs", "nemo.functions") + class AgentsCLI(NemoCLI): """CLI commands for the Agents plugin.""" @@ -117,9 +126,80 @@ def agents_callback(ctx: typer.Context) -> None: _register_platform_commands(app) register_leaderboard_commands(app) register_usage_commands(app) + _register_contributed_agent_commands(app) return app +def _registered_command_names(app: typer.Typer) -> set[str]: + command = typer_get_command(app) + if not isinstance(command, click.Group): + return set() + return set(command.commands) + + +def _reserved_command_names(app: typer.Typer) -> set[str]: + names = _registered_command_names(app) + for group in _INJECTED_COMMAND_ENTRY_POINT_GROUPS: + for entry_point_name in discover_entry_points(group): + if entry_point_name.startswith("agents."): + names.add(entry_point_name.removeprefix("agents.")) + return names + + +def _register_contributed_agent_commands(app: typer.Typer) -> None: + """Mount plugin-contributed agent CLIs at ``nemo agents ``.""" + contributions_by_name: dict[str, list[tuple[str, Any]]] = {} + for entry_point_name, factory in sorted(discover_agent_cli().items()): + match = _AGENT_CLI_ENTRY_POINT_PATTERN.fullmatch(entry_point_name) + if match is None: + logger.warning( + "Ignoring agent CLI entry point %r: expected '.' in kebab case", + entry_point_name, + ) + continue + command_name = match.group("agent") + contributions_by_name.setdefault(command_name, []).append((entry_point_name, factory)) + + reserved_names = _reserved_command_names(app) + for command_name, contributions in sorted(contributions_by_name.items()): + entry_point_names = [entry_point_name for entry_point_name, _factory in contributions] + if len(contributions) > 1: + logger.warning( + "Ignoring agent CLI command %r because multiple plugins registered it: %s", + command_name, + ", ".join(entry_point_names), + ) + continue + if command_name in reserved_names: + logger.warning( + "Ignoring agent CLI entry point %r because %r is already registered under 'nemo agents'", + entry_point_names[0], + command_name, + ) + continue + + entry_point_name, factory = contributions[0] + if not callable(factory) or isinstance(factory, typer.Typer): + logger.warning( + "Ignoring agent CLI entry point %r: expected a zero-argument callable returning typer.Typer", + entry_point_name, + ) + continue + try: + agent_app = factory() + except Exception: + logger.warning("Failed to build agent CLI entry point %r; skipping", entry_point_name, exc_info=True) + continue + if not isinstance(agent_app, typer.Typer): + logger.warning( + "Ignoring agent CLI entry point %r: factory returned %s instead of typer.Typer", + entry_point_name, + type(agent_app).__name__, + ) + continue + app.add_typer(agent_app, name=command_name, rich_help_panel=_AGENT_CLI_HELP_PANEL) + + # --------------------------------------------------------------------------- # Local commands — no platform required # --------------------------------------------------------------------------- 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..2a3f3c0bf0 --- /dev/null +++ b/plugins/nemo-agents/tests/unit/test_cli_extensions.py @@ -0,0 +1,133 @@ +# 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 collections.abc import Callable +from unittest.mock import patch + +import click +import typer +from nemo_agents_plugin.cli import AgentsCLI +from typer.main import get_command as typer_get_command +from typer.testing import CliRunner + + +def _agent_cli_factory(message: str) -> Callable[[], typer.Typer]: + def factory() -> typer.Typer: + app = typer.Typer(help=f"{message} agent commands.") + + @app.callback() + def _root() -> None: + """Force subcommand dispatch.""" + + @app.command() + def run() -> None: + typer.echo(message) + + return app + + return factory + + +def _command_names(app: typer.Typer) -> set[str]: + command = typer_get_command(app) + assert isinstance(command, click.Group) + return set(command.commands) + + +def test_plugin_can_contribute_agent_cli() -> None: + with ( + patch( + "nemo_agents_plugin.cli.discover_agent_cli", + return_value={"insights.analyst": _agent_cli_factory("analysis complete")}, + ), + patch("nemo_agents_plugin.cli.discover_entry_points", return_value={}), + ): + app = AgentsCLI().get_cli() + + result = CliRunner().invoke(app, ["analyst", "run"]) + + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "analysis complete" + + +def test_agent_cli_rejects_malformed_and_duplicate_names(caplog) -> None: + with ( + caplog.at_level(logging.WARNING, logger="nemo_agents_plugin.cli"), + patch( + "nemo_agents_plugin.cli.discover_agent_cli", + return_value={ + "invalid": _agent_cli_factory("invalid"), + "alpha.analyst": _agent_cli_factory("alpha"), + "beta.analyst": _agent_cli_factory("beta"), + "gamma.list": _agent_cli_factory("must not replace built-in"), + }, + ), + patch("nemo_agents_plugin.cli.discover_entry_points", return_value={}), + ): + app = AgentsCLI().get_cli() + + names = _command_names(app) + assert "analyst" not in names + assert "list" in names + assert "expected '.'" in caplog.text + assert "multiple plugins registered it" in caplog.text + assert "already registered under 'nemo agents'" in caplog.text + + +def test_agent_cli_cannot_replace_injected_job_or_function_commands(caplog) -> None: + def entry_points(group: str) -> dict[str, object]: + if group == "nemo.jobs": + return {"agents.experimentalist": object()} + if group == "nemo.functions": + return {"agents.eval-author": object()} + raise AssertionError(f"unexpected entry-point group: {group}") + + with ( + caplog.at_level(logging.WARNING, logger="nemo_agents_plugin.cli"), + patch( + "nemo_agents_plugin.cli.discover_agent_cli", + return_value={ + "optimizer.experimentalist": _agent_cli_factory("experimentalist"), + "evaluator.eval-author": _agent_cli_factory("eval author"), + }, + ), + patch("nemo_agents_plugin.cli.discover_entry_points", side_effect=entry_points), + ): + app = AgentsCLI().get_cli() + + names = _command_names(app) + assert "experimentalist" not in names + assert "eval-author" not in names + assert caplog.text.count("already registered under 'nemo agents'") == 2 + + +def test_invalid_agent_cli_factories_are_fault_isolated(caplog) -> None: + def raises() -> typer.Typer: + raise RuntimeError("broken factory") + + with ( + caplog.at_level(logging.WARNING, logger="nemo_agents_plugin.cli"), + patch( + "nemo_agents_plugin.cli.discover_agent_cli", + return_value={ + "bad.not-callable": object(), + "bad.raises": raises, + "bad.wrong-type": lambda: object(), + "good.analyst": _agent_cli_factory("working"), + }, + ), + patch("nemo_agents_plugin.cli.discover_entry_points", return_value={}), + ): + app = AgentsCLI().get_cli() + + names = _command_names(app) + assert "analyst" in names + assert "not-callable" not in names + assert "raises" not in names + assert "wrong-type" not in names + assert "expected a zero-argument callable" in caplog.text + assert "Failed to build agent CLI entry point 'bad.raises'" in caplog.text + assert "factory returned object instead of typer.Typer" in caplog.text From d2ce361a5726376ce96d0d1a0e9df0a501479a9c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 22:24:44 +0000 Subject: [PATCH 2/6] fix(plugin): sort discovery test imports Signed-off-by: Cursor Agent Co-authored-by: Nico Tonozzi --- packages/nemo_platform_plugin/tests/test_discovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nemo_platform_plugin/tests/test_discovery.py b/packages/nemo_platform_plugin/tests/test_discovery.py index 92c6838b7f..4f791963b2 100644 --- a/packages/nemo_platform_plugin/tests/test_discovery.py +++ b/packages/nemo_platform_plugin/tests/test_discovery.py @@ -12,8 +12,8 @@ from fastapi import APIRouter from nemo_platform_plugin.cli import NemoCLI from nemo_platform_plugin.discovery import ( - AGENT_CLI_GROUP, _ALL_SURFACE_GROUPS, + AGENT_CLI_GROUP, CUSTOMIZATION_CONTRIBUTORS_GROUP, discover, discover_agent_cli, From 21bb07d219e99fd010c837ad2f906c3e6f89f080 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 22:33:56 +0000 Subject: [PATCH 3/6] chore(cli): refresh vendored agent commands Signed-off-by: Cursor Agent Co-authored-by: Nico Tonozzi --- .../nemo_platform/cli/commands/use_cases/agent.py | 4 +++- .../nemo_platform_ext/cli/commands/test_agent.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/use_cases/agent.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/use_cases/agent.py index 09f162c660..dc23afd0e0 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/use_cases/agent.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/use_cases/agent.py @@ -30,6 +30,7 @@ _SURFACE_GROUPS: tuple[tuple[str, str], ...] = ( ("nemo.cli", "CLI"), + ("nemo.cli.agents", "Agent CLI"), ("nemo.controllers", "Controllers"), ("nemo.docs", "Docs"), ("nemo.executors", "Executors"), @@ -42,6 +43,7 @@ ("nemo.skills", "Skills"), ("nemo.studio", "Studio"), ) +_DOT_SCOPED_SURFACE_GROUPS = frozenset({"nemo.cli.agents", "nemo.functions", "nemo.jobs"}) def _normalize_cell(value: object) -> str: @@ -51,7 +53,7 @@ def _normalize_cell(value: object) -> str: def _plugin_name_for_entry_point(entry_point_name: str, entry_point_group: str) -> str: - if entry_point_group == "nemo.jobs": + if entry_point_group in _DOT_SCOPED_SURFACE_GROUPS: return entry_point_name.split(".", 1)[0] return entry_point_name diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_agent.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_agent.py index 5ed300825e..26ece6229c 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_agent.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_agent.py @@ -47,6 +47,18 @@ def test_context_maps_job_entry_points_to_plugin_surface(self): assert "Tasks" in result.stdout assert "test-plugin.some-job" in result.stdout + def test_context_maps_agent_cli_entry_points_to_owning_plugin(self): + with patch( + "nemo_platform_plugin.discovery.discover_entry_points", + side_effect=lambda group: {"insights.analyst": object()} if group == "nemo.cli.agents" else {}, + ): + result = _invoke("agent", "context") + + assert result.exit_code == 0 + assert "| insights |" in result.stdout + assert "Agent CLI" in result.stdout + assert "insights.analyst" in result.stdout + def test_context_sections_present(self): result = _invoke("agent", "context") assert result.exit_code == 0 From e12ab2cff89cff949ea259cf9004f59fc340f958 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 22:24:49 +0000 Subject: [PATCH 4/6] refactor(agents): simplify CLI extension contract Signed-off-by: Cursor Agent Co-authored-by: Nico Tonozzi --- .../cli/commands/use_cases/agent.py | 4 +- .../tests/cli/commands/test_agent.py | 12 -- .../src/nemo_platform_plugin/README.md | 1 - .../src/nemo_platform_plugin/cli.py | 17 +-- .../src/nemo_platform_plugin/discovery.py | 20 +--- .../nemo_platform_plugin/docs/ARCHITECTURE.md | 33 ++---- .../tests/test_discovery.py | 43 +------ .../nemo-agents/src/nemo_agents_plugin/cli.py | 84 +------------ .../tests/unit/test_cli_extensions.py | 111 ++---------------- .../cli/commands/use_cases/agent.py | 4 +- .../cli/commands/test_agent.py | 12 -- 11 files changed, 33 insertions(+), 308 deletions(-) diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/use_cases/agent.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/use_cases/agent.py index 0e4ad40e4a..337315f1e9 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/use_cases/agent.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/use_cases/agent.py @@ -30,7 +30,6 @@ _SURFACE_GROUPS: tuple[tuple[str, str], ...] = ( ("nemo.cli", "CLI"), - ("nemo.cli.agents", "Agent CLI"), ("nemo.controllers", "Controllers"), ("nemo.docs", "Docs"), ("nemo.executors", "Executors"), @@ -43,7 +42,6 @@ ("nemo.skills", "Skills"), ("nemo.studio", "Studio"), ) -_DOT_SCOPED_SURFACE_GROUPS = frozenset({"nemo.cli.agents", "nemo.functions", "nemo.jobs"}) def _normalize_cell(value: object) -> str: @@ -53,7 +51,7 @@ def _normalize_cell(value: object) -> str: def _plugin_name_for_entry_point(entry_point_name: str, entry_point_group: str) -> str: - if entry_point_group in _DOT_SCOPED_SURFACE_GROUPS: + if entry_point_group == "nemo.jobs": return entry_point_name.split(".", 1)[0] return entry_point_name diff --git a/packages/nemo_platform_ext/tests/cli/commands/test_agent.py b/packages/nemo_platform_ext/tests/cli/commands/test_agent.py index 69f5d20643..4fc204b1a0 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/test_agent.py +++ b/packages/nemo_platform_ext/tests/cli/commands/test_agent.py @@ -47,18 +47,6 @@ def test_context_maps_job_entry_points_to_plugin_surface(self): assert "Tasks" in result.stdout assert "test-plugin.some-job" in result.stdout - def test_context_maps_agent_cli_entry_points_to_owning_plugin(self): - with patch( - "nemo_platform_plugin.discovery.discover_entry_points", - side_effect=lambda group: {"insights.analyst": object()} if group == "nemo.cli.agents" else {}, - ): - result = _invoke("agent", "context") - - assert result.exit_code == 0 - assert "| insights |" in result.stdout - assert "Agent CLI" in result.stdout - assert "insights.analyst" in result.stdout - def test_context_sections_present(self): result = _invoke("agent", "context") assert result.exit_code == 0 diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/README.md b/packages/nemo_platform_plugin/src/nemo_platform_plugin/README.md index 23f1654af3..01f219ebcc 100644 --- a/packages/nemo_platform_plugin/src/nemo_platform_plugin/README.md +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/README.md @@ -14,7 +14,6 @@ Build NeMo Platform plugins in Python. |---|---|---|---| | HTTP service | `NemoService` | `nemo.services` | Contributes FastAPI routers mounted at `/apis//...` | | CLI | `NemoCLI` | `nemo.cli` | Contributes `nemo ` subcommands | -| Agent CLI | `() -> typer.Typer` | `nemo.cli.agents` | Contributes `nemo agents ` subcommands | | Job | `NemoJob` | `nemo.jobs` | Contributes schedulable, container-executable jobs. Auto-generates `run` / `submit` / `explain` CLI verbs. | | Controller | `NemoController` | `nemo.controllers` | Contributes background reconcile-loop controllers | | Configuration | `NemoConfig` | — | Typed plugin configuration with env var / YAML loading | diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/cli.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/cli.py index 31547d6b49..f91f070cb5 100644 --- a/packages/nemo_platform_plugin/src/nemo_platform_plugin/cli.py +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/cli.py @@ -31,24 +31,12 @@ def run(model: str) -> None: # pyproject.toml: # [project.entry-points."nemo.cli"] # my-plugin = "my_plugin.cli:MyCLI" - -Agent CLIs use a separate factory entry point so multiple plugins can extend -the shared ``nemo agents`` namespace without colliding on ``nemo.cli``:: - - def create_analyst_cli() -> typer.Typer: - app = typer.Typer() - ... - return app - - # [project.entry-points."nemo.cli.agents"] - # "my-plugin.analyst" = "my_plugin.cli:create_analyst_cli" """ from __future__ import annotations from abc import abstractmethod -from collections.abc import Callable -from typing import ClassVar, Literal, TypeAlias +from typing import ClassVar, Literal import typer from nemo_platform_plugin._base import _NamedPlugin @@ -56,9 +44,6 @@ def create_analyst_cli() -> typer.Typer: from nemo_platform_plugin.function import NemoFunction from nemo_platform_plugin.job import NemoJob -AgentCLIFactory: TypeAlias = Callable[[], typer.Typer] -"""Factory for a ``nemo agents `` Typer command group.""" - class NemoCLI(_NamedPlugin): """Abstract base class for plugin-contributed CLI commands. 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 e39d787f06..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,7 +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` — ``() -> typer.Typer`` callable mounted at ``nemo agents `` +``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) @@ -52,7 +52,7 @@ from importlib.metadata import EntryPoint, entry_points from typing import Any, cast -from nemo_platform_plugin.cli import AgentCLIFactory, NemoCLI +from nemo_platform_plugin.cli import NemoCLI from nemo_platform_plugin.controller import NemoController from nemo_platform_plugin.customization_contributor import ( CustomizationContributor, @@ -74,7 +74,6 @@ _ALL_SURFACE_GROUPS = ( "nemo.services", "nemo.cli", - AGENT_CLI_GROUP, "nemo.jobs", "nemo.functions", "nemo.controllers", @@ -93,12 +92,11 @@ # ``.`` rather than the bare plugin name. Used by the # manifest builder to map a key like ``example.greet`` back to the # plugin name ``example``. -_DOT_SCOPED_GROUPS: frozenset[str] = frozenset({AGENT_CLI_GROUP, "nemo.jobs", "nemo.functions"}) +_DOT_SCOPED_GROUPS: frozenset[str] = frozenset({"nemo.jobs", "nemo.functions"}) _SURFACE_ALLOWLIST_ENV_VARS: dict[str, str] = { "nemo.services": "NEMO_PLUGIN_SERVICES_ALLOWLIST", "nemo.cli": "NEMO_PLUGIN_CLI_ALLOWLIST", - AGENT_CLI_GROUP: "NEMO_PLUGIN_AGENT_CLI_ALLOWLIST", "nemo.jobs": "NEMO_PLUGIN_JOBS_ALLOWLIST", "nemo.functions": "NEMO_PLUGIN_FUNCTIONS_ALLOWLIST", "nemo.controllers": "NEMO_PLUGIN_CONTROLLERS_ALLOWLIST", @@ -298,15 +296,9 @@ def discover_cli() -> dict[str, type[NemoCLI]]: return result -def discover_agent_cli() -> dict[str, AgentCLIFactory]: - """Discover agent CLI factories contributed beneath ``nemo agents``. - - Entry-point keys use ``.`` so plugin discovery - and allowlists retain the owning plugin while the CLI exposes only the - agent-name suffix. Each value is a zero-argument callable returning the - :class:`typer.Typer` app mounted at ``nemo agents ``. - """ - return {key: cast(AgentCLIFactory, factory) for key, factory in discover(AGENT_CLI_GROUP).items()} +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]]: 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 745bc1c6b1..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,7 +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` | `() -> typer.Typer` | `nemo agents ` | mounts an agent command group under the shared `agents` namespace | +| **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 | @@ -32,36 +32,17 @@ Platform wraps each surface: ## Agent CLI extensions -Plugins that provide an agent register a Typer factory under -`nemo.cli.agents`. The entry-point key is `.`: +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"] -"insights.analyst" = "nemo_insights_plugin.analyst.cli:create_cli" +"analyst" = "nemo_insights_plugin.analyst.cli:AnalystCLI" ``` -```python -import typer - - -def create_cli() -> typer.Typer: - app = typer.Typer(help="Analyze agent telemetry.") - - @app.command() - def run(agent: str) -> None: - """Run the analyst against AGENT.""" - - return app -``` - -The plugin prefix preserves ownership for manifests and -`NEMO_PLUGIN_AGENT_CLI_ALLOWLIST`; only the kebab-case agent-name suffix is -public. The example registers `nemo agents analyst run`. - -Agent names must be nouns. Commands beneath an agent must be verbs. A -contribution cannot replace an existing `nemo agents` command or an injected -job/function command. If multiple plugins claim the same agent name, neither -contribution is mounted. +`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 diff --git a/packages/nemo_platform_plugin/tests/test_discovery.py b/packages/nemo_platform_plugin/tests/test_discovery.py index 4f791963b2..e9abef1766 100644 --- a/packages/nemo_platform_plugin/tests/test_discovery.py +++ b/packages/nemo_platform_plugin/tests/test_discovery.py @@ -209,16 +209,6 @@ def test_allowlist_filters_dot_scoped_entry_points_by_plugin_name(self, monkeypa assert result == {"alpha.job": alpha_job} - def test_agent_cli_allowlist_filters_by_owning_plugin(self, monkeypatch) -> None: - analyst = _make_ep("insights.analyst", object()) - experimentalist = _make_ep("experimentalist.experimentalist", object()) - monkeypatch.setenv("NEMO_PLUGIN_AGENT_CLI_ALLOWLIST", "insights") - - with patch("nemo_platform_plugin.discovery.entry_points", return_value=[analyst, experimentalist]): - result = discover_entry_points(AGENT_CLI_GROUP) - - assert result == {"insights.analyst": analyst} - # --------------------------------------------------------------------------- # discover — generic @@ -378,26 +368,16 @@ def test_uses_agent_cli_group(self) -> None: discover_agent_cli() mock_eps.assert_called_once_with(group=AGENT_CLI_GROUP) - def test_loads_factory_under_plugin_scoped_key(self) -> None: - def factory() -> typer.Typer: - return typer.Typer() + def test_loads_cli_class_under_agent_name(self) -> None: + class _AnalystCLI(_MinimalPluginCLI): + name = "analyst" - ep = _make_ep("insights.analyst", factory) + ep = _make_ep("analyst", _AnalystCLI) with patch("nemo_platform_plugin.discovery.entry_points", return_value=[ep]): result = discover_agent_cli() - assert result["insights.analyst"] is factory - assert isinstance(result["insights.analyst"](), typer.Typer) - - def test_failing_factory_import_is_skipped(self) -> None: - bad = _make_ep("bad.broken", None) - bad.load.side_effect = RuntimeError("broken") - good = _make_ep("good.analyst", lambda: typer.Typer()) - with patch("nemo_platform_plugin.discovery.entry_points", return_value=[bad, good]): - result = discover_agent_cli() - - assert "bad.broken" not in result - assert "good.analyst" in result + assert result["analyst"] is _AnalystCLI + assert isinstance(result["analyst"]().get_cli(), typer.Typer) # --------------------------------------------------------------------------- @@ -613,17 +593,6 @@ def test_function_only_plugins_use_plugin_name_not_function_name(self) -> None: assert list(result.keys()) == ["example"] assert result["example"].version == "1.2.3" - def test_agent_cli_only_plugins_use_plugin_name_not_agent_name(self) -> None: - ep = _make_ep("insights.analyst", None, version="1.2.3", description="Agent CLI only plugin") - with patch( - "nemo_platform_plugin.discovery.entry_points", - side_effect=_eps_by_group({AGENT_CLI_GROUP: [ep]}), - ): - result = discover_manifests() - - assert list(result.keys()) == ["insights"] - assert result["insights"].version == "1.2.3" - class TestDiscoverCustomizationContributors: def test_group_in_all_surface_groups(self) -> None: diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py index 96b2fe2f25..2e5a58458a 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py @@ -45,7 +45,6 @@ from pathlib import Path from typing import Any, ClassVar, Literal, Optional, cast -import click import httpx import typer import yaml @@ -72,8 +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, discover_entry_points -from typer.main import get_command as typer_get_command +from nemo_platform_plugin.discovery import discover_agent_cli logger = logging.getLogger(__name__) @@ -95,13 +93,6 @@ Column("created_at"), ] -_AGENT_CLI_ENTRY_POINT_PATTERN = re.compile( - r"^(?P[a-z0-9]+(?:-[a-z0-9]+)*)\.(?P[a-z0-9]+(?:-[a-z0-9]+)*)$" -) -_AGENT_CLI_HELP_PANEL = "Platform agents" -_INJECTED_COMMAND_ENTRY_POINT_GROUPS = ("nemo.jobs", "nemo.functions") - - class AgentsCLI(NemoCLI): """CLI commands for the Agents plugin.""" @@ -126,80 +117,11 @@ def agents_callback(ctx: typer.Context) -> None: _register_platform_commands(app) register_leaderboard_commands(app) register_usage_commands(app) - _register_contributed_agent_commands(app) + for name, cli_cls in discover_agent_cli().items(): + app.add_typer(cli_cls().get_cli(), name=name, rich_help_panel="Platform agents") return app -def _registered_command_names(app: typer.Typer) -> set[str]: - command = typer_get_command(app) - if not isinstance(command, click.Group): - return set() - return set(command.commands) - - -def _reserved_command_names(app: typer.Typer) -> set[str]: - names = _registered_command_names(app) - for group in _INJECTED_COMMAND_ENTRY_POINT_GROUPS: - for entry_point_name in discover_entry_points(group): - if entry_point_name.startswith("agents."): - names.add(entry_point_name.removeprefix("agents.")) - return names - - -def _register_contributed_agent_commands(app: typer.Typer) -> None: - """Mount plugin-contributed agent CLIs at ``nemo agents ``.""" - contributions_by_name: dict[str, list[tuple[str, Any]]] = {} - for entry_point_name, factory in sorted(discover_agent_cli().items()): - match = _AGENT_CLI_ENTRY_POINT_PATTERN.fullmatch(entry_point_name) - if match is None: - logger.warning( - "Ignoring agent CLI entry point %r: expected '.' in kebab case", - entry_point_name, - ) - continue - command_name = match.group("agent") - contributions_by_name.setdefault(command_name, []).append((entry_point_name, factory)) - - reserved_names = _reserved_command_names(app) - for command_name, contributions in sorted(contributions_by_name.items()): - entry_point_names = [entry_point_name for entry_point_name, _factory in contributions] - if len(contributions) > 1: - logger.warning( - "Ignoring agent CLI command %r because multiple plugins registered it: %s", - command_name, - ", ".join(entry_point_names), - ) - continue - if command_name in reserved_names: - logger.warning( - "Ignoring agent CLI entry point %r because %r is already registered under 'nemo agents'", - entry_point_names[0], - command_name, - ) - continue - - entry_point_name, factory = contributions[0] - if not callable(factory) or isinstance(factory, typer.Typer): - logger.warning( - "Ignoring agent CLI entry point %r: expected a zero-argument callable returning typer.Typer", - entry_point_name, - ) - continue - try: - agent_app = factory() - except Exception: - logger.warning("Failed to build agent CLI entry point %r; skipping", entry_point_name, exc_info=True) - continue - if not isinstance(agent_app, typer.Typer): - logger.warning( - "Ignoring agent CLI entry point %r: factory returned %s instead of typer.Typer", - entry_point_name, - type(agent_app).__name__, - ) - continue - app.add_typer(agent_app, name=command_name, rich_help_panel=_AGENT_CLI_HELP_PANEL) - - # --------------------------------------------------------------------------- # Local commands — no platform required # --------------------------------------------------------------------------- diff --git a/plugins/nemo-agents/tests/unit/test_cli_extensions.py b/plugins/nemo-agents/tests/unit/test_cli_extensions.py index 2a3f3c0bf0..0673bc9bdc 100644 --- a/plugins/nemo-agents/tests/unit/test_cli_extensions.py +++ b/plugins/nemo-agents/tests/unit/test_cli_extensions.py @@ -3,20 +3,19 @@ from __future__ import annotations -import logging -from collections.abc import Callable from unittest.mock import patch -import click import typer from nemo_agents_plugin.cli import AgentsCLI -from typer.main import get_command as typer_get_command +from nemo_platform_plugin.cli import NemoCLI from typer.testing import CliRunner -def _agent_cli_factory(message: str) -> Callable[[], typer.Typer]: - def factory() -> typer.Typer: - app = typer.Typer(help=f"{message} agent commands.") +class _AnalystCLI(NemoCLI): + name = "analyst" + + def get_cli(self) -> typer.Typer: + app = typer.Typer(help="Analyst agent commands.") @app.callback() def _root() -> None: @@ -24,110 +23,16 @@ def _root() -> None: @app.command() def run() -> None: - typer.echo(message) + typer.echo("analysis complete") return app - return factory - - -def _command_names(app: typer.Typer) -> set[str]: - command = typer_get_command(app) - assert isinstance(command, click.Group) - return set(command.commands) - def test_plugin_can_contribute_agent_cli() -> None: - with ( - patch( - "nemo_agents_plugin.cli.discover_agent_cli", - return_value={"insights.analyst": _agent_cli_factory("analysis complete")}, - ), - patch("nemo_agents_plugin.cli.discover_entry_points", return_value={}), - ): + with patch("nemo_agents_plugin.cli.discover_agent_cli", return_value={"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" - - -def test_agent_cli_rejects_malformed_and_duplicate_names(caplog) -> None: - with ( - caplog.at_level(logging.WARNING, logger="nemo_agents_plugin.cli"), - patch( - "nemo_agents_plugin.cli.discover_agent_cli", - return_value={ - "invalid": _agent_cli_factory("invalid"), - "alpha.analyst": _agent_cli_factory("alpha"), - "beta.analyst": _agent_cli_factory("beta"), - "gamma.list": _agent_cli_factory("must not replace built-in"), - }, - ), - patch("nemo_agents_plugin.cli.discover_entry_points", return_value={}), - ): - app = AgentsCLI().get_cli() - - names = _command_names(app) - assert "analyst" not in names - assert "list" in names - assert "expected '.'" in caplog.text - assert "multiple plugins registered it" in caplog.text - assert "already registered under 'nemo agents'" in caplog.text - - -def test_agent_cli_cannot_replace_injected_job_or_function_commands(caplog) -> None: - def entry_points(group: str) -> dict[str, object]: - if group == "nemo.jobs": - return {"agents.experimentalist": object()} - if group == "nemo.functions": - return {"agents.eval-author": object()} - raise AssertionError(f"unexpected entry-point group: {group}") - - with ( - caplog.at_level(logging.WARNING, logger="nemo_agents_plugin.cli"), - patch( - "nemo_agents_plugin.cli.discover_agent_cli", - return_value={ - "optimizer.experimentalist": _agent_cli_factory("experimentalist"), - "evaluator.eval-author": _agent_cli_factory("eval author"), - }, - ), - patch("nemo_agents_plugin.cli.discover_entry_points", side_effect=entry_points), - ): - app = AgentsCLI().get_cli() - - names = _command_names(app) - assert "experimentalist" not in names - assert "eval-author" not in names - assert caplog.text.count("already registered under 'nemo agents'") == 2 - - -def test_invalid_agent_cli_factories_are_fault_isolated(caplog) -> None: - def raises() -> typer.Typer: - raise RuntimeError("broken factory") - - with ( - caplog.at_level(logging.WARNING, logger="nemo_agents_plugin.cli"), - patch( - "nemo_agents_plugin.cli.discover_agent_cli", - return_value={ - "bad.not-callable": object(), - "bad.raises": raises, - "bad.wrong-type": lambda: object(), - "good.analyst": _agent_cli_factory("working"), - }, - ), - patch("nemo_agents_plugin.cli.discover_entry_points", return_value={}), - ): - app = AgentsCLI().get_cli() - - names = _command_names(app) - assert "analyst" in names - assert "not-callable" not in names - assert "raises" not in names - assert "wrong-type" not in names - assert "expected a zero-argument callable" in caplog.text - assert "Failed to build agent CLI entry point 'bad.raises'" in caplog.text - assert "factory returned object instead of typer.Typer" in caplog.text diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/use_cases/agent.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/use_cases/agent.py index dc23afd0e0..09f162c660 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/use_cases/agent.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/use_cases/agent.py @@ -30,7 +30,6 @@ _SURFACE_GROUPS: tuple[tuple[str, str], ...] = ( ("nemo.cli", "CLI"), - ("nemo.cli.agents", "Agent CLI"), ("nemo.controllers", "Controllers"), ("nemo.docs", "Docs"), ("nemo.executors", "Executors"), @@ -43,7 +42,6 @@ ("nemo.skills", "Skills"), ("nemo.studio", "Studio"), ) -_DOT_SCOPED_SURFACE_GROUPS = frozenset({"nemo.cli.agents", "nemo.functions", "nemo.jobs"}) def _normalize_cell(value: object) -> str: @@ -53,7 +51,7 @@ def _normalize_cell(value: object) -> str: def _plugin_name_for_entry_point(entry_point_name: str, entry_point_group: str) -> str: - if entry_point_group in _DOT_SCOPED_SURFACE_GROUPS: + if entry_point_group == "nemo.jobs": return entry_point_name.split(".", 1)[0] return entry_point_name diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_agent.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_agent.py index 26ece6229c..5ed300825e 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_agent.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_agent.py @@ -47,18 +47,6 @@ def test_context_maps_job_entry_points_to_plugin_surface(self): assert "Tasks" in result.stdout assert "test-plugin.some-job" in result.stdout - def test_context_maps_agent_cli_entry_points_to_owning_plugin(self): - with patch( - "nemo_platform_plugin.discovery.discover_entry_points", - side_effect=lambda group: {"insights.analyst": object()} if group == "nemo.cli.agents" else {}, - ): - result = _invoke("agent", "context") - - assert result.exit_code == 0 - assert "| insights |" in result.stdout - assert "Agent CLI" in result.stdout - assert "insights.analyst" in result.stdout - def test_context_sections_present(self): result = _invoke("agent", "context") assert result.exit_code == 0 From 5fbbb197ef272aa11bf7c4fa6405921635a8ce98 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 22:25:29 +0000 Subject: [PATCH 5/6] style(agents): format CLI registration Signed-off-by: Cursor Agent Co-authored-by: Nico Tonozzi --- plugins/nemo-agents/src/nemo_agents_plugin/cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py index 2e5a58458a..5396ea06bd 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py @@ -93,6 +93,7 @@ Column("created_at"), ] + class AgentsCLI(NemoCLI): """CLI commands for the Agents plugin.""" From f4095baffeecb9fb7e84856bfe19de3f2e9da1df Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 22:41:45 +0000 Subject: [PATCH 6/6] fix(agents): isolate failing CLI extensions Signed-off-by: Cursor Agent Co-authored-by: Nico Tonozzi --- .../nemo-agents/src/nemo_agents_plugin/cli.py | 7 ++++++- .../tests/unit/test_cli_extensions.py | 20 ++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py index 5396ea06bd..53ce7afffa 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py @@ -119,7 +119,12 @@ def agents_callback(ctx: typer.Context) -> None: register_leaderboard_commands(app) register_usage_commands(app) for name, cli_cls in discover_agent_cli().items(): - app.add_typer(cli_cls().get_cli(), name=name, rich_help_panel="Platform agents") + 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 index 0673bc9bdc..b270ae109e 100644 --- a/plugins/nemo-agents/tests/unit/test_cli_extensions.py +++ b/plugins/nemo-agents/tests/unit/test_cli_extensions.py @@ -3,6 +3,7 @@ from __future__ import annotations +import logging from unittest.mock import patch import typer @@ -28,11 +29,24 @@ def run() -> None: return app -def test_plugin_can_contribute_agent_cli() -> None: - with patch("nemo_agents_plugin.cli.discover_agent_cli", return_value={"analyst": _AnalystCLI}): - app = AgentsCLI().get_cli() +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