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
2 changes: 0 additions & 2 deletions packages/nemo_helix/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,6 @@ nemo-helix-sdk = [
nemo-insights-plugin = [
"fastapi>=0.115",
"httpx",
"insight-agent @ git+https://github.com/NVIDIA-NeMo/labs-trace-intel.git@cd639d223e06ee9d27783df6b461d302ceb09137",
"nemo-fabric-adapter-contract>=0.3.0,<0.4.0",
"nemo-fabric-adapters-common>=0.3.0,<0.4.0",
"nemo-helix-plugin",
Expand All @@ -477,7 +476,6 @@ nemo-insights-plugin = [
"opentelemetry-sdk>=1.42.1",
"pydantic>=2.12.0",
"pyyaml>=6.0.3",
"trace-ingest @ git+https://github.com/NVIDIA-NeMo/labs-trace-intel.git@cd639d223e06ee9d27783df6b461d302ceb09137#subdirectory=packages/trace-ingest",
"typer>=0.20.0",
"tzdata==2026.2",
]
Expand Down
25 changes: 25 additions & 0 deletions plugins/nemo-insights/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,31 @@ uv sync

The plugin is installed by default through the root workspace's `enabled-plugins` group.

## Install analyst dependencies for PyPI installations

PyPI does not accept Git dependencies in wheel metadata, so `nemo-helix[all]`
does not install `insight-agent` and `trace-ingest`. For a `uv tool install`
installation, include both when installing Helix before running Insights analysis:

```bash
uv tool install --force \
--with "insight-agent @ git+https://github.com/NVIDIA-NeMo/labs-trace-intel.git@cd639d223e06ee9d27783df6b461d302ceb09137" \
--with "trace-ingest @ git+https://github.com/NVIDIA-NeMo/labs-trace-intel.git@cd639d223e06ee9d27783df6b461d302ceb09137#subdirectory=packages/trace-ingest" \
"nemo-helix[all]"
```

For a virtual environment, install both pinned requirements in the environment
that runs the analyst:

```bash
uv pip install \
"insight-agent @ git+https://github.com/NVIDIA-NeMo/labs-trace-intel.git@cd639d223e06ee9d27783df6b461d302ceb09137" \
"trace-ingest @ git+https://github.com/NVIDIA-NeMo/labs-trace-intel.git@cd639d223e06ee9d27783df6b461d302ceb09137#subdirectory=packages/trace-ingest"
```

Source checkouts and the platform images install these pinned dependencies through
the workspace's `insights` group.

## CLI

Submit analysis through NeMo Helix. Replace `<run-name>` with the name
Expand Down
2 changes: 0 additions & 2 deletions plugins/nemo-insights/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ requires-python = ">=3.12,<3.15"
dependencies = [
"fastapi>=0.115",
"httpx",
"insight-agent @ git+https://github.com/NVIDIA-NeMo/labs-trace-intel.git@cd639d223e06ee9d27783df6b461d302ceb09137",
"nemo-fabric-adapter-contract>=0.3.0,<0.4.0",
"nemo-fabric-adapters-common>=0.3.0,<0.4.0",
"nemo-helix",
Expand All @@ -20,7 +19,6 @@ dependencies = [
"opentelemetry-sdk>=1.42.1",
"pydantic>=2.12.0",
"pyyaml>=6.0.3",
"trace-ingest @ git+https://github.com/NVIDIA-NeMo/labs-trace-intel.git@cd639d223e06ee9d27783df6b461d302ceb09137#subdirectory=packages/trace-ingest",
"typer>=0.20.0",
"tzdata==2026.2",
]
Expand Down
16 changes: 14 additions & 2 deletions plugins/nemo-insights/src/nemo_insights_plugin/analyst/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@
setup_analyst_observability,
)
from nemo_insights_plugin.analyst.result import AnalystResult
from nemo_insights_plugin.analyst.trace_intel import analyze_snapshot, load_existing_insights
from nemo_insights_plugin.analyst.trace_snapshot import load_trace_snapshot
from nooa.context_blocks import EventBase
from nooa.events import LLMComplete, PythonOutput

Expand Down Expand Up @@ -123,6 +121,20 @@ async def run_analyst_change_set(
instrumenting it. ``None`` runs uninstrumented. Independent of
*enable_observability*, which is the older direct-to-Intake path.
"""
try:
from nemo_insights_plugin.analyst.trace_intel import analyze_snapshot, load_existing_insights
from nemo_insights_plugin.analyst.trace_snapshot import load_trace_snapshot
except ModuleNotFoundError as exc:
if exc.name not in {"insight_agent", "trace_ingest"}:
raise
raise RuntimeError(
"The Insights analyst requires the separately installed insight-agent and trace-ingest packages. "
"Install the pinned dependencies in the environment running the analyst. "
"For uv tool and virtual-environment installation commands, see "
"https://github.com/NVIDIA-NeMo/nemo-helix/blob/release/0.7/plugins/nemo-insights/README.md"
"#install-analyst-dependencies-for-pypi-installations"
) from exc

observability = None
model_clients: ConfiguredModelClients | None = None
insights_output_path = str(insights_output) if insights_output else None
Expand Down
56 changes: 53 additions & 3 deletions plugins/nemo-insights/tests/test_analyst_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@

"""The ``run_analyst`` client injection contract."""

import subprocess
import sys
from typing import Any, cast

import pytest
from nemo_helix_plugin.client.client import AsyncNemoClient
from nemo_helix_plugin.nooa_model_client import ConfiguredModelClients
from nemo_insights_plugin.analyst import run as run_module
from nemo_insights_plugin.analyst import trace_intel, trace_snapshot
from nemo_insights_plugin.analyst.observability import AnalystEvaluationContext
from nooa.context_blocks import ResultStatus
from nooa.events import LLMComplete, PythonOutput
Expand Down Expand Up @@ -82,9 +85,9 @@ async def fake_load_snapshot(*args: object, **kwargs: object) -> object:
seen["snapshot_kwargs"] = kwargs
return object()

monkeypatch.setattr(run_module, "analyze_snapshot", fake_analyze_snapshot)
monkeypatch.setattr(run_module, "load_existing_insights", fake_load_existing)
monkeypatch.setattr(run_module, "load_trace_snapshot", fake_load_snapshot)
monkeypatch.setattr(trace_intel, "analyze_snapshot", fake_analyze_snapshot)
monkeypatch.setattr(trace_intel, "load_existing_insights", fake_load_existing)
monkeypatch.setattr(trace_snapshot, "load_trace_snapshot", fake_load_snapshot)

class Observability:
def shutdown(self) -> None:
Expand All @@ -93,6 +96,53 @@ def shutdown(self) -> None:
monkeypatch.setattr(run_module, "setup_analyst_observability", lambda **_kwargs: Observability())


@pytest.mark.parametrize("missing_dependency", ["insight_agent", "trace_ingest", "unexpected_dependency"])
def test_missing_analyst_dependencies(missing_dependency: str) -> None:
# A fresh process exercises adapter imports without cached analyst dependencies.
script = """
import asyncio
import importlib.abc
import sys
from unittest.mock import AsyncMock

missing = sys.argv[1]
blocked = "insight_agent" if missing == "unexpected_dependency" else missing

class BlockDependency(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
if fullname.split(".")[0] == blocked:
raise ModuleNotFoundError(f"No module named '{missing}'", name=missing)

sys.meta_path.insert(0, BlockDependency())
from nemo_insights_plugin import fabric_adapter
from nemo_insights_plugin.analyst.run import run_analyst

async def check():
client = AsyncMock()
expected = ModuleNotFoundError if missing == "unexpected_dependency" else RuntimeError
try:
await run_analyst(agent="agent", ethos=None, workspace="default", base_url=None, client=client)
except expected as error:
if missing == "unexpected_dependency":
assert error.name == missing
else:
assert error.__cause__.name == missing
message = str(error)
for text in ("insight-agent", "trace-ingest", "uv tool", "virtual-environment",
"#install-analyst-dependencies-for-pypi-installations"):
assert text in message
else:
raise AssertionError("Missing dependency did not fail analyst execution")
client.close.assert_awaited_once()

asyncio.run(check())
"""
result = subprocess.run(
[sys.executable, "-c", script, missing_dependency], capture_output=True, text=True, timeout=30
)
assert result.returncode == 0, result.stdout + result.stderr


async def test_injected_client_is_used_and_closed(monkeypatch: pytest.MonkeyPatch) -> None:
client = FakeClient()
seen: dict[str, object] = {}
Expand Down
12 changes: 8 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,12 @@ dependencies = [
version = "0.0.0"

[dependency-groups]
# Insights analyst plugin convenience group.
insights = ["nemo-insights-plugin"]
# Keep Git-only analyst dependencies out of published wheel metadata.
insights = [
"nemo-insights-plugin",
"insight-agent @ git+https://github.com/NVIDIA-NeMo/labs-trace-intel.git@cd639d223e06ee9d27783df6b461d302ceb09137",
"trace-ingest @ git+https://github.com/NVIDIA-NeMo/labs-trace-intel.git@cd639d223e06ee9d27783df6b461d302ceb09137#subdirectory=packages/trace-ingest",
]
typecheck = ["transformers>=5.5.0,<5.9.0"]
# The scaled-evals plugin is opt-in: it is a path dependency rather than a workspace
# member, so neither `enabled-plugins` nor `uv sync --all-packages` installs it. It needs
Expand Down Expand Up @@ -184,7 +188,7 @@ enabled-plugins = [
"nemo-anonymizer-plugin",
"nemo-data-designer-plugin[retrieval-sdg]",
"nemo-evaluator-plugin",
"nemo-insights-plugin",
{ include-group = "insights" },
"nemo-guardrails-plugin",
"nemo-auditor-plugin",
"nemo-safe-synthesizer-plugin",
Expand Down Expand Up @@ -251,7 +255,7 @@ cpu-tasks = [
# managed CPU task environment. Hermes requires a separate execution image.
"nemo-agents-plugin[all]",
# Insights Analyst Fabric adapter and insights.analysis execute extension.
"nemo-insights-plugin",
{ include-group = "insights" },
"nemo-evaluator-plugin",
"nemo-sandboxed-gym[server,opensandbox]",
"nemo-anonymizer-plugin",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import pytest
import tomlkit
from packaging.requirements import Requirement

REPO_ROOT = Path(__file__).resolve().parents[5]
WRAPPER_PYPROJECT = REPO_ROOT / "packages" / "nemo_helix" / "pyproject.toml"
Expand All @@ -35,6 +36,14 @@ def _load(path: Path) -> dict:
return tomlkit.parse(path.read_text(encoding="utf-8")).unwrap()


def test_wrapper_dependencies_have_no_direct_urls() -> None:
project = _load(WRAPPER_PYPROJECT)["project"]
dependency_groups = [project.get("dependencies", []), *project.get("optional-dependencies", {}).values()]
for dependencies in dependency_groups:
for dependency in dependencies:
assert Requirement(dependency).url is None, f"PyPI rejects direct dependency: {dependency}"


def _source_pyproject(source: Path) -> Path:
for candidate in [source, *source.parents]:
pyproject = candidate / "pyproject.toml"
Expand Down
Loading
Loading