diff --git a/plugins/nemo-insights/examples/research-agent/tests/test_analyst_e2e.py b/plugins/nemo-insights/examples/research-agent/tests/test_analyst_e2e.py index 8519d31397..a57e4d11d0 100644 --- a/plugins/nemo-insights/examples/research-agent/tests/test_analyst_e2e.py +++ b/plugins/nemo-insights/examples/research-agent/tests/test_analyst_e2e.py @@ -20,10 +20,9 @@ - ``NVIDIA_API_KEY`` and ``TAVILY_API_KEY`` — for the research agent's NIM model + Tavily search. Read from the example's ``.env`` (or the shell). - ``INFERENCE_API_KEY`` — the ``sk-...`` NVIDIA Inference Gateway virtual key - for the analyst's Claude Opus (served over the Anthropic wire format). The - analyst's LLM ``base_url`` is pinned in - :mod:`nemo_insights_plugin.analyst.agent`, so no base-url override is - required. + for the analyst's Claude Opus. Nooa routes it through LiteLLM using the + gateway's OpenAI-compatible API. The model and base URL are pinned in + :mod:`nemo_insights_plugin.analyst.model_config`, so no override is required. - Docker — required to auto-start ClickHouse if one isn't already at ``NMP_INTAKE_CLICKHOUSE_URL`` (default ``http://localhost:8123``). A missing Docker daemon fails the test. @@ -464,7 +463,7 @@ def test_analyst_creates_insight_end_to_end(platform_server: str) -> None: # no f"--- analyst stdout ---\n{result.stdout}\n--- analyst stderr ---\n{result.stderr}" ) - # 6. The analyst's own Pydantic AI spans should be queryable in Intake. + # 6. The analyst's own Nooa spans should be queryable in Intake. analyst_spans = _wait_for_analyst_spans(SPAN_VISIBLE_TIMEOUT_S) assert analyst_spans, ( f"expected Intake spans for {ANALYST_OBSERVABILITY_AGENT_NAME}; " diff --git a/plugins/nemo-insights/pyproject.toml b/plugins/nemo-insights/pyproject.toml index 3985d8422a..fe16c55c6e 100644 --- a/plugins/nemo-insights/pyproject.toml +++ b/plugins/nemo-insights/pyproject.toml @@ -2,18 +2,16 @@ name = "nemo-insights-plugin" version = "0.1.0" description = "Agent telemetry analysis and persistent insights for NeMo Platform." -requires-python = ">=3.11,<3.15" +requires-python = ">=3.12,<3.14" dependencies = [ "fastapi>=0.115", - "genai-prices==0.0.62", "httpx", "nemo-platform", "nemo-platform-plugin", + "nooa", "opentelemetry-exporter-otlp>=1.42.1", "opentelemetry-sdk>=1.42.1", "pydantic>=2.10.6", - "pydantic-ai-harness[code-mode]==0.3.0", - "pydantic-ai-slim[anthropic]==1.105.0", "pyyaml>=6.0.3", "typer>=0.20.0", "tzdata==2026.2", diff --git a/plugins/nemo-insights/src/nemo_insights_plugin/analyst/agent.py b/plugins/nemo-insights/src/nemo_insights_plugin/analyst/agent.py index f56cd51531..882d97ee1e 100644 --- a/plugins/nemo-insights/src/nemo_insights_plugin/analyst/agent.py +++ b/plugins/nemo-insights/src/nemo_insights_plugin/analyst/agent.py @@ -1,12 +1,12 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""The NeMo Insights analyst agent, built on Pydantic AI. +"""The NeMo Insights analyst agent, built on NVIDIA NeMo OO Agents. The analyst inspects recent traces from a target agent, identifies failure patterns and performance regressions, and reports actionable Insights. It is a -Pydantic AI :class:`~pydantic_ai.Agent` with a fixed persona (``INSTRUCTIONS``) -and read-only tools for observability data. +Nooa :class:`~nooa.Agent` with a fixed persona (``INSTRUCTIONS``) and scoped, +read-only methods for observability data. Rather than mutating platform state mid-run through a series of write tools, the analyst gathers evidence with its read tools and then emits a single @@ -15,73 +15,44 @@ (new insights and updates) back to the CLI, which is the only component that persists. -The result is delivered via ``PromptedOutput`` rather than a tool call: -Anthropic rejects extended thinking and tool-based output in the same request, -and the analyst keeps adaptive thinking on for reasoning quality, so the -change-set is returned as a final structured message validated against the -``AnalystResult`` schema. +The result is delivered through Nooa's ``return_result`` helper and validated +against the ``AnalystResult`` schema. The analyst's persona, task, the agent-under-test name, and the optional AUT spec are all formatted into the instructions by ``build_analyst_agent``; the -run is seeded with only the minimal ``KICKOFF`` user turn (the Anthropic -Messages API requires a non-empty ``messages`` array). The per-run config the -tools need is carried in :class:`~nemo_insights_plugin.analyst.deps.AnalystDeps`. -Workspace and base URL aren't in the instructions because the tools are already +run is seeded with only the minimal ``KICKOFF`` request. The per-run config the +methods need is carried in :class:`~nemo_insights_plugin.analyst.deps.AnalystDeps`. +Workspace and base URL aren't in the instructions because the methods are already scoped to them via ``AnalystDeps``. """ -import os -from typing import Any +from typing import Annotated, Any from nemo_insights_plugin.analyst.deps import AnalystDeps -from nemo_insights_plugin.analyst.functions.annotations import ( - fetch_annotations, - get_annotation, -) -from nemo_insights_plugin.analyst.functions.insights import list_insights -from nemo_insights_plugin.analyst.functions.spans import ( - fetch_scores, - fetch_spans, - get_span, -) -from nemo_insights_plugin.analyst.observability import ( - ANALYST_OBSERVABILITY_AGENT_NAME, - AnalystObservability, -) +from nemo_insights_plugin.analyst.functions import annotations, insights, spans +from nemo_insights_plugin.analyst.model_config import get_fast_model, get_smart_model from nemo_insights_plugin.analyst.result import AnalystResult -from pydantic_ai import Agent, PromptedOutput -from pydantic_ai.capabilities import Instrumentation -from pydantic_ai.models.anthropic import AnthropicModel, AnthropicModelSettings -from pydantic_ai.providers.anthropic import AnthropicProvider -from pydantic_ai_harness import CodeMode - -# The analyst runs on Claude Opus 4.8, but reached over the native Anthropic -# Messages wire format rather than Anthropic's own endpoint: NVIDIA's Inference -# Gateway (a LiteLLM proxy) exposes ``/v1/messages`` and authenticates with the -# gateway virtual key in ``INFERENCE_API_KEY``. The Anthropic SDK appends -# ``/v1/messages`` to the base URL, so we point it at the gateway root. -DEFAULT_MODEL = "aws/anthropic/bedrock-claude-opus-4-8" -INFERENCE_GATEWAY_BASE_URL = "https://inference-api.nvidia.com" -# Extended thinking effort. Opus 4.8 only accepts adaptive thinking with an -# ``output_config.effort`` level (it rejects the older fixed ``budget_tokens`` -# form). We set these explicitly because the gateway-aliased model name hides -# the model identity from Pydantic AI's profile-based inference. -THINKING_EFFORT = "medium" -MAX_TOKENS = 16000 +from nooa import Agent, CodeActStrategy, hidden, strategy +from nooa.agents import TokenBudgetSummarizer +from nooa.config import CodeActConfig +from nooa.config.summarizer_config import TokenBudgetConfig +from nooa.tools import TodoManager +from nooa.unifiedllm import UnifiedLLM # Safety cap on model requests per run so a misbehaving loop cannot spin # forever. Each tool-calling round is one request, so this bounds the analyst # to roughly this many tool-use steps. MAX_REQUESTS = 50 +MAX_SUMMARY_TOKENS = 80_000 # --------------------------------------------------------------------------- # Analyst persona + task + methodology, derived from docs/prd-por.md. # -# This is the system prompt (Pydantic AI "instructions") and the only prompt +# This is the analyst context prompt and the only long-form prompt # the analyst gets — there is no separate user-message brief. ``{agent}`` is # formatted in by ``build_analyst_agent`` and the optional AUT spec is appended -# as the final paragraph. Pydantic AI owns the tool catalog and JSON -# tool-calling protocol, so this text covers only the analyst's persona, +# as the final paragraph. Nooa owns the CodeAct protocol and method catalog, so +# this text covers only the analyst's persona, # principles, and method — it deliberately does not document the tools or # restate any output format. # @@ -150,8 +121,8 @@ ## Reporting your findings -When your analysis is complete, report everything in one -final ``analyst_result`` with your full change-set: +When your analysis is complete, report everything in one final +``AnalystResult`` via ``return_result`` with your full change-set: - ``new_insights``: Insights that do not already exist. Give each a short, human-readable title (a sentence naming the failure, e.g. @@ -189,62 +160,212 @@ ) +class Analyst(Agent): + """Analyze telemetry using only scoped, read-only NeMo Insights methods.""" + + _deps: Annotated[AnalystDeps, hidden] + + def __init__( + self, + *, + deps: AnalystDeps, + agent: str, + agent_spec: str | None = None, + **kwargs: Any, + ) -> None: + super().__init__(llm=kwargs.pop("llm", None) or get_smart_model(), **kwargs) + self._deps = deps + self.todos = TodoManager() + TokenBudgetSummarizer.install( + self, + llm=get_fast_model(), + config=TokenBudgetConfig(max_tokens=MAX_SUMMARY_TOKENS), + ) + + instructions = INSTRUCTIONS.format(agent=agent) + if agent_spec and agent_spec.strip(): + instructions = f"{instructions}\n{AGENT_SPEC_HEADER}\n\n{agent_spec.strip()}\n" + self.context["analyst_instructions"] = instructions + + async def fetch_spans( + self, + filter: dict[str, object] | None = None, + group_by: str | None = None, + sort: str | None = None, + mode: str = "detailed", + limit: int | None = None, + ) -> dict[str, object]: + """List the AUT's spans from Intake, or roll them up into groups. + + One method, two modes: + + - **Grouped** (pass ``group_by``, e.g. ``group_by="session_id"``): rolls + the matching spans up server-side into one row per group and returns + ``{"groups": [...], "grouped_by": str, "count": int, "total": int, + "truncated": bool}``, where each group is + ``{"group": {: value, ...}, "span_count": int}``. ``total`` is + the server's full distinct-group count. **Start here** for initial + exploration: grouping by ``session_id`` recovers the AUT's sessions + so you fan out across **many** of them in one shot. + - **Flat** (omit ``group_by``): returns the individual spans as + ``{"spans": [...], "count": int, "truncated": bool}``. Use this once + you have specific sessions worth opening up; scope it with a + ``session_id`` or ``trace_id`` filter. + + In both modes ``truncated`` means more matched than ``limit``; narrow + the filter or raise ``limit``. + + Args: + filter: Raw Intake span filter pushed to the server. Supported keys: + ``agent_name`` (e.g. "codex"), ``status`` ("ok"/"error"), + ``kind`` ("LLM"/"TOOL"/"AGENT"/"CHAIN"/"EVALUATOR"/...), + ``session_id``, ``trace_id``, ``parent_span_id`` (direct + children of a span), ``model``, ``provider``, ``tool_name``, + ``source``, ``evaluation_run_id``, ``dataset_name``, + ``test_case_id``, and ``started_at`` (a range, e.g. + ``{"gte": "2026-06-01T00:00:00"}``). ``agent_name`` defaults + to the run's agent under test when omitted; pass an explicit + value to query another agent, or ``"__all__"`` to disable + agent scoping. There is no span-id filter; use ``get_span``. + group_by: When set, the span field(s) to group by. Only + ``session_id`` and ``trace_id`` are groupable; pass one or both + comma-separated. Omit for a flat span list. + sort: Sort field. Defaults to ``"-started_at"`` for flat mode and + ``"-span_count"`` for grouped mode. + mode: ``"summary"`` omits input/output; ``"detailed"`` includes + everything. Ignored in grouped mode. + limit: Max rows to pull, clamped to the run's ceiling. Defaults to + 100 in grouped mode and 50 in flat mode. + """ + return await spans.fetch_spans( + self._deps, + filter=filter, + group_by=group_by, + sort=sort, + mode=mode, + limit=limit, + ) + + async def get_span(self, span_id: str) -> dict[str, object]: + """Fetch one Intake span by id. + + Args: + span_id: Intake span id, such as one cited by an annotation. + """ + return await spans.get_span(self._deps, span_id=span_id) + + async def fetch_scores(self, span_id: str) -> dict[str, object]: + """Fetch evaluator results (scores) attached to a span. + + Evaluator results are verifier/judge outputs. Each has a ``name``, a + numeric ``value`` and/or ``string_value``, and an optional ``comment``. + For terminal-bench/eval traces the score lives on the EVALUATOR span. + Returns ``{"evaluator_results": [...], "count": int}``. + + Args: + span_id: Intake span id to read evaluator results for. + """ + return await spans.fetch_scores(self._deps, span_id=span_id) + + async def fetch_annotations( + self, + filter: dict[str, object] | None = None, + sort: str = "-created_at", + limit: int = 50, + ) -> dict[str, object]: + """List span/session annotations (feedback, labels, notes), newest first. + + Returns ``{"annotations": [...], "count": int, "truncated": bool}``; + ``truncated`` means more matched than ``limit``. + + Args: + filter: Raw Intake annotation filter pushed to the server. Supported + keys: ``kind`` ("feedback"/"label"/"note"/"metadata"), + ``value_text`` (e.g. "negative" for feedback, or a label's text + value), ``name`` (label name, e.g. "helpfulness"), + ``value_numeric`` (a range object, e.g. ``{"lte": 2}`` for low + scores), ``span_id``, ``session_id``, ``created_by``, and + ``created_at`` (a range). To start with negative feedback use + ``{"kind": "feedback", "value_text": "negative"}``. Omit to + list all annotations. + sort: Sort field; ``"-created_at"`` (default, newest first) or + ``"created_at"``. + limit: Max annotations to pull across pages, clamped to the ceiling. + """ + return await annotations.fetch_annotations(self._deps, filter=filter, sort=sort, limit=limit) + + async def get_annotation(self, annotation_id: str) -> dict[str, object]: + """Fetch one Intake annotation by id. + + Args: + annotation_id: Intake annotation id. + """ + return await annotations.get_annotation(self._deps, annotation_id=annotation_id) + + async def list_insights( + self, + agent: str | None = None, + status: str | None = None, + page: int = 1, + page_size: int = 20, + ) -> str: + """List existing Insights for the agent under test. + + Use this before deciding whether a finding is a new Insight or new + evidence for an existing one. + + Args: + agent: Filter by agent name. Defaults to the analyst's configured + agent; pass an empty string to list across agents. + status: Filter by lifecycle status. + page: Page number (1-indexed). + page_size: Items per page. + """ + return await insights.list_insights( + self._deps, + agent=agent, + status=status, + page=page, + page_size=page_size, + ) + + @strategy( + CodeActStrategy( + config=CodeActConfig( + max_iterations=MAX_REQUESTS, + cell_timeout=3600.0, + ) + ) + ) + async def analyze(self, request: str) -> AnalystResult: # ty: ignore[empty-body] # pyright: ignore[reportReturnType] + """Analyze the target agent's traces and return one complete change-set. + + Follow ``self.context["analyst_instructions"]``. Gather evidence with + the scoped read-only methods on ``self``. When the analysis is complete, + call ``return_result(result=AnalystResult(...))`` exactly once. + + Args: + request: The analysis request. + + Returns: + The complete set of new Insights and evidence updates. + """ + ... + + def build_analyst_agent( + *, + deps: AnalystDeps, agent: str, agent_spec: str | None = None, - observability: AnalystObservability | None = None, -) -> Agent[AnalystDeps, AnalystResult]: - """Build the analyst :class:`~pydantic_ai.Agent`. - - Args: - agent: Name of the agent under test, formatted into the instructions. - agent_spec: Optional spec describing the agent under test. When given, - it is appended as the final paragraph of the instructions (under - ``AGENT_SPEC_HEADER``) so the analyst can flag divergence from it. - observability: Optional Pydantic AI OTel instrumentation for dogfooding - analyst self-observability. - """ - instructions = INSTRUCTIONS.format(agent=agent) - if agent_spec and agent_spec.strip(): - instructions = f"{instructions}\n{AGENT_SPEC_HEADER}\n\n{agent_spec.strip()}\n" - capabilities: list[Any] = [CodeMode()] - if observability is not None: - capabilities.append(Instrumentation(settings=observability.instrumentation_settings)) - return Agent( - AnthropicModel( - DEFAULT_MODEL, - provider=AnthropicProvider( - api_key=os.environ["INFERENCE_API_KEY"], - base_url=INFERENCE_GATEWAY_BASE_URL, - ), - ), - deps_type=AnalystDeps, - name=ANALYST_OBSERVABILITY_AGENT_NAME, - instructions=instructions, - metadata=observability.metadata if observability else None, - output_type=PromptedOutput( - AnalystResult, - name="analyst_result", - description=( - "The complete set of insights from this analysis. Emit this once, at the end; it ends the run." - ), - ), - model_settings=AnthropicModelSettings( - max_tokens=MAX_TOKENS, - anthropic_thinking={"type": "adaptive"}, - anthropic_effort=THINKING_EFFORT, - ), - # Code mode (Pydantic AI Harness) collapses the analyst's read tools - # into a single sandboxed ``run_code`` tool, so the model orchestrates - # multi-step trace triage in one Python program instead of dozens of - # serial tool-call round-trips. - capabilities=capabilities, - tools=[ - fetch_spans, - get_span, - fetch_scores, - fetch_annotations, - get_annotation, - list_insights, - ], + llm: UnifiedLLM | None = None, + **kwargs: Any, +) -> Analyst: + """Build the analyst with per-run scope and optional Nooa runtime overrides.""" + return Analyst( + deps=deps, + agent=agent, + agent_spec=agent_spec, + llm=llm, + **kwargs, ) diff --git a/plugins/nemo-insights/src/nemo_insights_plugin/analyst/analyst_backend.py b/plugins/nemo-insights/src/nemo_insights_plugin/analyst/analyst_backend.py index 559350274f..3bc95b29ec 100644 --- a/plugins/nemo-insights/src/nemo_insights_plugin/analyst/analyst_backend.py +++ b/plugins/nemo-insights/src/nemo_insights_plugin/analyst/analyst_backend.py @@ -159,7 +159,7 @@ class AnalystBackend(ABC): The read surface is a thin, uniform pass-through over the Intake SDK: every list method takes the raw Intake ``filter`` dict and ``sort`` field and drains pages up to ``limit``, and there are get-by-id and evaluator-score - primitives. The analyst composes these in ``run_code`` rather than relying + primitives. The analyst composes these in Nooa CodeAct rather than relying on a wide catalog of narrow tools. Reads always hit the live platform, even in local insights mode. """ diff --git a/plugins/nemo-insights/src/nemo_insights_plugin/analyst/deps.py b/plugins/nemo-insights/src/nemo_insights_plugin/analyst/deps.py index 4903932195..76617e0688 100644 --- a/plugins/nemo-insights/src/nemo_insights_plugin/analyst/deps.py +++ b/plugins/nemo-insights/src/nemo_insights_plugin/analyst/deps.py @@ -1,13 +1,12 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Run-time dependencies shared by the analyst agent and its tools. +"""Run-time dependencies shared by the analyst agent and its read methods. -A single :class:`AnalystDeps` instance is threaded through every tool call via -Pydantic AI's :class:`~pydantic_ai.RunContext`, replacing the per-function NAT -config classes. The CLI builds it from its flags; tools read it off -``ctx.deps``. Keeping it in its own module avoids an import cycle between the -agent definition and the tool modules it registers. +The CLI builds one :class:`AnalystDeps` for each run. The Nooa agent keeps it +hidden from generated code and injects it into every scoped backend method. +Keeping it in its own module avoids an import cycle between the agent and the +read-method implementations. """ from dataclasses import dataclass @@ -18,7 +17,7 @@ @dataclass class AnalystDeps: - """Per-run configuration injected into every analyst tool. + """Per-run configuration injected into every analyst read method. Every tool talks to the platform through a single :class:`AnalystBackend` built once by the CLI and shared here, rather than constructing an SDK diff --git a/plugins/nemo-insights/src/nemo_insights_plugin/analyst/functions/annotations.py b/plugins/nemo-insights/src/nemo_insights_plugin/analyst/functions/annotations.py index 8a4d9520c8..e7b7112457 100644 --- a/plugins/nemo-insights/src/nemo_insights_plugin/analyst/functions/annotations.py +++ b/plugins/nemo-insights/src/nemo_insights_plugin/analyst/functions/annotations.py @@ -28,11 +28,10 @@ from typing import Any from nemo_insights_plugin.analyst.deps import AnalystDeps -from pydantic_ai import RunContext async def fetch_annotations( - ctx: RunContext[AnalystDeps], + deps: AnalystDeps, filter: dict[str, Any] | None = None, sort: str = "-created_at", limit: int = 50, @@ -54,7 +53,6 @@ async def fetch_annotations( sort: Sort field; "-created_at" (default, newest first) or "created_at". limit: Max annotations to pull across pages (clamped to the ceiling). """ - deps = ctx.deps assert deps.backend is not None return await deps.backend.list_annotations( workspace=deps.workspace, @@ -65,12 +63,11 @@ async def fetch_annotations( ) -async def get_annotation(ctx: RunContext[AnalystDeps], annotation_id: str) -> dict[str, Any]: +async def get_annotation(deps: AnalystDeps, annotation_id: str) -> dict[str, Any]: """Fetch a single annotation by id. Args: annotation_id: Intake annotation id. """ - deps = ctx.deps assert deps.backend is not None return await deps.backend.get_annotation(workspace=deps.workspace, annotation_id=annotation_id) diff --git a/plugins/nemo-insights/src/nemo_insights_plugin/analyst/functions/insights.py b/plugins/nemo-insights/src/nemo_insights_plugin/analyst/functions/insights.py index e2049e9e46..5b3dbe8418 100644 --- a/plugins/nemo-insights/src/nemo_insights_plugin/analyst/functions/insights.py +++ b/plugins/nemo-insights/src/nemo_insights_plugin/analyst/functions/insights.py @@ -4,7 +4,7 @@ """Analyst read tool: ``list_insights``. The analyst no longer mutates Insights through tools — it reports its whole -change-set at the end via the ``analyst_result`` output tool (see +change-set at the end via Nooa's ``return_result`` helper (see :mod:`nemo_insights_plugin.analyst.result`). This module keeps only the read-only ``list_insights`` tool, which the analyst uses to see which Insights already exist for the agent so it can decide what is new versus an update. @@ -19,11 +19,10 @@ from nemo_insights_plugin.analyst.deps import AnalystDeps from nemo_insights_plugin.entities import InsightStatus -from pydantic_ai import RunContext async def list_insights( - ctx: RunContext[AnalystDeps], + deps: AnalystDeps, agent: str | None = None, status: str | None = None, page: int = 1, @@ -41,7 +40,6 @@ async def list_insights( page: Page number (1-indexed). page_size: Items per page. """ - deps = ctx.deps assert deps.backend is not None target_agent = agent if agent is not None else deps.agent result = await deps.backend.list_insights( diff --git a/plugins/nemo-insights/src/nemo_insights_plugin/analyst/functions/spans.py b/plugins/nemo-insights/src/nemo_insights_plugin/analyst/functions/spans.py index 05a7030e7e..50eb230d68 100644 --- a/plugins/nemo-insights/src/nemo_insights_plugin/analyst/functions/spans.py +++ b/plugins/nemo-insights/src/nemo_insights_plugin/analyst/functions/spans.py @@ -5,13 +5,12 @@ Spans are the LLM calls, tool invocations, and agent steps inside a trace. These tools are thin pass-throughs over ``client.intake.spans`` — the analyst -supplies the raw Intake filter and composes the results in ``run_code``. +supplies the raw Intake filter and composes the results in Nooa CodeAct. """ from typing import Any from nemo_insights_plugin.analyst.deps import AnalystDeps -from pydantic_ai import RunContext # Sentinel the analyst can pass as ``filter["agent_name"]`` to query spans across # all agents instead of the run's default agent under test. @@ -36,7 +35,7 @@ def _effective_span_filter( async def fetch_spans( - ctx: RunContext[AnalystDeps], + deps: AnalystDeps, filter: dict[str, Any] | None = None, group_by: str | None = None, sort: str | None = None, @@ -87,7 +86,6 @@ async def fetch_spans( limit: Max rows to pull (clamped to the run's ceiling). Defaults to 100 in grouped mode and 50 in flat mode. """ - deps = ctx.deps assert deps.backend is not None effective_filter = _effective_span_filter(filter, deps.agent) if group_by is not None: @@ -111,18 +109,17 @@ async def fetch_spans( ) -async def get_span(ctx: RunContext[AnalystDeps], span_id: str) -> dict[str, Any]: +async def get_span(deps: AnalystDeps, span_id: str) -> dict[str, Any]: """Fetch a single span by id (e.g. a span id cited by an annotation). Args: span_id: Intake span id. """ - deps = ctx.deps assert deps.backend is not None return await deps.backend.get_span(workspace=deps.workspace, span_id=span_id) -async def fetch_scores(ctx: RunContext[AnalystDeps], span_id: str) -> dict[str, Any]: +async def fetch_scores(deps: AnalystDeps, span_id: str) -> dict[str, Any]: """Fetch evaluator results (scores) attached to a span. Evaluator results are the verifier/judge outputs for a span — each has a @@ -134,6 +131,5 @@ async def fetch_scores(ctx: RunContext[AnalystDeps], span_id: str) -> dict[str, Args: span_id: Intake span id to read evaluator results for. """ - deps = ctx.deps assert deps.backend is not None return await deps.backend.list_scores(workspace=deps.workspace, span_id=span_id) diff --git a/plugins/nemo-insights/src/nemo_insights_plugin/analyst/model_config.py b/plugins/nemo-insights/src/nemo_insights_plugin/analyst/model_config.py new file mode 100644 index 0000000000..2edeec48c1 --- /dev/null +++ b/plugins/nemo-insights/src/nemo_insights_plugin/analyst/model_config.py @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""LLM clients for the Insights Analyst.""" + +import functools +import os + +from nooa.unifiedllm import CompletionClient + +_API_BASE = "https://inference-api.nvidia.com/v1" +# The leading ``openai/`` selects LiteLLM's OpenAI-compatible transport. The +# remaining value is the model alias sent unchanged to NVIDIA's gateway. +_SMART_MODEL = "openai/aws/anthropic/bedrock-claude-opus-4-8" +_FAST_MODEL = "openai/openai/openai/gpt-5-mini" + + +@functools.cache +def _completion_client(name: str, api_base: str, api_key: str) -> CompletionClient: + """Reuse clients by their complete inference identity.""" + return CompletionClient(name, api_base=api_base, api_key=api_key) + + +@functools.cache +def get_smart_model() -> CompletionClient: + """Return the cached high-capability model used for analysis.""" + return _completion_client(_SMART_MODEL, _API_BASE, os.environ["INFERENCE_API_KEY"]) + + +@functools.cache +def get_fast_model() -> CompletionClient: + """Return the cached low-latency model used for context summarization.""" + return _completion_client(_FAST_MODEL, _API_BASE, os.environ["INFERENCE_API_KEY"]) diff --git a/plugins/nemo-insights/src/nemo_insights_plugin/analyst/observability.py b/plugins/nemo-insights/src/nemo_insights_plugin/analyst/observability.py index babe675697..6c60f05f2f 100644 --- a/plugins/nemo-insights/src/nemo_insights_plugin/analyst/observability.py +++ b/plugins/nemo-insights/src/nemo_insights_plugin/analyst/observability.py @@ -1,9 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""OpenTelemetry setup for analyst self-observability.""" - -from __future__ import annotations +"""Nooa OpenTelemetry setup for analyst self-observability.""" from dataclasses import dataclass from urllib.parse import urlparse @@ -11,43 +9,36 @@ from nemo_insights_plugin.client import LOOPBACK_HOSTS from nemo_platform.config.config import Config -from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter -from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor -from pydantic_ai.models.instrumented import InstrumentationSettings +from nooa.tracing import enable_tracing, exporters, flush_traces, set_session ANALYST_OBSERVABILITY_ENV = "NEMO_INSIGHTS_ANALYST_OBSERVABILITY" -ANALYST_OBSERVABILITY_AGENT_NAME = "nemo-insights-analyst" +ANALYST_OBSERVABILITY_AGENT_NAME = "Analyst" ANALYST_OBSERVABILITY_SERVICE_NAMESPACE = "nemo-insights" OTLP_TRACES_PATH = "/apis/intake/v2/workspaces/{workspace}/ingest/otlp/v1/traces" @dataclass class AnalystObservability: - """Configured Pydantic AI instrumentation for one analyst run.""" + """Configured Nooa instrumentation for one analyst run.""" endpoint: str session_id: str - instrumentation_settings: InstrumentationSettings - tracer_provider: TracerProvider - - @property - def metadata(self) -> dict[str, str]: - """Metadata attached to the Pydantic AI agent run span.""" - return { - "session.id": self.session_id, - "gen_ai.conversation.id": self.session_id, - } def shutdown(self) -> None: - """Flush pending spans and stop the provider's processors.""" - self.tracer_provider.force_flush() - self.tracer_provider.shutdown() + """Flush this process's pending spans without disabling global tracing.""" + flush_traces() def build_intake_otlp_traces_endpoint(*, base_url: str, workspace: str) -> str: """Return Intake's workspace-scoped OTLP/HTTP traces endpoint.""" + parsed = urlparse(base_url) + scheme = parsed.scheme.lower() + host = (parsed.hostname or "").lower() + if scheme != "https" and not (scheme == "http" and host in LOOPBACK_HOSTS): + raise ValueError( + f"Analyst observability endpoint must use HTTPS (got {base_url!r}). " + "HTTP is only allowed for loopback addresses." + ) return f"{base_url.rstrip('/')}{OTLP_TRACES_PATH.format(workspace=workspace)}" @@ -57,7 +48,7 @@ def setup_analyst_observability( workspace: str, target_agent: str, ) -> AnalystObservability: - """Configure native Pydantic AI OTel instrumentation for Intake export. + """Configure native Nooa OTel instrumentation for Intake export. This path is intended for insights dogfooding and is opt-in at the CLI layer, so it always sends the analyst's own spans to the platform Intake @@ -71,33 +62,24 @@ def setup_analyst_observability( session_id=session_id, ) - tracer_provider = TracerProvider(resource=Resource.create(resource_attributes)) otlp_headers = _otlp_auth_headers(base_url) - tracer_provider.add_span_processor( - BatchSpanProcessor( - OTLPSpanExporter( - endpoint=endpoint, - headers=otlp_headers, - ) - ) - ) - instrumentation_settings = InstrumentationSettings( - tracer_provider=tracer_provider, - include_content=True, + enable_tracing( + exporters=[exporters.otlp(endpoint=endpoint, headers=otlp_headers)], + extra_resource_attrs=resource_attributes, ) + set_session(session_id) return AnalystObservability( endpoint=endpoint, session_id=session_id, - instrumentation_settings=instrumentation_settings, - tracer_provider=tracer_provider, ) def _otlp_auth_headers(base_url: str) -> dict[str, str] | None: """Return Bearer auth headers for remote Intake OTLP ingest, if available.""" - host = (urlparse(base_url).hostname or "").lower() + parsed = urlparse(base_url) + host = (parsed.hostname or "").lower() config_path = Config.get_default_config_path() - if host in LOOPBACK_HOSTS or not config_path.exists(): + if parsed.scheme.lower() != "https" or host in LOOPBACK_HOSTS or not config_path.exists(): return None config = Config.load(config_path, overrides={"base_url": base_url}) diff --git a/plugins/nemo-insights/src/nemo_insights_plugin/analyst/result.py b/plugins/nemo-insights/src/nemo_insights_plugin/analyst/result.py index fdf70535c8..5cf004aff4 100644 --- a/plugins/nemo-insights/src/nemo_insights_plugin/analyst/result.py +++ b/plugins/nemo-insights/src/nemo_insights_plugin/analyst/result.py @@ -7,9 +7,9 @@ (``create_insight`` / ``update_insight``) while it reasons, the analyst reads observability data only, then emits one :class:`AnalystResult` struct that captures *every* change it wants to make. -That struct is the agent's typed output: Pydantic AI surfaces it as a single -``analyst_result`` tool, and the model calling that tool both ends the run and -hands the whole change-set back to the CLI. +That struct is the agent's typed output: Nooa validates the value passed to +``return_result``, which ends the run and hands the whole change-set back to +the CLI. These models intentionally know nothing about how the change-set is persisted. Each :class:`~nemo_insights_plugin.analyst.analyst_backend.AnalystBackend` @@ -77,8 +77,8 @@ class AnalystResult(BaseModel): """The analyst's complete, final change-set for one run. The model populates this once, at the end of its analysis, in place of the - old mutating tool calls. Calling the ``analyst_result`` tool with this - struct ends the run; the CLI then hands it to the backend to persist. + old mutating tool calls. Calling ``return_result`` with this struct ends + the run; the CLI then hands it to the backend to persist. """ model_config = ConfigDict(extra="forbid") diff --git a/plugins/nemo-insights/src/nemo_insights_plugin/analyst/run.py b/plugins/nemo-insights/src/nemo_insights_plugin/analyst/run.py index 87000f02f6..4a1e5c6026 100644 --- a/plugins/nemo-insights/src/nemo_insights_plugin/analyst/run.py +++ b/plugins/nemo-insights/src/nemo_insights_plugin/analyst/run.py @@ -3,15 +3,16 @@ """Reusable insights analyst run orchestration.""" +import importlib import os import sys from datetime import datetime from pathlib import Path -from typing import Any +from typing import Protocol, cast from nemo_insights_plugin.analyst.agent import ( KICKOFF, - MAX_REQUESTS, + Analyst, build_analyst_agent, ) from nemo_insights_plugin.analyst.analyst_backend import make_analyst_backend @@ -22,14 +23,18 @@ ) from nemo_insights_plugin.analyst.result import AnalystResult from nemo_platform import AsyncNeMoPlatform -from pydantic_ai import Agent, UsageLimits -from pydantic_ai.messages import TextPart, ToolCallPart, ToolReturnPart +from nooa.context_blocks import EventBase +from nooa.events import LLMComplete, PythonOutput # Truncate long tool inputs/outputs when echoing the verbose trace so a single # span dump doesn't flood the terminal. _VERBOSE_TRUNCATE = 2000 +class _LiteLLMModule(Protocol): + drop_params: bool + + class ClientConstructionError(Exception): """The analyst's NeMo Platform client could not be constructed.""" @@ -70,6 +75,7 @@ async def run_analyst( observability = None insights_output_path = str(insights_output) if insights_output else None try: + _enable_litellm_drop_params() backend = make_analyst_backend( client=client, insights_output=insights_output_path, @@ -91,11 +97,11 @@ async def run_analyst( target_agent=agent, ) analyst = build_analyst_agent( + deps=deps, agent=agent, agent_spec=agent_spec, - observability=observability, ) - result = await _run_agent(analyst, deps, verbose=verbose) + result = await _run_agent(analyst, verbose=verbose) return await backend.persist_result(workspace=workspace, agent=agent, result=result) finally: try: @@ -111,43 +117,49 @@ def _analyst_observability_enabled() -> bool: return value.strip().lower() in {"1", "true", "yes", "on"} +def _enable_litellm_drop_params() -> None: + """Let LiteLLM omit parameters unsupported by the configured model.""" + litellm = cast(_LiteLLMModule, importlib.import_module("litellm")) + litellm.drop_params = True + + async def _run_agent( - analyst: Agent[AnalystDeps, AnalystResult], - deps: AnalystDeps, + analyst: Analyst, *, verbose: bool, ) -> AnalystResult: - """Run *analyst*, optionally streaming its tool calls to stderr.""" - usage_limits = UsageLimits(request_limit=MAX_REQUESTS) + """Run *analyst*, optionally streaming Nooa reasoning and execution events.""" if not verbose: - result = await analyst.run(KICKOFF, deps=deps, usage_limits=usage_limits) - return result.output - - async with analyst.iter(KICKOFF, deps=deps, usage_limits=usage_limits) as run: - async for node in run: - _echo_node(node) - assert run.result is not None - return run.result.output - - -def _echo_node(node: Any) -> None: - """Print tool calls, model text, and tool returns for one graph node.""" - if Agent.is_call_tools_node(node): - for part in node.model_response.parts: - if isinstance(part, ToolCallPart): - print( - f"[tool] {part.tool_name}({_truncate(str(part.args))})", - file=sys.stderr, - ) - elif isinstance(part, TextPart) and part.content.strip(): - print(f"[thought] {part.content.strip()}", file=sys.stderr) - elif Agent.is_model_request_node(node): - for part in node.request.parts: - if isinstance(part, ToolReturnPart): - print( - f"[result] {part.tool_name} -> {_truncate(str(part.content))}", - file=sys.stderr, - ) + return await analyst.analyze(KICKOFF) + + unsubscribers = [ + analyst.event_manager.on("LLMComplete", _echo_event), + analyst.event_manager.on("PythonOutput", _echo_event), + ] + try: + return await analyst.analyze(KICKOFF) + finally: + for unsubscribe in unsubscribers: + unsubscribe() + + +def _echo_event(event: EventBase) -> None: + """Print one useful Nooa event in the legacy verbose CLI format.""" + if isinstance(event, LLMComplete): + if event.reasoning_content.strip(): + print(f"[thought] {_truncate(event.reasoning_content.strip())}", file=sys.stderr) + for tool_call in event.tool_calls: + name = str(tool_call.get("function_name", "tool")) + arguments = tool_call.get("arguments", "") + print(f"[tool] {name}({_truncate(str(arguments))})", file=sys.stderr) + return + + if isinstance(event, PythonOutput): + parts = [part.rstrip() for part in (event.stdout, event.stderr, event.error) if part.rstrip()] + if event.value is not None: + parts.append(repr(event.value)) + detail = "\n".join(parts) or event.execution_status.value + print(f"[result] execute_python -> {_truncate(detail)}", file=sys.stderr) def _truncate(text: str, limit: int = _VERBOSE_TRUNCATE) -> str: diff --git a/plugins/nemo-insights/src/nemo_insights_plugin/cli.py b/plugins/nemo-insights/src/nemo_insights_plugin/cli.py index 91eeaeed06..6fde946391 100644 --- a/plugins/nemo-insights/src/nemo_insights_plugin/cli.py +++ b/plugins/nemo-insights/src/nemo_insights_plugin/cli.py @@ -41,7 +41,7 @@ from nemo_insights_plugin.profile import AnalysisProfile, load_profile, pick_agent_spec from nemo_platform import NeMoPlatformError from nemo_platform_plugin.cli import NemoCLI -from pydantic_ai import AgentRunError +from nooa import GenerationError DEFAULT_WORKSPACE = "default" _PREFLIGHT_PROBES: AnalysisProbes | None = None @@ -186,7 +186,7 @@ async def _run_analysis(analysis: _ResolvedAnalysis, *, verbose: bool) -> str: insights_output=insights_output, verbose=verbose, ) - except AgentRunError as exc: + except GenerationError as exc: detail = _one_line_error(exc).rstrip(".") typer.echo( f"Error: analyst run failed: {detail}. " diff --git a/plugins/nemo-insights/src/nemo_insights_plugin/client.py b/plugins/nemo-insights/src/nemo_insights_plugin/client.py index a9f8d224f4..777ad04b06 100644 --- a/plugins/nemo-insights/src/nemo_insights_plugin/client.py +++ b/plugins/nemo-insights/src/nemo_insights_plugin/client.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Shared NeMo Platform SDK client construction for analyst NAT functions. +"""Shared NeMo Platform SDK client construction for analyst read methods. Auth lives in the active ``nemo auth login`` context in ``~/.config/nmp/config.yaml``. The SDK only wires up that context (and the @@ -13,8 +13,8 @@ (by also passing ``config_path``) so the explicit ``base_url`` is combined with the context's credentials. -Every analyst function takes ``base_url`` from its workflow context, so this -helper is the one place that branch lives. +The analyst run takes ``base_url`` from its CLI/job context, so this helper is +the one place that branch lives. """ from urllib.parse import urlparse diff --git a/plugins/nemo-insights/tests/test_analyst_agent.py b/plugins/nemo-insights/tests/test_analyst_agent.py new file mode 100644 index 0000000000..b7157092c3 --- /dev/null +++ b/plugins/nemo-insights/tests/test_analyst_agent.py @@ -0,0 +1,142 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Hermetic validation of the Nooa analyst harness.""" + +import json +from datetime import UTC, datetime +from typing import Any, cast + +import pytest +from nemo_insights_plugin.analyst import model_config +from nemo_insights_plugin.analyst.agent import KICKOFF, Analyst, build_analyst_agent +from nemo_insights_plugin.analyst.analyst_backend import AnalystBackend +from nemo_insights_plugin.analyst.deps import AnalystDeps +from nooa.unifiedllm import FakeLLMClient, LLMResponse, ToolCall + + +@pytest.fixture(autouse=True) +def _use_fake_summarizer_llm(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "nemo_insights_plugin.analyst.agent.get_fast_model", + FakeLLMClient, + ) + + +def _exec_response(code: str) -> LLMResponse: + return LLMResponse( + raw_response=None, + content="", + finish_reason="tool_calls", + assistant_message={"role": "assistant", "content": ""}, + tool_calls=[ + ToolCall( + id="call_exec", + name="execute_python", + arguments=json.dumps({"code": code}), + ) + ], + ) + + +def test_smart_model_uses_opus_through_openai_compatible_gateway(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("INFERENCE_API_KEY", "test-key") + model_config.get_smart_model.cache_clear() + model_config._completion_client.cache_clear() + + client = model_config.get_smart_model() + try: + assert client.model == "openai/aws/anthropic/bedrock-claude-opus-4-8" + assert client.config == { + "api_base": "https://inference-api.nvidia.com/v1", + "api_key": "test-key", + } + finally: + client.close() + model_config.get_smart_model.cache_clear() + model_config._completion_client.cache_clear() + + +async def test_nooa_codeact_returns_typed_analyst_result_and_receives_prompt() -> None: + fake = FakeLLMClient( + scripted_responses=[ + _exec_response( + "return_result(result={" + "'summary': 'No high-impact failures found.', " + "'new_insights': [], 'updated_insights': []})" + ) + ] + ) + analyst = build_analyst_agent( + deps=AnalystDeps(agent="target-agent", workspace="private-workspace"), + agent="target-agent", + agent_spec="# Expected behavior\nBe accurate.", + llm=fake, + ) + + result = await analyst.analyze(KICKOFF) + + assert isinstance(analyst, Analyst) + assert result.summary == "No high-impact failures found." + assert result.new_insights == [] + assert result.updated_insights == [] + rendered_messages = json.dumps(fake.last_messages) + assert "target-agent" in rendered_messages + assert "Expected behavior" in rendered_messages + assert "One method, two modes" in rendered_messages + assert "private-workspace" not in rendered_messages + assert fake.last_tools is not None + assert {tool.name for tool in fake.last_tools} == {"execute_python", "return_result"} + + +class _SpanBackend: + def __init__(self) -> None: + self.kwargs: dict[str, object] | None = None + + async def list_span_groups(self, **kwargs: object) -> dict[str, object]: + self.kwargs = kwargs + return {"groups": [], "count": 0, "total": 0, "truncated": False} + + +async def test_nooa_read_method_preserves_run_scope() -> None: + backend = _SpanBackend() + since = datetime(2026, 8, 1, tzinfo=UTC) + analyst = build_analyst_agent( + deps=AnalystDeps( + agent="target-agent", + workspace="workspace", + backend=cast(AnalystBackend, backend), + since=since, + evaluation_id="eval-1", + ), + agent="target-agent", + llm=FakeLLMClient(), + ) + + result = await analyst.fetch_spans( + filter={"status": "error"}, + group_by="session_id", + limit=500, + ) + + assert result["total"] == 0 + assert backend.kwargs == { + "workspace": "workspace", + "filter": {"status": "error", "agent_name": "target-agent"}, + "group_by": "session_id", + "sort": "-span_count", + "limit": 200, + "since": since, + "evaluation_id": "eval-1", + } + + +def test_nooa_runtime_options_are_forwarded() -> None: + analyst = build_analyst_agent( + deps=AnalystDeps(agent="target-agent", workspace="workspace"), + agent="target-agent", + llm=FakeLLMClient(), + context={"runtime_override": "forwarded"}, + ) + + assert cast(Any, analyst.context)["runtime_override"] == "forwarded" diff --git a/plugins/nemo-insights/tests/test_analyst_observability.py b/plugins/nemo-insights/tests/test_analyst_observability.py new file mode 100644 index 0000000000..edc0b902f3 --- /dev/null +++ b/plugins/nemo-insights/tests/test_analyst_observability.py @@ -0,0 +1,70 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Nooa tracing adapter tests.""" + +from typing import cast + +import pytest +from nemo_insights_plugin.analyst import observability + + +def test_setup_maps_intake_endpoint_auth_resource_and_session(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, object] = {} + exporter = object() + + def fake_otlp(*, endpoint: str, headers: dict[str, str] | None) -> object: + seen["otlp"] = (endpoint, headers) + return exporter + + def fake_enable_tracing(*, exporters: list[object], extra_resource_attrs: dict[str, str]) -> None: + seen["enable"] = (exporters, extra_resource_attrs) + + monkeypatch.setattr(observability.exporters, "otlp", fake_otlp) + monkeypatch.setattr(observability, "enable_tracing", fake_enable_tracing) + monkeypatch.setattr(observability, "set_session", lambda session_id: seen.setdefault("session", session_id)) + monkeypatch.setattr(observability, "_otlp_auth_headers", lambda base_url: {"Authorization": "Bearer test"}) + + configured = observability.setup_analyst_observability( + base_url="https://platform.example/", + workspace="workspace", + target_agent="target-agent", + ) + + assert configured.endpoint == ("https://platform.example/apis/intake/v2/workspaces/workspace/ingest/otlp/v1/traces") + assert seen["otlp"] == (configured.endpoint, {"Authorization": "Bearer test"}) + enabled_exporters, attributes = cast(tuple[list[object], dict[str, str]], seen["enable"]) + assert enabled_exporters == [exporter] + assert attributes["gen_ai.agent.name"] == observability.ANALYST_OBSERVABILITY_AGENT_NAME + assert attributes["nemo.insights.target_agent"] == "target-agent" + assert seen["session"] == configured.session_id + + +def test_shutdown_flushes_nooa_traces(monkeypatch: pytest.MonkeyPatch) -> None: + flushed: list[bool] = [] + monkeypatch.setattr(observability, "flush_traces", lambda: flushed.append(True)) + + observability.AnalystObservability(endpoint="endpoint", session_id="session").shutdown() + + assert flushed == [True] + + +def test_remote_http_export_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + def unexpected_otlp(*args: object, **kwargs: object) -> object: + raise AssertionError("remote HTTP must be rejected before configuring the exporter") + + monkeypatch.setattr(observability.exporters, "otlp", unexpected_otlp) + + with pytest.raises(ValueError, match="must use HTTPS"): + observability.setup_analyst_observability( + base_url="http://platform.example", + workspace="workspace", + target_agent="target-agent", + ) + + +@pytest.mark.parametrize("base_url", ["http://localhost:8080", "http://127.0.0.1:8080", "http://[::1]:8080"]) +def test_loopback_http_export_is_allowed(base_url: str) -> None: + assert observability.build_intake_otlp_traces_endpoint(base_url=base_url, workspace="workspace") == ( + f"{base_url}/apis/intake/v2/workspaces/workspace/ingest/otlp/v1/traces" + ) diff --git a/plugins/nemo-insights/tests/test_analyst_run.py b/plugins/nemo-insights/tests/test_analyst_run.py index 6ba79439cd..66169a9dc7 100644 --- a/plugins/nemo-insights/tests/test_analyst_run.py +++ b/plugins/nemo-insights/tests/test_analyst_run.py @@ -3,8 +3,14 @@ """The ``run_analyst`` client injection contract.""" +from typing import cast + import pytest from nemo_insights_plugin.analyst import run as run_module +from nemo_insights_plugin.analyst.deps import AnalystDeps +from nemo_platform import AsyncNeMoPlatform +from nooa.context_blocks import ResultStatus +from nooa.events import LLMComplete, PythonOutput class FakeClient: @@ -26,11 +32,16 @@ def fake_make_backend(*, client: FakeClient, insights_output: str | None, local_ seen["local_only"] = local_only return FakeBackend() - async def fake_run_agent(analyst: object, deps: object, *, verbose: bool) -> object: + async def fake_run_agent(analyst: object, *, verbose: bool) -> object: return object() monkeypatch.setattr(run_module, "make_analyst_backend", fake_make_backend) - monkeypatch.setattr(run_module, "build_analyst_agent", lambda **kwargs: object()) + + def fake_build_agent(**kwargs: object) -> object: + seen["build_kwargs"] = kwargs + return object() + + monkeypatch.setattr(run_module, "build_analyst_agent", fake_build_agent) monkeypatch.setattr(run_module, "_run_agent", fake_run_agent) @@ -44,14 +55,28 @@ async def test_injected_client_is_used_and_closed(monkeypatch: pytest.MonkeyPatc agent_spec=None, workspace="workspace", base_url="https://platform", - client=client, # type: ignore[arg-type] + client=cast(AsyncNeMoPlatform, client), ) assert report == "REPORT" assert seen["backend_client"] is client + build_kwargs = cast(dict[str, object], seen["build_kwargs"]) + assert cast(AnalystDeps, build_kwargs["deps"]).backend is not None assert client.closed +def test_litellm_compatibility_is_enabled(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeLiteLLM: + drop_params = False + + fake_litellm = FakeLiteLLM() + monkeypatch.setattr(run_module.importlib, "import_module", lambda name: fake_litellm) + + run_module._enable_litellm_drop_params() + + assert fake_litellm.drop_params is True + + async def test_client_closed_when_backend_construction_raises(monkeypatch: pytest.MonkeyPatch) -> None: client = FakeClient() @@ -66,12 +91,61 @@ def raising_backend(*, client: FakeClient, insights_output: str | None, local_on agent_spec=None, workspace="workspace", base_url="https://platform", - client=client, # type: ignore[arg-type] + client=cast(AsyncNeMoPlatform, client), + ) + + assert client.closed + + +async def test_client_closed_when_litellm_initialization_raises(monkeypatch: pytest.MonkeyPatch) -> None: + client = FakeClient() + + def raising_litellm_initialization() -> None: + raise RuntimeError("litellm failed") + + monkeypatch.setattr(run_module, "_enable_litellm_drop_params", raising_litellm_initialization) + + with pytest.raises(RuntimeError, match="litellm failed"): + await run_module.run_analyst( + agent="agent", + agent_spec=None, + workspace="workspace", + base_url="https://platform", + client=cast(AsyncNeMoPlatform, client), ) assert client.closed +def test_verbose_echo_maps_nooa_reasoning_tools_and_execution(capsys: pytest.CaptureFixture[str]) -> None: + run_module._echo_event( + LLMComplete( + reasoning_content="inspect the failing sessions", + tool_calls=[ + { + "tool_call_id": "call-1", + "function_name": "execute_python", + "arguments": '{"code":"await self.fetch_spans()"}', + } + ], + ) + ) + run_module._echo_event( + PythonOutput( + tool_call_id="call-1", + execution_count=1, + execution_status=ResultStatus.COMPLETE, + stdout="2 sessions\n", + ) + ) + + assert capsys.readouterr().err.splitlines() == [ + "[thought] inspect the failing sessions", + '[tool] execute_python({"code":"await self.fetch_spans()"})', + "[result] execute_python -> 2 sessions", + ] + + async def test_client_closed_when_observability_shutdown_raises(monkeypatch: pytest.MonkeyPatch) -> None: client = FakeClient() seen: dict[str, object] = {} @@ -94,7 +168,7 @@ def shutdown(self) -> None: agent_spec=None, workspace="workspace", base_url="https://platform", - client=client, # type: ignore[arg-type] + client=cast(AsyncNeMoPlatform, client), ) assert client.closed diff --git a/plugins/nemo-insights/tests/test_cli_profile.py b/plugins/nemo-insights/tests/test_cli_profile.py index cb9d95c573..c03fd0565e 100644 --- a/plugins/nemo-insights/tests/test_cli_profile.py +++ b/plugins/nemo-insights/tests/test_cli_profile.py @@ -13,7 +13,7 @@ from nemo_insights_plugin.contracts.profile import DEFAULT_BASE_URL from nemo_insights_plugin.preflight import AnalysisProbes from nemo_platform import NeMoPlatformError -from pydantic_ai import AgentRunError +from nooa import GenerationError from typer.testing import CliRunner runner = CliRunner() @@ -521,13 +521,13 @@ async def fail_analysis(**kwargs: object) -> str: assert "Traceback" not in result.output -def test_analyze_renders_agent_run_error_with_model_and_usage_guidance( +def test_analyze_renders_generation_error_with_model_and_usage_guidance( app: typer.Typer, profile_tree: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: async def fail_analysis(**kwargs: object) -> str: - raise AgentRunError("request limit exceeded") + raise GenerationError("request limit exceeded") monkeypatch.setattr(cli, "run_analyst", fail_analysis) monkeypatch.chdir(profile_tree) diff --git a/plugins/nemo-insights/tests/testbed/test_cli.py b/plugins/nemo-insights/tests/testbed/test_cli.py index 7a982c2d59..6caddc8507 100644 --- a/plugins/nemo-insights/tests/testbed/test_cli.py +++ b/plugins/nemo-insights/tests/testbed/test_cli.py @@ -382,7 +382,7 @@ def _write_analyst_lock( *, unrelated_version: str = "1", transitive_version: str = "1", - anthropic_version: str = "1", + litellm_version: str = "1", ) -> None: path.write_text( f""" @@ -396,29 +396,21 @@ def _write_analyst_lock( name = "nemo-insights-plugin" version = "1" dependencies = [ - {{ name = "pydantic-ai-harness" }}, - {{ name = "pydantic-ai-slim", extra = ["anthropic"] }}, + {{ name = "nooa" }}, ] [[package]] -name = "pydantic-ai-harness" +name = "nooa" version = "2" -dependencies = [{{ name = "transitive" }}] - -[[package]] -name = "pydantic-ai-slim" -version = "3" - -[package.optional-dependencies] -anthropic = [{{ name = "anthropic" }}] +dependencies = [{{ name = "transitive" }}, {{ name = "litellm" }}] [[package]] name = "transitive" version = "{transitive_version}" [[package]] -name = "anthropic" -version = "{anthropic_version}" +name = "litellm" +version = "{litellm_version}" """, encoding="utf-8", ) @@ -464,7 +456,7 @@ def test_analyst_hash_tracks_resolved_dependency_closure(tmp_path): lockfile, unrelated_version="changed", transitive_version="changed", - anthropic_version="changed", + litellm_version="changed", ) assert cli._analyst_sha256(plugin_root, lockfile) != transitive_changed diff --git a/third_party/licenses.jsonl b/third_party/licenses.jsonl index 94d0c116fb..f9885094dd 100644 --- a/third_party/licenses.jsonl +++ b/third_party/licenses.jsonl @@ -79,14 +79,12 @@ {"name": "flatbuffers", "license": "APACHE-2.0", "compatible": true} {"name": "frozenlist", "license": "APACHE-2.0", "compatible": true} {"name": "fsspec", "license": "BSD-3-CLAUSE", "compatible": true} -{"name": "genai-prices", "license": "MIT", "compatible": true} {"name": "gitdb", "license": "BSD-3-CLAUSE", "compatible": true} {"name": "gitpython", "license": "BSD-3-CLAUSE", "compatible": true} {"name": "google-auth", "license": "APACHE-2.0", "compatible": true} {"name": "google-genai", "license": "APACHE-2.0", "compatible": true} {"name": "googleapis-common-protos", "license": "APACHE-2.0", "compatible": true} {"name": "greenlet", "license": "MIT", "compatible": true} -{"name": "griffelib", "license": "ISC", "compatible": true} {"name": "grpcio", "license": "APACHE-2.0", "compatible": true} {"name": "gunicorn", "license": "MIT", "compatible": true} {"name": "h11", "license": "MIT", "compatible": true} @@ -95,12 +93,10 @@ {"name": "hf-xet", "license": "APACHE-2.0", "compatible": true} {"name": "hpack", "license": "MIT", "compatible": true} {"name": "httpcore", "license": "BSD-3-CLAUSE", "compatible": true} -{"name": "httpcore2", "license": "BSD-3-CLAUSE", "compatible": true} {"name": "httptools", "license": "MIT", "compatible": true} {"name": "httpx", "license": "BSD-3-CLAUSE", "compatible": true} {"name": "httpx-retries", "license": "MIT", "compatible": true} {"name": "httpx-sse", "license": "MIT", "compatible": true} -{"name": "httpx2", "license": "BSD-3-CLAUSE", "compatible": true} {"name": "huggingface-hub", "license": "APACHE-2.0", "compatible": true} {"name": "hvac", "license": "APACHE-2.0", "compatible": true} {"name": "hyperframe", "license": "MIT", "compatible": true} @@ -153,7 +149,6 @@ {"name": "langsmith", "license": "MIT", "compatible": true} {"name": "lark", "license": "MIT", "compatible": true} {"name": "litellm", "license": "MIT", "compatible": true} -{"name": "logfire-api", "license": "MIT", "compatible": true} {"name": "loguru", "license": "MIT", "compatible": true} {"name": "lxml", "license": "BSD-3-CLAUSE", "compatible": true} {"name": "lz4", "license": "BSD-3-CLAUSE", "compatible": true} @@ -258,12 +253,8 @@ {"name": "pyasn1-modules", "license": "BSD-3-CLAUSE", "compatible": true} {"name": "pycparser", "license": "BSD-3-CLAUSE", "compatible": true} {"name": "pydantic", "license": "MIT", "compatible": true} -{"name": "pydantic-ai-harness", "license": "MIT", "compatible": true} -{"name": "pydantic-ai-slim", "license": "MIT", "compatible": true} {"name": "pydantic-core", "license": "MIT", "compatible": true} {"name": "pydantic-extra-types", "license": "MIT", "compatible": true} -{"name": "pydantic-graph", "license": "MIT", "compatible": true} -{"name": "pydantic-monty", "license": "MIT", "compatible": true} {"name": "pydantic-settings", "license": "MIT", "compatible": true} {"name": "pygments", "license": "BSD-2-CLAUSE", "compatible": true} {"name": "pyjwt", "license": "MIT", "compatible": true} @@ -335,7 +326,6 @@ {"name": "tornado", "license": "APACHE-2.0", "compatible": true} {"name": "tqdm", "license": "MIT", "compatible": true} {"name": "transformers", "license": "APACHE-2.0", "compatible": true} -{"name": "truststore", "license": "MIT", "compatible": true} {"name": "typer", "license": "MIT", "compatible": true} {"name": "types-aioboto3", "license": "MIT", "compatible": true} {"name": "types-aiobotocore", "license": "MIT", "compatible": true} diff --git a/uv.lock b/uv.lock index 9f019d5b97..f0c5f0c448 100644 --- a/uv.lock +++ b/uv.lock @@ -1895,19 +1895,6 @@ requires-dist = [ ] provides-extras = ["tests", "lint"] -[[package]] -name = "genai-prices" -version = "0.0.62" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpx2", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "pydantic", extra = ["email"], marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0c/8e/ed322d1f22b57fd455749bdbe2f285d310e1c1ebe921cb3d5c0b920de648/genai_prices-0.0.62.tar.gz", hash = "sha256:baf1ffa64be0d15577878216464d6a2d04244db5fbdf78d56bde43809e7aef44", size = 67611, upload-time = "2026-05-25T18:47:16.306Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/35/ce64112dcc6f406b3e290dcf57a97acfa2b7d3d0391979219cb9d4a9db6d/genai_prices-0.0.62-py3-none-any.whl", hash = "sha256:5d9ab0d9e5d81e035f88bf591fb6a8dde527922786acf1ee2737358f7bbe0167", size = 70333, upload-time = "2026-05-25T18:47:17.642Z" }, -] - [[package]] name = "genson" version = "1.3.0" @@ -3217,15 +3204,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/fe/bb185f11bad82f2637e3cd8cbf6b200cbb6ed56ac395de47ea05a60d4649/llguidance-1.7.6-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:9c54c899db8cb4b4fba128a7d844730066576c70d806c95ada92b2bd2d6ab498", size = 3138127, upload-time = "2026-06-03T20:13:11.649Z" }, ] -[[package]] -name = "logfire-api" -version = "4.37.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/03/04/471b916249fe7e22056818ca734af46418cd3ff9b9b920c1829c3627b4d2/logfire_api-4.37.0.tar.gz", hash = "sha256:0f62debd6ed593d51307277bd6d5636b57bda07935b5604b96db10fe64441af4", size = 88906, upload-time = "2026-06-12T20:47:08.163Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/2f/23e5b8fa22f75f73965c72e5c29e6fb8715263457394601e254fe26fbe31/logfire_api-4.37.0-py3-none-any.whl", hash = "sha256:1d756f8ba23aa56d438e0ba2c0f529a00fcac975b8785c561b058267f9465088", size = 138710, upload-time = "2026-06-12T20:47:05.526Z" }, -] - [[package]] name = "loguru" version = "0.7.3" @@ -4564,15 +4542,13 @@ version = "0.1.0" source = { editable = "plugins/nemo-insights" } dependencies = [ { name = "fastapi", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "genai-prices", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "httpx", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-platform", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-platform-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nooa", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "opentelemetry-exporter-otlp", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "opentelemetry-sdk", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "pydantic", extra = ["email"], marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "pydantic-ai-harness", extra = ["code-mode"], marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "pydantic-ai-slim", extra = ["anthropic"], marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "pyyaml", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "typer", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "tzdata", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, @@ -4581,15 +4557,13 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, - { name = "genai-prices", specifier = "==0.0.62" }, { name = "httpx" }, { name = "nemo-platform", editable = "packages/nemo_platform" }, { name = "nemo-platform-plugin", editable = "packages/nemo_platform_plugin" }, + { name = "nooa", git = "https://github.com/NVIDIA-NeMo/labs-OO-Agents.git?rev=6e0274dd03f883254a084cfb9f871ea580e03434" }, { name = "opentelemetry-exporter-otlp", specifier = ">=1.42.1" }, { name = "opentelemetry-sdk", specifier = ">=1.42.1" }, { name = "pydantic", specifier = ">=2.10.6" }, - { name = "pydantic-ai-harness", extras = ["code-mode"], specifier = "==0.3.0" }, - { name = "pydantic-ai-slim", extras = ["anthropic"], specifier = "==1.105.0" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "typer", specifier = ">=0.20.0" }, { name = "tzdata", specifier = "==2026.2" }, @@ -9264,46 +9238,6 @@ email = [ { name = "email-validator", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] -[[package]] -name = "pydantic-ai-harness" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic-ai-slim", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/ae/95ada09e80a7cf71a1a87fb2f824450387b324ddcf68a0dc7c54550c8d3e/pydantic_ai_harness-0.3.0.tar.gz", hash = "sha256:3a803c2569a3346830443ee7a646b0c2267659d2265ada560c12430cd16d2ffe", size = 536553, upload-time = "2026-05-13T19:23:08.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/08/6c3872d654ef40b0dde64bd839e857fc08f930386b583c369b7a07df27ef/pydantic_ai_harness-0.3.0-py3-none-any.whl", hash = "sha256:b3d363ce3bdadba89e6e3378c66a44ce77808a8fa959429d5d7bc07bea8c854f", size = 25497, upload-time = "2026-05-13T19:23:07.356Z" }, -] - -[package.optional-dependencies] -code-mode = [ - { name = "pydantic-monty", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, -] - -[[package]] -name = "pydantic-ai-slim" -version = "1.105.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "genai-prices", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "griffelib", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "httpx", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "opentelemetry-api", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "pydantic", extra = ["email"], marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "pydantic-graph", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "typing-inspection", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cd/ae/1b0370f9b9f1ca7ccf2e6b51ec5a8d11da11d9dd621e5eb015c6420c5e9b/pydantic_ai_slim-1.105.0.tar.gz", hash = "sha256:8b4ad8034b40ab3bde8e0c6285082a204ecd203007150a47943f192b474e06e9", size = 772048, upload-time = "2026-06-02T06:20:01.522Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/6e/8afdff693d21c0743ee71d792ce90afc27d4ddbaf7270d969a84452cfd0d/pydantic_ai_slim-1.105.0-py3-none-any.whl", hash = "sha256:1e65561ba9a58a9d8fc3a63b550c3c2b2c4017da275dea78291e526aa06298d8", size = 956108, upload-time = "2026-06-02T06:19:52.821Z" }, -] - -[package.optional-dependencies] -anthropic = [ - { name = "anthropic", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, -] - [[package]] name = "pydantic-core" version = "2.41.5" @@ -9349,48 +9283,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" }, ] -[[package]] -name = "pydantic-graph" -version = "1.105.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpx", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "logfire-api", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "pydantic", extra = ["email"], marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "typing-inspection", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/98/0361e1eb28f8d107e4e12dcd2d14eabef55f4a8ca18b1a6f185df74934c0/pydantic_graph-1.105.0.tar.gz", hash = "sha256:3f5cf97d544b900098d3cc2dbd6a8cdd79ea59dac610d7651f86c9228d33c0b9", size = 62570, upload-time = "2026-06-02T06:20:05.158Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/1b/13882fd4d70299dc2995bee20f21599cb8d453b27f44e239f82384d4ea3f/pydantic_graph-1.105.0-py3-none-any.whl", hash = "sha256:ba76d77ad21a13f2961fbda9d988f3d5a3d9ffc1817ee912e0ea59b0b5a9e825", size = 80099, upload-time = "2026-06-02T06:19:57.098Z" }, -] - -[[package]] -name = "pydantic-monty" -version = "0.0.18" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/14/5b/bb6a8bfdf13eb9808c966bdac064a40ce9ac881ec6d64dba3e055888f22b/pydantic_monty-0.0.18.tar.gz", hash = "sha256:c43794c7c4664fa1403d4841459d0e23f01b4f552283db638f5b40ced4dac6a1", size = 1197105, upload-time = "2026-05-29T08:31:41.077Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/83/8ccf04b2f9642153702c6eb22d0a0abad57014fd85879ab1f6341b5a1946/pydantic_monty-0.0.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2988d3e511131680d9de60647bfe5c697b1e4e4cad474fecf1451c53314e8520", size = 8688756, upload-time = "2026-05-29T08:30:24.677Z" }, - { url = "https://files.pythonhosted.org/packages/de/b8/c7881620a812850772ae0924863d1399cbecb3e4c8c455a9c7a9c20b06f8/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c688dc7c7b28a2f389a61217bae1d50658e28f960abe33a254328cadc8a17a", size = 8171342, upload-time = "2026-05-29T08:29:44.773Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ea/6d10ea1657e303295a75a3854f6dd6b378cbd501dcd1782844107b932acd/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f469293f5a776231b9617787a5dd6c58048c6568833ee6848338be6391f15449", size = 8591152, upload-time = "2026-05-29T08:31:29.944Z" }, - { url = "https://files.pythonhosted.org/packages/93/fb/ab85c4676ccffd0f3b7f509a4c8b396b07c7860577def2f58a22b3fe8aef/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6e0cd4991947b8a47210985836f94c14139bc3ea06253d2f38d0d752b165517", size = 9183064, upload-time = "2026-05-29T08:31:12.776Z" }, - { url = "https://files.pythonhosted.org/packages/5c/12/11292178b487052f9e0a1ea7b3d17e1e3bfcba598fefce8cb9ed8712021e/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58b4b96863abbc0ffa5baf64779b3fbb6376cc763488b027ecaddb5052d5ff17", size = 9285440, upload-time = "2026-05-29T08:29:35.642Z" }, - { url = "https://files.pythonhosted.org/packages/79/42/7afb8dde4414d84c042f2cc1b0870a7351cae2e4fbf3fef89b3aa683eca9/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1208bd5976c1254b2705559836511c86ea3cc51d9c6f688b1e3e22984715dbc", size = 9233438, upload-time = "2026-05-29T08:31:39.177Z" }, - { url = "https://files.pythonhosted.org/packages/5f/46/89124cf146725e354b44685b477da6b0b5dc07a8a3af2aec309e88c55405/pydantic_monty-0.0.18-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:977168253d8f6b49bb64f128d02c4b62ee8b435fd62876268377e1f2b00cc00f", size = 8351900, upload-time = "2026-05-29T08:31:08.348Z" }, - { url = "https://files.pythonhosted.org/packages/00/c5/dda512f5a9c68242faea368844aacefb54c2a13f9b40bee5ab48ccdc78c5/pydantic_monty-0.0.18-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c21319a091dc1ff1fccb8647dae5bb543b3f528c556319ed15c7992dfa9b5e87", size = 8901559, upload-time = "2026-05-29T08:29:26.047Z" }, - { url = "https://files.pythonhosted.org/packages/c6/9c/7628423f955efb669d2cc1d3a8909bf8271b543ce27036e18229ad0e51e8/pydantic_monty-0.0.18-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d47976a18e3e3da0e86f8cf6068fc8a125930422dc10d3d3bf0b5410a9e9282e", size = 8689116, upload-time = "2026-05-29T08:29:30.906Z" }, - { url = "https://files.pythonhosted.org/packages/c2/9c/51f8ffa4340bc1986eb9240b0756724f5fdf3c463d6d66c8cc8450e1446d/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb84fe51e3f6e00a0cc9628e0acf5904d982c8cc85d4db42b7532d071602f703", size = 8178458, upload-time = "2026-05-29T08:30:09.015Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ec/7eb84aeb86631571f9acffc91552217dc2b524b00db37b8d10517df467d1/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a2e86f3ba67b094d498bc8b071d5e3b8b034bb9f3006216a357c5f71d49d6132", size = 8591295, upload-time = "2026-05-29T08:29:23.357Z" }, - { url = "https://files.pythonhosted.org/packages/da/69/d5210208fa116593bd81789e2e5abb6222d38087c9c1879e18f7e7620275/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb8a412aa0336d4e0334a4e05a54b391d91fa334020bd295a280285ba17ab5ca", size = 9184647, upload-time = "2026-05-29T08:29:51.852Z" }, - { url = "https://files.pythonhosted.org/packages/ed/74/4d95c8f65072964c4cb798dbe87d2e1c1349607ab5905874bfa8a0b94de1/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1056ce3acef60ab880314caf97775c5ea41b30f9306d0dd28cefeffd42dda366", size = 9291637, upload-time = "2026-05-29T08:31:19.966Z" }, - { url = "https://files.pythonhosted.org/packages/d0/40/5817780313a3e089ca6f860fbdc836d3aa33790eb72c2e8fe2edc877820e/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9593caa45b68fd07ac67bea9974effbe2a1c5453d8a106b913596b0dff6d8471", size = 9233863, upload-time = "2026-05-29T08:31:42.838Z" }, - { url = "https://files.pythonhosted.org/packages/b3/55/f77565c5797502c7ba995dc23a26759cb33023590f9f2926bc4e8ab87afe/pydantic_monty-0.0.18-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:e15e18ed27a17ee607ad3bcbf82f25a9ec4d496ff493ff64cdabb83ca2174cec", size = 8358264, upload-time = "2026-05-29T08:31:32.531Z" }, - { url = "https://files.pythonhosted.org/packages/1b/1f/c700eb800868d1be4078a99cb00e23fb7e5d8760c8e83b729bba27b5bf92/pydantic_monty-0.0.18-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:96d75a418d96640ff0c7f354a78fc4470a2d0f40ba69e886c2f32756d594d9a0", size = 8906664, upload-time = "2026-05-29T08:30:04.055Z" }, -] - [[package]] name = "pydantic-settings" version = "2.14.2"