From 48e76219a16774079c1794b1cee638cb05b59a49 Mon Sep 17 00:00:00 2001 From: mschwab Date: Thu, 16 Jul 2026 14:22:30 -0700 Subject: [PATCH 01/11] feat(agents): register and visualize existing NAT agents, incl. external Studio had no way to bring in an existing NAT agent or see its workflow. Add register + visualize paths on the Agents surface and a first-class notion of agents that run outside NeMo Platform. - Register modal: "Connect running agent" (by URL) or "Paste config" (NAT YAML) - Onboarding empty state with dual CTA + NAT docs link - Workflow tab on the agent panel: managed -> config graph + YAML; external -> A2A card skills + endpoint - Agent entity gains source/endpoint/card; POST /agents takes config XOR url and fetches the A2A agent card for external agents - External agents cannot be deployed/evaluated (API 400 + UI gating); chat tab shows an external-endpoint message instead of deploy prompts Backend: a2a.py card fetch (timeout/size-capped), unified create validator, deploy guard; tests for a2a, create, and deploy paths. Deferred: A2A chat proxy. Flagged for review: SSRF on server-side card fetch, Python SDK (Stainless) regen. Signed-off-by: mschwab --- plugins/nemo-agents/openapi/openapi.yaml | 51 ++- .../nemo-agents/src/nemo_agents_plugin/a2a.py | 75 ++++ .../src/nemo_agents_plugin/api/v2/agents.py | 38 +- .../nemo_agents_plugin/api/v2/deployments.py | 8 + .../src/nemo_agents_plugin/entities.py | 29 +- .../src/nemo_agents_plugin/schema.py | 33 +- plugins/nemo-agents/tests/unit/test_a2a.py | 58 +++ .../nemo-agents/tests/unit/test_agents_api.py | 90 +++++ .../tests/unit/test_deployments_api.py | 63 +++ .../src/api/agents/parseAgentConfig.test.ts | 46 +++ .../studio/src/api/agents/parseAgentConfig.ts | 38 ++ .../dataViews/AgentsDataView/index.tsx | 103 +++-- .../AgentPanel/AgentDetailsContent.tsx | 369 ++++++++++-------- .../AgentPanel/AgentWorkflowContent.tsx | 183 +++++++++ .../AgentPanel/ChatPlaygroundContent.tsx | 54 ++- .../AgentPanels/AgentPanel/index.tsx | 6 + .../AgentPanel/summarizeAgentCard.test.ts | 38 ++ .../AgentPanel/summarizeAgentCard.ts | 43 ++ .../AgentPanel/summarizeAgentWorkflow.test.ts | 53 +++ .../AgentPanel/summarizeAgentWorkflow.ts | 46 +++ .../AgentPanels/AgentPanel/types.ts | 6 +- web/packages/studio/src/constants/links.ts | 1 + .../RegisterAgentModal/index.tsx | 180 +++++++++ .../RegisterAgentModal/type.ts | 36 ++ .../routes/agents/AgentsListRoute/index.tsx | 22 +- 25 files changed, 1427 insertions(+), 242 deletions(-) create mode 100644 plugins/nemo-agents/src/nemo_agents_plugin/a2a.py create mode 100644 plugins/nemo-agents/tests/unit/test_a2a.py create mode 100644 plugins/nemo-agents/tests/unit/test_deployments_api.py create mode 100644 web/packages/studio/src/api/agents/parseAgentConfig.test.ts create mode 100644 web/packages/studio/src/api/agents/parseAgentConfig.ts create mode 100644 web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentWorkflowContent.tsx create mode 100644 web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentCard.test.ts create mode 100644 web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentCard.ts create mode 100644 web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow.test.ts create mode 100644 web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow.ts create mode 100644 web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/index.tsx create mode 100644 web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/type.ts diff --git a/plugins/nemo-agents/openapi/openapi.yaml b/plugins/nemo-agents/openapi/openapi.yaml index 5e53975db4..9c06a83671 100644 --- a/plugins/nemo-agents/openapi/openapi.yaml +++ b/plugins/nemo-agents/openapi/openapi.yaml @@ -8,7 +8,11 @@ paths: tags: - Agents summary: Create Agent - description: Create a new agent from a NAT workflow config. + description: "Create an agent.\n\nManaged (``config`` supplied): stores the\ + \ NAT workflow the platform will run.\nExternal (``url`` supplied): fetches\ + \ the running agent's A2A card and stores\na pointer \u2014 the platform never\ + \ runs it. ``CreateAgentRequest`` enforces that\nexactly one of ``config``/``url``\ + \ is present." operationId: create_agent_apis_agents_v2_workspaces__workspace__agents_post parameters: - name: workspace @@ -2281,7 +2285,7 @@ components: type: object title: Config description: NAT workflow config (YAML-equivalent dict, keyed by component - name). + name). Empty for external agents. config_format: type: string title: Config Format @@ -2289,6 +2293,29 @@ components: \ dict. Not read or validated by NAT \u2014 used by NeMo Platform for\ \ future config migration. Currently only 'nat-workflow-v1' is supported." default: nat-workflow-v1 + source: + type: string + enum: + - managed + - external + title: Source + description: '''managed'' (default) agents are deployed and run by NeMo + Platform. ''external'' agents run outside the platform; NMP holds only + a pointer to them.' + default: managed + endpoint: + type: string + title: Endpoint + description: Base URL of an external agent (e.g. http://host:10000). Empty + for managed agents, whose runtime address lives on the AgentDeployment + instead. + default: '' + card: + additionalProperties: true + type: object + title: Card + description: A2A agent card fetched from an external agent at registration + (name, description, skills). Empty for managed agents. id: type: string title: Id @@ -2654,24 +2681,34 @@ components: description: type: string title: Description - description: Human-readable description. + description: Human-readable description. For external agents, falls back + to the agent card's description. default: '' config: + title: Config + description: NAT workflow config dict. Required for a managed agent; omit + for external. additionalProperties: true type: object - title: Config - description: NAT workflow config dict. config_format: type: string title: Config Format description: Config format identifier. default: nat-workflow-v1 + url: + title: Url + description: Base URL of a running external agent (e.g. http://host:10000). + Provide instead of config. + type: string type: object required: - name - - config title: CreateAgentRequest - description: Request body for ``POST /v2/workspaces/{workspace}/agents``. + description: "Request body for ``POST /v2/workspaces/{workspace}/agents``.\n\ + \nCreates either a **managed** agent (supply ``config`` \u2014 a NAT workflow\ + \ the\nplatform will run) or an **external** agent (supply ``url`` \u2014\ + \ a pointer to\na NAT agent already running elsewhere, whose A2A card the\ + \ platform fetches\nat creation). Provide exactly one of ``config`` or ``url``." CreateDeploymentRequest: properties: agent: diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py b/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py new file mode 100644 index 0000000000..fcdb2d3800 --- /dev/null +++ b/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""A2A (Agent-to-Agent) discovery helpers. + +An externally-running NAT agent served with ``nat a2a serve`` publishes an +*agent card* at a well-known path. The card describes the agent (name, +description) and its skills (one per workflow function). We fetch it at +registration time so the platform can list and visualize an agent it does not +run. See https://github.com/nvidia/nemo-agent-toolkit A2A server docs. +""" + +from __future__ import annotations + +from typing import Any +from urllib.parse import urljoin + +import httpx + +# Well-known paths that expose an A2A agent card. The spec moved from +# ``agent.json`` to ``agent-card.json``; try the current name first. +AGENT_CARD_PATHS = ("/.well-known/agent-card.json", "/.well-known/agent.json") + +# Cap the card body so a hostile/misconfigured endpoint can't stream us an +# unbounded response. Cards are a few KB in practice. +_MAX_CARD_BYTES = 512 * 1024 +_FETCH_TIMEOUT_S = 10.0 + + +class AgentCardError(Exception): + """Raised when an external agent's card can't be fetched or parsed.""" + + +def _card_url(base_url: str, path: str) -> str: + # urljoin needs a trailing slash on the base to preserve any path prefix, + # and the well-known path is absolute, so join against the origin. + return urljoin(base_url if base_url.endswith("/") else base_url + "/", path.lstrip("/")) + + +async def fetch_agent_card(base_url: str) -> dict[str, Any]: + """Fetch and parse an external agent's A2A card. + + Tries each well-known path in order. Raises :class:`AgentCardError` with a + user-facing message if the endpoint is unreachable, returns non-JSON, or + exposes no recognizable card. The returned dict is the raw card. + """ + base = base_url.strip() + if not base.startswith(("http://", "https://")): + raise AgentCardError("Endpoint must be an http(s) URL.") + + last_error = "no agent card found" + async with httpx.AsyncClient(timeout=_FETCH_TIMEOUT_S, follow_redirects=True) as client: + for path in AGENT_CARD_PATHS: + url = _card_url(base, path) + try: + resp = await client.get(url, headers={"Accept": "application/json"}) + except httpx.HTTPError as exc: + last_error = f"could not reach agent at {base} ({exc.__class__.__name__})" + continue + if resp.status_code != 200: + last_error = f"agent card request returned HTTP {resp.status_code}" + continue + if len(resp.content) > _MAX_CARD_BYTES: + raise AgentCardError("Agent card response is too large.") + try: + card = resp.json() + except ValueError: + last_error = "agent card response was not valid JSON" + continue + if not isinstance(card, dict) or not (card.get("name") or card.get("skills")): + last_error = "response did not look like an A2A agent card" + continue + return card + + raise AgentCardError(f"Could not fetch A2A agent card: {last_error}.") diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/agents.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/agents.py index 06cb89091d..a8193da427 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/agents.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/agents.py @@ -13,6 +13,7 @@ import logging from fastapi import APIRouter, Depends, HTTPException, Query +from nemo_agents_plugin.a2a import AgentCardError, fetch_agent_card from nemo_agents_plugin.api.v2._perms import AgentPerms from nemo_agents_plugin.api.v2.dependencies import get_entity_client from nemo_agents_plugin.authz import scope @@ -50,14 +51,35 @@ async def create_agent( body: CreateAgentRequest, entity_client: NemoEntitiesClient = Depends(get_entity_client), ) -> Agent: - """Create a new agent from a NAT workflow config.""" - agent = Agent( - name=body.name, - workspace=workspace, - description=body.description, - config=body.config, - config_format=body.config_format, - ) + """Create an agent. + + Managed (``config`` supplied): stores the NAT workflow the platform will run. + External (``url`` supplied): fetches the running agent's A2A card and stores + a pointer — the platform never runs it. ``CreateAgentRequest`` enforces that + exactly one of ``config``/``url`` is present. + """ + if body.url: + try: + card = await fetch_agent_card(body.url) + except AgentCardError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + card_description = card.get("description") if isinstance(card.get("description"), str) else "" + agent = Agent( + name=body.name, + workspace=workspace, + description=body.description or card_description or "", + source="external", + endpoint=body.url.strip(), + card=card, + ) + else: + agent = Agent( + name=body.name, + workspace=workspace, + description=body.description, + config=body.config or {}, + config_format=body.config_format, + ) try: saved = await entity_client.create(agent) except NemoEntityConflictError as exc: diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py index d2c674ee2a..87dbc51c03 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py @@ -70,6 +70,14 @@ async def create_deployment( logger.exception("Failed to look up agent '%s'", body.agent) raise HTTPException(status_code=500, detail="Failed to look up agent.") from exc + # External agents run outside NeMo Platform; there is nothing for the + # platform to deploy. Reject rather than spawn a doomed empty-config process. + if agent.source == "external": + raise HTTPException( + status_code=400, + detail=f"Agent '{body.agent}' is external and runs outside NeMo Platform; it cannot be deployed.", + ) + # 2. Build deployment name (auto-generate if not provided) deployment_name = body.name or f"{body.agent}-{secrets.token_hex(4)}" diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/entities.py b/plugins/nemo-agents/src/nemo_agents_plugin/entities.py index 2cec22d610..9c825a7c82 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/entities.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/entities.py @@ -19,6 +19,12 @@ DeploymentStatus = Literal["pending", "starting", "running", "failed", "deleting"] +# Where an agent's runtime lives. ``managed`` agents are run by NeMo Platform +# (config compiled to a ``nat serve`` deployment). ``external`` agents run +# outside the platform; NMP only holds a pointer (``endpoint``) plus the A2A +# agent card fetched at registration time — it never spawns them. +AgentSource = Literal["managed", "external"] + # Runtime backend for an AgentDeployment. ``subprocess`` (the default) runs the # agent as a local ``nat serve`` process reachable on a loopback ``endpoint``. # ``docker``/``k8s`` run the agent as a durable container deployment via the @@ -113,7 +119,7 @@ class Agent(NemoEntity, entity_type="agent"): description: str = Field(default="", description="Human-readable description of the agent.") config: dict[str, Any] = Field( default_factory=dict, - description="NAT workflow config (YAML-equivalent dict, keyed by component name).", + description="NAT workflow config (YAML-equivalent dict, keyed by component name). Empty for external agents.", ) config_format: str = Field( default="nat-workflow-v1", @@ -123,6 +129,27 @@ class Agent(NemoEntity, entity_type="agent"): "Currently only 'nat-workflow-v1' is supported." ), ) + source: AgentSource = Field( + default="managed", + description=( + "'managed' (default) agents are deployed and run by NeMo Platform. " + "'external' agents run outside the platform; NMP holds only a pointer to them." + ), + ) + endpoint: str = Field( + default="", + description=( + "Base URL of an external agent (e.g. http://host:10000). Empty for managed " + "agents, whose runtime address lives on the AgentDeployment instead." + ), + ) + card: dict[str, Any] = Field( + default_factory=dict, + description=( + "A2A agent card fetched from an external agent at registration " + "(name, description, skills). Empty for managed agents." + ), + ) class AgentDeployment(NemoEntity, entity_type="agent_deployment"): diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/schema.py b/plugins/nemo-agents/src/nemo_agents_plugin/schema.py index 2f372d6f85..49538c23e4 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/schema.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/schema.py @@ -24,7 +24,7 @@ from nemo_agents_plugin.entities import Agent, AgentDeployment, DeploymentMode, DeploymentStatus from nemo_platform_plugin.schema import NemoFilter, NemoListResponse -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator # --------------------------------------------------------------------------- # Request bodies — plain BaseModel, named by convention @@ -32,12 +32,37 @@ class CreateAgentRequest(BaseModel): - """Request body for ``POST /v2/workspaces/{workspace}/agents``.""" + """Request body for ``POST /v2/workspaces/{workspace}/agents``. + + Creates either a **managed** agent (supply ``config`` — a NAT workflow the + platform will run) or an **external** agent (supply ``url`` — a pointer to + a NAT agent already running elsewhere, whose A2A card the platform fetches + at creation). Provide exactly one of ``config`` or ``url``. + """ name: str = Field(description="Unique agent name within the workspace.") - description: str = Field(default="", description="Human-readable description.") - config: dict[str, Any] = Field(description="NAT workflow config dict.") + description: str = Field( + default="", + description="Human-readable description. For external agents, falls back to the agent card's description.", + ) + config: dict[str, Any] | None = Field( + default=None, + description="NAT workflow config dict. Required for a managed agent; omit for external.", + ) config_format: str = Field(default="nat-workflow-v1", description="Config format identifier.") + url: str | None = Field( + default=None, + description="Base URL of a running external agent (e.g. http://host:10000). Provide instead of config.", + ) + + @model_validator(mode="after") + def _require_config_xor_url(self) -> CreateAgentRequest: + if self.url: + if self.config is not None: + raise ValueError("Provide either 'config' (managed agent) or 'url' (external agent), not both.") + elif self.config is None: + raise ValueError("'config' is required for a managed agent, or provide 'url' to register an external one.") + return self class CreateDeploymentRequest(BaseModel): diff --git a/plugins/nemo-agents/tests/unit/test_a2a.py b/plugins/nemo-agents/tests/unit/test_a2a.py new file mode 100644 index 0000000000..68e9d9b085 --- /dev/null +++ b/plugins/nemo-agents/tests/unit/test_a2a.py @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for A2A agent-card discovery (no real network; httpx mocked).""" + +from __future__ import annotations + +import httpx +import pytest +import respx +from nemo_agents_plugin.a2a import AgentCardError, fetch_agent_card + +CARD = {"name": "Calculator Agent", "description": "does math", "skills": [{"id": "add", "name": "add"}]} + + +@pytest.mark.asyncio +@respx.mock +async def test_fetch_returns_card_from_primary_path() -> None: + respx.get("http://host:10000/.well-known/agent-card.json").mock(return_value=httpx.Response(200, json=CARD)) + card = await fetch_agent_card("http://host:10000") + assert card["name"] == "Calculator Agent" + + +@pytest.mark.asyncio +@respx.mock +async def test_falls_back_to_legacy_path() -> None: + respx.get("http://host:10000/.well-known/agent-card.json").mock(return_value=httpx.Response(404)) + respx.get("http://host:10000/.well-known/agent.json").mock(return_value=httpx.Response(200, json=CARD)) + card = await fetch_agent_card("http://host:10000") + assert card["skills"][0]["id"] == "add" + + +@pytest.mark.asyncio +async def test_rejects_non_http_url() -> None: + with pytest.raises(AgentCardError, match="http"): + await fetch_agent_card("ftp://host") + + +@pytest.mark.asyncio +@respx.mock +async def test_unreachable_raises() -> None: + respx.get("http://host:10000/.well-known/agent-card.json").mock(side_effect=httpx.ConnectError("refused")) + respx.get("http://host:10000/.well-known/agent.json").mock(side_effect=httpx.ConnectError("refused")) + with pytest.raises(AgentCardError, match="could not reach"): + await fetch_agent_card("http://host:10000") + + +@pytest.mark.asyncio +@respx.mock +async def test_non_card_json_rejected() -> None: + respx.get("http://host:10000/.well-known/agent-card.json").mock( + return_value=httpx.Response(200, json={"unrelated": True}) + ) + respx.get("http://host:10000/.well-known/agent.json").mock( + return_value=httpx.Response(200, json={"unrelated": True}) + ) + with pytest.raises(AgentCardError): + await fetch_agent_card("http://host:10000") diff --git a/plugins/nemo-agents/tests/unit/test_agents_api.py b/plugins/nemo-agents/tests/unit/test_agents_api.py index cc29f30dd4..ea4dd8cb98 100644 --- a/plugins/nemo-agents/tests/unit/test_agents_api.py +++ b/plugins/nemo-agents/tests/unit/test_agents_api.py @@ -193,6 +193,96 @@ def test_create_preserves_workspace_from_path(self, client: TestClient, mock_ent assert resp.json()["workspace"] == "prod" +# --------------------------------------------------------------------------- +# POST /agents with `url` — create an external agent (pointer to a running one) +# --------------------------------------------------------------------------- + + +_CARD = {"name": "Ext Agent", "description": "runs elsewhere", "skills": [{"id": "s1", "name": "s1"}]} + + +class TestCreateExternalAgent: + def test_url_payload_fetches_card_and_creates_external_agent( + self, client: TestClient, mock_entity_client: AsyncMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(agents_router_module, "fetch_agent_card", AsyncMock(return_value=_CARD)) + mock_entity_client.create = AsyncMock(side_effect=lambda a: a) + + resp = client.post( + "/apis/agents/v2/workspaces/default/agents", + json={"name": "ext", "url": "http://host:10000"}, + ) + + assert resp.status_code == 201 + body = resp.json() + assert body["source"] == "external" + assert body["endpoint"] == "http://host:10000" + assert body["card"]["name"] == "Ext Agent" + # Description falls back to the card when not supplied. + assert body["description"] == "runs elsewhere" + + def test_explicit_description_overrides_card( + self, client: TestClient, mock_entity_client: AsyncMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(agents_router_module, "fetch_agent_card", AsyncMock(return_value=_CARD)) + mock_entity_client.create = AsyncMock(side_effect=lambda a: a) + + resp = client.post( + "/apis/agents/v2/workspaces/default/agents", + json={"name": "ext", "url": "http://host:10000", "description": "mine"}, + ) + + assert resp.status_code == 201 + assert resp.json()["description"] == "mine" + + def test_unreachable_card_returns_502( + self, client: TestClient, mock_entity_client: AsyncMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + from nemo_agents_plugin.a2a import AgentCardError + + monkeypatch.setattr( + agents_router_module, + "fetch_agent_card", + AsyncMock(side_effect=AgentCardError("could not reach agent")), + ) + + resp = client.post( + "/apis/agents/v2/workspaces/default/agents", + json={"name": "ext", "url": "http://host:10000"}, + ) + + assert resp.status_code == 502 + assert "could not reach" in resp.json()["detail"] + mock_entity_client.create.assert_not_called() + + def test_conflict_returns_409( + self, client: TestClient, mock_entity_client: AsyncMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(agents_router_module, "fetch_agent_card", AsyncMock(return_value=_CARD)) + mock_entity_client.create = AsyncMock(side_effect=NemoEntityConflictError("already exists")) + + resp = client.post( + "/apis/agents/v2/workspaces/default/agents", + json={"name": "ext", "url": "http://host:10000"}, + ) + + assert resp.status_code == 409 + + def test_both_config_and_url_returns_422(self, client: TestClient, mock_entity_client: AsyncMock) -> None: + resp = client.post( + "/apis/agents/v2/workspaces/default/agents", + json={"name": "ext", "url": "http://host:10000", "config": {"workflow": {}}}, + ) + assert resp.status_code == 422 + + def test_neither_config_nor_url_returns_422(self, client: TestClient, mock_entity_client: AsyncMock) -> None: + resp = client.post( + "/apis/agents/v2/workspaces/default/agents", + json={"name": "ext"}, + ) + assert resp.status_code == 422 + + # --------------------------------------------------------------------------- # GET /agents — list (NemoListResponse envelope) # --------------------------------------------------------------------------- diff --git a/plugins/nemo-agents/tests/unit/test_deployments_api.py b/plugins/nemo-agents/tests/unit/test_deployments_api.py new file mode 100644 index 0000000000..fc53bf6242 --- /dev/null +++ b/plugins/nemo-agents/tests/unit/test_deployments_api.py @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for deployment create route (external-agent guard + happy path). + +Uses FastAPI TestClient with a mocked EntityClient; no network or entity store. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from nemo_agents_plugin.api.v2 import deployments as deployments_router_module +from nemo_agents_plugin.api.v2.dependencies import get_entity_client +from nemo_agents_plugin.entities import Agent + + +@pytest.fixture +def mock_entity_client() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture +def client(mock_entity_client: AsyncMock) -> TestClient: + app = FastAPI() + app.include_router( + deployments_router_module.router, + prefix="/apis/agents/v2/workspaces/{workspace}", + ) + app.dependency_overrides[get_entity_client] = lambda: mock_entity_client + return TestClient(app, raise_server_exceptions=False) + + +def test_deploy_external_agent_returns_400(client: TestClient, mock_entity_client: AsyncMock) -> None: + external = Agent(name="ext", workspace="default", source="external", endpoint="http://host:10000") + mock_entity_client.get = AsyncMock(return_value=external) + + resp = client.post( + "/apis/agents/v2/workspaces/default/deployments", + json={"agent": "ext"}, + ) + + assert resp.status_code == 400 + assert "external" in resp.json()["detail"].lower() + mock_entity_client.create.assert_not_called() + + +def test_deploy_managed_agent_creates_pending(client: TestClient, mock_entity_client: AsyncMock) -> None: + managed = Agent(name="calc", workspace="default", config={"workflow": {"_type": "react_agent"}}) + mock_entity_client.get = AsyncMock(return_value=managed) + mock_entity_client.create = AsyncMock(side_effect=lambda d: d) + + resp = client.post( + "/apis/agents/v2/workspaces/default/deployments", + json={"agent": "calc"}, + ) + + assert resp.status_code == 201 + assert resp.json()["status"] == "pending" + mock_entity_client.create.assert_called_once() diff --git a/web/packages/studio/src/api/agents/parseAgentConfig.test.ts b/web/packages/studio/src/api/agents/parseAgentConfig.test.ts new file mode 100644 index 0000000000..63cb6d22d8 --- /dev/null +++ b/web/packages/studio/src/api/agents/parseAgentConfig.test.ts @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { parseAgentConfig } from '@studio/api/agents/parseAgentConfig'; + +describe('parseAgentConfig', () => { + it('parses a NAT workflow YAML into a config dict', () => { + const config = parseAgentConfig(` +llms: + llm: + _type: openai + model_name: gpt-4o +workflow: + _type: react_agent + llm_name: llm +`); + expect(config).toMatchObject({ + workflow: { _type: 'react_agent', llm_name: 'llm' }, + llms: { llm: { model_name: 'gpt-4o' } }, + }); + }); + + it('accepts JSON input (YAML is a JSON superset)', () => { + expect(parseAgentConfig('{"workflow": {"_type": "react_agent"}}')).toEqual({ + workflow: { _type: 'react_agent' }, + }); + }); + + it('rejects empty input', () => { + expect(() => parseAgentConfig(' ')).toThrow(/paste your nat workflow config/i); + }); + + it('rejects invalid YAML', () => { + expect(() => parseAgentConfig('foo: [1, 2')).toThrow(/not valid yaml/i); + }); + + it('rejects a scalar', () => { + expect(() => parseAgentConfig('just a string')).toThrow(/must be a yaml mapping/i); + }); + + it('rejects a config with no workflow section', () => { + expect(() => parseAgentConfig('llms:\n llm:\n _type: openai')).toThrow( + /missing.*workflow/i + ); + }); +}); diff --git a/web/packages/studio/src/api/agents/parseAgentConfig.ts b/web/packages/studio/src/api/agents/parseAgentConfig.ts new file mode 100644 index 0000000000..8bdb4f5717 --- /dev/null +++ b/web/packages/studio/src/api/agents/parseAgentConfig.ts @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import YAML from 'yaml'; + +/** + * Parses a user-supplied NAT workflow config (YAML or JSON text) into the dict + * the agents API stores as `config`. Validates enough to catch paste mistakes + * early: it must parse to an object and carry a top-level `workflow` section. + * Deep NAT validation only happens at deploy time, so we stay intentionally + * light here and surface a clear message instead. + */ +export const parseAgentConfig = (text: string): Record => { + const trimmed = text.trim(); + if (!trimmed) { + throw new Error('Paste your NAT workflow config to register an agent.'); + } + + let parsed: unknown; + try { + parsed = YAML.parse(trimmed); + } catch (err) { + throw new Error(`Config is not valid YAML: ${(err as Error).message}`); + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Config must be a YAML mapping (a NAT workflow config).'); + } + + const config = parsed as Record; + if (!('workflow' in config)) { + throw new Error( + "Config is missing a top-level 'workflow' section — this is not a NAT workflow." + ); + } + + return config; +}; diff --git a/web/packages/studio/src/components/dataViews/AgentsDataView/index.tsx b/web/packages/studio/src/components/dataViews/AgentsDataView/index.tsx index 48e4341be2..ff71374071 100644 --- a/web/packages/studio/src/components/dataViews/AgentsDataView/index.tsx +++ b/web/packages/studio/src/components/dataViews/AgentsDataView/index.tsx @@ -25,12 +25,12 @@ import { } from '@nemo/sdk/generated/agents/api'; import type { Agent } from '@nemo/sdk/generated/agents/schema/Agent'; import type { AgentDeployment } from '@nemo/sdk/generated/agents/schema/AgentDeployment'; -import { Button, Divider, Flex, Text } from '@nvidia/foundations-react-core'; +import { Badge, Button, Divider, Flex, Text } from '@nvidia/foundations-react-core'; import { getAgentModelNames } from '@studio/components/dataViews/AgentsDataView/utils'; import { DeleteConfirmationModal } from '@studio/components/DeleteConfirmationModal'; import { DocumentationButton } from '@studio/components/DocumentationButton'; import { MODEL_COMPARE_ENABLED } from '@studio/constants/environment'; -import { LINK_DOCS_STUDIO } from '@studio/constants/links'; +import { LINK_DOCS_AGENTS } from '@studio/constants/links'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { getModelCompareRoute } from '@studio/routes/utils'; import { keepPreviousData, useQueryClient } from '@tanstack/react-query'; @@ -73,6 +73,8 @@ export type AgentTableRow = { description?: string; config?: AgentConfig; config_format?: string; + source?: string; + endpoint?: string; created_at?: string; models: string[]; deploymentsStatus: string; @@ -92,6 +94,8 @@ export interface CombinedAgentsTableProps { onCreateDeployment?: (agentName: string) => void; onCloneAgent?: (agent: AgentTableRow) => void; onAgentsLoaded?: (agents: Agent[]) => void; + onRegisterAgent?: () => void; + onCreateExampleAgent?: () => void; canTestModels?: boolean; } @@ -100,6 +104,8 @@ export const AgentsTable: FC = ({ onCreateDeployment, onCloneAgent, onAgentsLoaded, + onRegisterAgent, + onCreateExampleAgent, canTestModels = MODEL_COMPARE_ENABLED, }) => { const workspace = useWorkspaceFromPath(); @@ -169,6 +175,8 @@ export const AgentsTable: FC = ({ description: agent.description, config, config_format: agent.config_format, + source: agent.source, + endpoint: agent.endpoint, created_at: agent.created_at, models: getAgentModelNames(config), deploymentsStatus, @@ -248,6 +256,12 @@ export const AgentsTable: FC = ({ accessor('name', { header: 'Name', enableSorting: true, + cell: ({ row }) => ( + + {row.original.name} + {row.original.source === 'external' && External} + + ), }), accessor('description', { header: 'Description', @@ -283,35 +297,44 @@ export const AgentsTable: FC = ({ rowActionsColumn({ size: ROW_ACTIONS_COLUMN_SIZE, enableResizing: false, - rowActions: (row: AgentTableRow) => [ - { - children: 'Deploy', - onSelect: () => onCreateDeployment?.(row.name), - }, - ...(canTestModels - ? [ - { - children: 'Test models', - onSelect: () => { - const target = getModelCompareRoute(workspace); - const model = row.models[0]; - const urn = model ? `${row.workspace}/${model}` : null; - navigate(urn ? `${target}?model=${encodeURIComponent(urn)}` : target); + rowActions: (row: AgentTableRow) => { + // External agents run outside NeMo Platform — deploy/test/clone don't apply. + const managedActions = + row.source === 'external' + ? [] + : [ + { + children: 'Deploy', + onSelect: () => onCreateDeployment?.(row.name), + }, + ...(canTestModels + ? [ + { + children: 'Test models', + onSelect: () => { + const target = getModelCompareRoute(workspace); + const model = row.models[0]; + const urn = model ? `${row.workspace}/${model}` : null; + navigate(urn ? `${target}?model=${encodeURIComponent(urn)}` : target); + }, + }, + ] + : []), + { + children: 'Clone', + onSelect: () => onCloneAgent?.(row), }, - }, - ] - : []), - { - children: 'Clone', - onSelect: () => onCloneAgent?.(row), - }, - { kind: 'divider' as const }, - { - children: 'Delete', - danger: true, - onSelect: () => setDeleteState({ kind: 'agent', item: row }), - }, - ], + { kind: 'divider' as const }, + ]; + return [ + ...managedActions, + { + children: 'Delete', + danger: true, + onSelect: () => setDeleteState({ kind: 'agent', item: row }), + }, + ]; + }, }), ]; @@ -359,10 +382,24 @@ export const AgentsTable: FC = ({ DataViewTableContent: { renderEmptyState: () => ( } - actions={} + actions={ + + {onRegisterAgent && ( + + )} + {onCreateExampleAgent && ( + + )} + + + } /> ), }, diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx index bc6e43b1cc..47bbf3131a 100644 --- a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx @@ -56,176 +56,205 @@ export const AgentDetailsContent: FC = ({ onDeploy, onSwitchToChat, onDeleteDeployment, -}) => ( - - - - {agentName} - {isDefined(agent?.description) && agent.description && ( - - {agent.description} - - )} - - -
- -
-
-
-
- - - - {isDefined(agent?.description) && ( - - )} - {(() => { - const models = getAgentModelNames(agent?.config as AgentConfig | undefined); - return models.length > 0 ? ( - - ) : null; - })()} - {isDefined(agent?.config_format) && ( - - )} -
- ), - value: 'agent-details', - }, - { - chevronPosition: 'start', - slotTrigger: 'Deployments', - slotContent: - !isDeploymentsLoading && agentDeployments.length === 0 ? ( - - ) : ( - - {agentDeployments.map((deployment) => ( - - - - {deployment.name} - {deployment.endpoint && ( - - {deployment.endpoint} - - )} - {deployment.error && ( - - {deployment.error} - - )} - - - - - - - - ))} - - ), - value: 'deployments', - }, - { - chevronPosition: 'start' as const, - slotTrigger: 'Recent Evaluations', - slotContent: - agentEvals.length === 0 ? ( +}) => { + const isExternal = agent?.source === 'external'; + return ( + + + + {agentName} + {isDefined(agent?.description) && agent.description && ( + + {agent.description} + + )} + {isExternal ? ( + + Runs outside NeMo Platform. Deploy and evaluate are unavailable for external agents. + + ) : ( + + +
+ +
+
+ )} +
+
+ - No evaluation jobs found for this agent. - - - View all evaluations → - - -
- ) : ( - - {agentEvals.map((job) => ( - - - - - {job.name} - - - - - - - - - ))} - - - View all evaluations → - - + + + {isDefined(agent?.description) && ( + + )} + {(() => { + const models = getAgentModelNames(agent?.config as AgentConfig | undefined); + return models.length > 0 ? ( + + ) : null; + })()} + {isExternal ? ( + <> + + {isDefined(agent?.endpoint) && agent.endpoint && ( + + )} + + ) : ( + isDefined(agent?.config_format) && ( + + ) + )} ), - value: 'evaluations', - }, - ]} - /> - -); + value: 'agent-details', + }, + ...(isExternal + ? [] + : [ + { + chevronPosition: 'start' as const, + slotTrigger: 'Deployments', + slotContent: + !isDeploymentsLoading && agentDeployments.length === 0 ? ( + + ) : ( + + {agentDeployments.map((deployment) => ( + + + + {deployment.name} + {deployment.endpoint && ( + + {deployment.endpoint} + + )} + {deployment.error && ( + + {deployment.error} + + )} + + + + + + + + ))} + + ), + value: 'deployments', + }, + { + chevronPosition: 'start' as const, + slotTrigger: 'Recent Evaluations', + slotContent: + agentEvals.length === 0 ? ( + + No evaluation jobs found for this agent. + + + View all evaluations → + + + + ) : ( + + {agentEvals.map((job) => ( + + + + + {job.name} + + + + + + + + + ))} + + + View all evaluations → + + + + ), + value: 'evaluations', + }, + ]), + ]} + /> + + ); +}; diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentWorkflowContent.tsx b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentWorkflowContent.tsx new file mode 100644 index 0000000000..2351f11ed1 --- /dev/null +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentWorkflowContent.tsx @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Agent } from '@nemo/sdk/generated/agents/schema/Agent'; +import { Accordion, Badge, Block, Flex, Stack, Text } from '@nvidia/foundations-react-core'; +import type { AgentConfig } from '@studio/components/dataViews/AgentsDataView'; +import { summarizeAgentCard } from '@studio/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentCard'; +import { summarizeAgentWorkflow } from '@studio/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow'; +import { Globe, Workflow, Wrench } from 'lucide-react'; +import type { FC } from 'react'; +import YAML from 'yaml'; + +interface AgentWorkflowContentProps { + agent?: Agent; +} + +export const AgentWorkflowContent: FC = ({ agent }) => { + if (agent?.source === 'external') { + return ; + } + + const config = agent?.config as AgentConfig | undefined; + const summary = summarizeAgentWorkflow(config); + + if (!config || (!summary.workflowType && summary.tools.length === 0)) { + return ( + + This agent has no workflow config to visualize. + + ); + } + + let yamlText = ''; + try { + yamlText = YAML.stringify(config); + } catch { + yamlText = 'Unable to render config as YAML.'; + } + + const wiredTools = summary.tools.filter((t) => t.wired); + const unwiredTools = summary.tools.filter((t) => !t.wired); + + return ( + + + + + + {summary.workflowType ?? 'workflow'} + {summary.models.map((model) => ( + + {model} + + ))} + + + + + {wiredTools.length > 0 && ( + <> +
+ + {wiredTools.map((tool) => ( + + ))} + + + )} + + + {unwiredTools.length > 0 && ( + + Declared but not wired as tools: {unwiredTools.map((t) => t.name).join(', ')} + + )} + + + + Workflow config (YAML), + slotContent: ( + +
+                  {yamlText}
+                
+
+ ), + }, + ]} + /> + + ); +}; + +interface WorkflowNodeProps { + label: string; + sub?: string; + root?: boolean; +} + +const WorkflowNode: FC = ({ label, sub, root }) => ( + + {!root && } + + {label} + {sub && ( + + {sub} + + )} + + +); + +const ExternalAgentWorkflow: FC<{ agent: Agent }> = ({ agent }) => { + const summary = summarizeAgentCard(agent.card); + + return ( + + + + + + {summary.name ?? agent.name} + External + + {agent.endpoint && ( + + {agent.endpoint} + + )} + + {summary.skills.length > 0 ? ( + + +
+ + {summary.skills.map((skill) => ( + + ))} + + + ) : ( + + The agent card exposed no skills. This agent runs outside NeMo Platform. + + )} + + + + Agent card (JSON), + slotContent: ( + +
+                  {JSON.stringify(agent.card ?? {}, null, 2)}
+                
+
+ ), + }, + ]} + /> + + ); +}; diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ChatPlaygroundContent.tsx b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ChatPlaygroundContent.tsx index 6ebb91b7a3..d0e71f08c4 100644 --- a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ChatPlaygroundContent.tsx +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ChatPlaygroundContent.tsx @@ -2,10 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import type { AgentDeployment } from '@nemo/sdk/generated/agents/schema/AgentDeployment'; -import { Block, Select } from '@nvidia/foundations-react-core'; +import { Block, Select, Stack, Text } from '@nvidia/foundations-react-core'; import { ModelChat } from '@studio/components/ModelChat'; import { NoHealthyDeploymentsBanner } from '@studio/components/sidePanels/AgentPanels/AgentPanel/NoHealthyDeploymentsBanner'; import { PLATFORM_BASE_URL } from '@studio/constants/environment'; +import { Globe } from 'lucide-react'; import type { FC, RefObject } from 'react'; interface ChatPlaygroundContentProps { @@ -15,6 +16,8 @@ interface ChatPlaygroundContentProps { healthyDeployments: AgentDeployment[]; isDeploymentsLoading: boolean; isDeploying: boolean; + isExternal?: boolean; + externalEndpoint?: string; chatAreaRef: RefObject; onSelectDeployment: (name: string) => void; onDeploy: () => void; @@ -27,6 +30,8 @@ export const ChatPlaygroundContent: FC = ({ healthyDeployments, isDeploymentsLoading, isDeploying, + isExternal, + externalEndpoint, chatAreaRef, onSelectDeployment, onDeploy, @@ -43,6 +48,26 @@ export const ChatPlaygroundContent: FC = ({ ); const noHealthyDeployments = !isDeploymentsLoading && healthyDeployments.length === 0; + // External agents run outside NeMo Platform, so there's no deployment to + // chat through. Direct chat (A2A) isn't wired up yet — show a clear message + // instead of the managed "no deployment / deploy" flow. + if (isExternal) { + return ( +
+ + + + This agent runs outside NeMo Platform + + There is no NeMo deployment to chat with. Chat with it directly at its own endpoint + {externalEndpoint ? `: ${externalEndpoint}` : '.'} + + + +
+ ); + } + return (
{!noHealthyDeployments && healthyDeployments.length > 1 && ( @@ -54,7 +79,7 @@ export const ChatPlaygroundContent: FC = ({ /> )} - {noHealthyDeployments && ( + {noHealthyDeployments ? ( = ({ onDeploy={onDeploy} /> + ) : ( + + + )} - - -
); }; diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/index.tsx b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/index.tsx index f073e3cb87..7723b2ed93 100644 --- a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/index.tsx +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/index.tsx @@ -5,6 +5,7 @@ import type { AgentDeployment } from '@nemo/sdk/generated/agents/schema/AgentDep import { Block, SegmentedControl, SidePanel } from '@nvidia/foundations-react-core'; import { DeleteConfirmationModal } from '@studio/components/DeleteConfirmationModal'; import { AgentDetailsContent } from '@studio/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent'; +import { AgentWorkflowContent } from '@studio/components/sidePanels/AgentPanels/AgentPanel/AgentWorkflowContent'; import { ChatPlaygroundContent } from '@studio/components/sidePanels/AgentPanels/AgentPanel/ChatPlaygroundContent'; import { DeploymentLogsView } from '@studio/components/sidePanels/AgentPanels/AgentPanel/DeploymentLogsView'; import type { AgentPanelTab } from '@studio/components/sidePanels/AgentPanels/AgentPanel/types'; @@ -59,6 +60,7 @@ export const AgentPanel: FC = ({ const tabItems = useMemo( () => [ { value: 'agent-details', children: 'Details' }, + { value: 'agent-workflow', children: 'Workflow' }, { value: 'chat-playground', children: 'Chat Playground' }, { value: 'deployment-logs', children: 'Logs' }, ], @@ -111,6 +113,8 @@ export const AgentPanel: FC = ({ if (selectedTab === 'deployment-logs') { content = ; + } else if (selectedTab === 'agent-workflow') { + content = ; } else if (selectedTab === 'chat-playground') { content = ( = ({ healthyDeployments={healthyDeployments} isDeploymentsLoading={isDeploymentsLoading} isDeploying={isDeploying} + isExternal={agent?.source === 'external'} + externalEndpoint={agent?.endpoint} chatAreaRef={chatAreaRef} onSelectDeployment={(v) => setSelectedDeploymentName(v)} onDeploy={() => setCreateDeploymentOpen(true)} diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentCard.test.ts b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentCard.test.ts new file mode 100644 index 0000000000..3db4741bc5 --- /dev/null +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentCard.test.ts @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { summarizeAgentCard } from '@studio/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentCard'; + +describe('summarizeAgentCard', () => { + it('extracts name, description, and skills', () => { + expect( + summarizeAgentCard({ + name: 'Calculator Agent', + description: 'does math', + skills: [ + { id: 'calculator__add', name: 'add', description: 'Add numbers' }, + { id: 'calculator__divide', name: 'divide' }, + ], + }) + ).toEqual({ + name: 'Calculator Agent', + description: 'does math', + skills: [ + { id: 'calculator__add', name: 'add', description: 'Add numbers' }, + { id: 'calculator__divide', name: 'divide', description: undefined }, + ], + }); + }); + + it('returns empty skills for undefined or malformed card', () => { + expect(summarizeAgentCard(undefined).skills).toEqual([]); + expect(summarizeAgentCard({ skills: 'nope' }).skills).toEqual([]); + expect(summarizeAgentCard({ skills: [null, 42, {}] }).skills).toEqual([]); + }); + + it('falls back to id when a skill has no name', () => { + expect(summarizeAgentCard({ skills: [{ id: 'only_id' }] }).skills).toEqual([ + { id: 'only_id', name: 'only_id', description: undefined }, + ]); + }); +}); diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentCard.ts b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentCard.ts new file mode 100644 index 0000000000..acb87c0326 --- /dev/null +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentCard.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { AgentCard } from '@nemo/sdk/generated/agents/schema/AgentCard'; + +export interface AgentCardSkill { + id: string; + name: string; + description?: string; +} + +export interface AgentCardSummary { + name?: string; + description?: string; + skills: AgentCardSkill[]; +} + +const asString = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined); + +/** + * Reads the display-relevant fields out of a fetched A2A agent card. The card + * is stored loosely (a plain dict), so every access is defensive; unknown + * shapes degrade to an empty skills list rather than throwing. + */ +export const summarizeAgentCard = (card: AgentCard | undefined): AgentCardSummary => { + const rawSkills = Array.isArray((card as { skills?: unknown } | undefined)?.skills) + ? ((card as { skills: unknown[] }).skills as unknown[]) + : []; + + const skills: AgentCardSkill[] = rawSkills.flatMap((entry, i) => { + if (!entry || typeof entry !== 'object') return []; + const s = entry as Record; + const name = asString(s.name) ?? asString(s.id); + if (!name) return []; + return [{ id: asString(s.id) ?? `skill-${i}`, name, description: asString(s.description) }]; + }); + + return { + name: asString((card as Record | undefined)?.name), + description: asString((card as Record | undefined)?.description), + skills, + }; +}; diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow.test.ts b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow.test.ts new file mode 100644 index 0000000000..7f3065b709 --- /dev/null +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow.test.ts @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { AgentConfig } from '@studio/components/dataViews/AgentsDataView'; +import { summarizeAgentWorkflow } from '@studio/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow'; + +describe('summarizeAgentWorkflow', () => { + it('returns empty summary for undefined config', () => { + expect(summarizeAgentWorkflow(undefined)).toEqual({ + workflowType: undefined, + llmName: undefined, + models: [], + tools: [], + }); + }); + + it('extracts workflow type, model, and flags wired tools', () => { + const config: AgentConfig = { + functions: { + calculator: { _type: 'calculator' }, + current_datetime: { _type: 'current_datetime' }, + unused: { _type: 'internet_search' }, + }, + llms: { llm: { _type: 'openai', model_name: 'gpt-4o' } }, + workflow: { + _type: 'react_agent', + llm_name: 'llm', + tool_names: ['calculator', 'current_datetime'], + }, + }; + expect(summarizeAgentWorkflow(config)).toEqual({ + workflowType: 'react_agent', + llmName: 'llm', + models: ['gpt-4o'], + tools: [ + { name: 'calculator', type: 'calculator', wired: true }, + { name: 'current_datetime', type: 'current_datetime', wired: true }, + { name: 'unused', type: 'internet_search', wired: false }, + ], + }); + }); + + it('dedupes repeated model names across llms', () => { + const config: AgentConfig = { + llms: { + a: { _type: 'openai', model_name: 'gpt-4o' }, + b: { _type: 'openai', model_name: 'gpt-4o' }, + }, + workflow: { _type: 'react_agent' }, + }; + expect(summarizeAgentWorkflow(config).models).toEqual(['gpt-4o']); + }); +}); diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow.ts b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow.ts new file mode 100644 index 0000000000..ff2ebd30a5 --- /dev/null +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow.ts @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { AgentConfig } from '@studio/components/dataViews/AgentsDataView'; + +export interface WorkflowToolNode { + name: string; + type: string; + /** True when the workflow's tool_names wires this function in as a tool. */ + wired: boolean; +} + +export interface AgentWorkflowSummary { + workflowType?: string; + llmName?: string; + models: string[]; + tools: WorkflowToolNode[]; +} + +/** + * Derives a display-friendly view of a NAT workflow config: the top-level + * workflow type, its LLM/model, and the functions declared in the config — + * flagging which are wired into the agent via `workflow.tool_names`. Purely a + * read model over the stored config; it does not validate the NAT schema. + */ +export const summarizeAgentWorkflow = (config: AgentConfig | undefined): AgentWorkflowSummary => { + const workflow = config?.workflow; + const wiredNames = new Set(workflow?.tool_names ?? []); + + const models = Object.values(config?.llms ?? {}) + .map((llm) => llm?.model_name) + .filter((m): m is string => !!m); + + const tools: WorkflowToolNode[] = Object.entries(config?.functions ?? {}).map(([name, fn]) => ({ + name, + type: fn?._type ?? 'unknown', + wired: wiredNames.has(name), + })); + + return { + workflowType: workflow?._type, + llmName: workflow?.llm_name, + models: [...new Set(models)], + tools, + }; +}; diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/types.ts b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/types.ts index a569330b96..835ae62d30 100644 --- a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/types.ts +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/types.ts @@ -1,4 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -export type AgentPanelTab = 'agent-details' | 'chat-playground' | 'deployment-logs'; +export type AgentPanelTab = + | 'agent-details' + | 'agent-workflow' + | 'chat-playground' + | 'deployment-logs'; diff --git a/web/packages/studio/src/constants/links.ts b/web/packages/studio/src/constants/links.ts index 195849c777..3312f73a4f 100644 --- a/web/packages/studio/src/constants/links.ts +++ b/web/packages/studio/src/constants/links.ts @@ -6,6 +6,7 @@ const GITHUB_REPO_URL = 'https://github.com/NVIDIA-NeMo/nemo-platform'; // Studio documentation links export const LINK_DOCS_STUDIO = `${DOCS_BASE_URL}studio`; +export const LINK_DOCS_AGENTS = `${DOCS_BASE_URL}studio/agents`; export const LINK_DOCS_STUDIO_CUSTOMIZATION = `${DOCS_BASE_URL}customizer-reference`; export const LINK_DOCS_STUDIO_EVALUATION = `${DOCS_BASE_URL}evaluate-models`; export const LINK_DOCS_PROJECT = `${DOCS_BASE_URL}get-started/core-concepts/projects`; diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/index.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/index.tsx new file mode 100644 index 0000000000..0a17de0a26 --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/index.tsx @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { zodResolver } from '@hookform/resolvers/zod'; +import { ControlledTextArea } from '@nemo/common/src/components/form/ControlledTextArea'; +import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; +import { FormModal } from '@nemo/common/src/components/FormModal'; +import { useToast } from '@nemo/common/src/providers/toast/useToast'; +import { getAgentsListAgentsQueryKey, useAgentsCreateAgent } from '@nemo/sdk/generated/agents/api'; +import { Anchor, Block, SegmentedControl, Text } from '@nvidia/foundations-react-core'; +import { parseAgentConfig } from '@studio/api/agents/parseAgentConfig'; +import { getErrorMessage } from '@studio/api/common/utils'; +import { LINK_DOCS_AGENTS } from '@studio/constants/links'; +import { + registerAgentFormSchema, + type RegisterAgentFormData, + type RegisterAgentModalProps, +} from '@studio/routes/agents/AgentsListRoute/RegisterAgentModal/type'; +import { getAgentDetailRoute } from '@studio/routes/utils'; +import { useQueryClient } from '@tanstack/react-query'; +import { type FC, useState } from 'react'; +import { type SubmitHandler, useForm, useWatch } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; + +const DEFAULT_VALUES: RegisterAgentFormData = { + mode: 'url', + name: '', + description: '', + url: '', + configText: '', +}; + +const MODE_ITEMS = [ + { value: 'url', children: 'Connect running agent' }, + { value: 'config', children: 'Paste config' }, +]; + +export const RegisterAgentModal: FC = ({ open, onClose, workspace }) => { + const toast = useToast(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [parseError, setParseError] = useState(undefined); + + const { + mutateAsync: createAgent, + error: createError, + isPending, + reset: resetMutation, + } = useAgentsCreateAgent({ + mutation: { + onSuccess: (agent) => { + toast.success(`Agent "${agent.name}" registered`); + void queryClient.invalidateQueries({ queryKey: getAgentsListAgentsQueryKey(workspace) }); + resetAndClose(); + if (agent.name) navigate(getAgentDetailRoute(workspace, agent.name)); + }, + }, + }); + + const { + control, + reset: resetForm, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(registerAgentFormSchema), + defaultValues: DEFAULT_VALUES, + disabled: isPending, + mode: 'onChange', + }); + + const mode = useWatch({ control, name: 'mode' }); + + const resetAndClose = () => { + resetMutation(); + setParseError(undefined); + resetForm(DEFAULT_VALUES); + onClose(); + }; + + const onSubmit: SubmitHandler = async (formData) => { + setParseError(undefined); + const description = formData.description?.trim() || ''; + + let data: Parameters[0]['data']; + if (formData.mode === 'url') { + data = { name: formData.name.trim(), description, url: formData.url?.trim() }; + } else { + let config: Record; + try { + config = parseAgentConfig(formData.configText ?? ''); + } catch (err) { + setParseError((err as Error).message); + return; + } + data = { name: formData.name.trim(), description, config }; + } + + try { + await createAgent({ workspace, data }); + } catch { + // surfaced via errorText + } + }; + + const errorMessage = + parseError ?? + (createError ? getErrorMessage(createError as Error, 'Failed to register agent') : undefined); + + return ( + + + resetForm({ ...DEFAULT_VALUES, mode: v as RegisterAgentFormData['mode'] }) + } + /> + + + + + {mode === 'url' ? ( + + + Points at a NAT agent already running (A2A). NeMo Platform fetches its agent card + and does not run it. + + + ), + }} + /> + ) : ( + + How to structure a NAT workflow config + + ), + }} + /> + )} + + ); +}; diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/type.ts b/web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/type.ts new file mode 100644 index 0000000000..158db9e6ac --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/type.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { FormModalProps } from '@nemo/common/src/components/FormModal'; +import { z } from 'zod'; + +export const registerAgentFormSchema = z + .object({ + mode: z.enum(['url', 'config']), + name: z.string().min(1, 'Name is required'), + description: z.string().optional(), + url: z.string().optional(), + configText: z.string().optional(), + }) + .superRefine((v, ctx) => { + if (v.mode === 'url' && !v.url?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['url'], + message: 'Endpoint URL is required', + }); + } + if (v.mode === 'config' && !v.configText?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['configText'], + message: 'Config is required', + }); + } + }); + +export type RegisterAgentFormData = z.infer; + +export interface RegisterAgentModalProps extends Pick { + workspace: string; +} diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx index 3be7d70784..b6324cf0af 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { Agent } from '@nemo/sdk/generated/agents/schema/Agent'; -import { Button, PageHeader, Stack } from '@nvidia/foundations-react-core'; +import { Button, Flex, PageHeader, Stack } from '@nvidia/foundations-react-core'; import { AccessibleTitle } from '@studio/components/AccessibleTitle'; import { AgentsTable, type AgentTableRow } from '@studio/components/dataViews/AgentsDataView'; import { @@ -15,6 +15,7 @@ import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; import { CreateDeploymentModal } from '@studio/routes/agents/AgentDeploymentsListRoute/CreateDeploymentModal'; import { CloneAgentModal } from '@studio/routes/agents/AgentsListRoute/CloneAgentModal'; import { CreateExampleAgentModal } from '@studio/routes/agents/AgentsListRoute/CreateExampleAgentModal'; +import { RegisterAgentModal } from '@studio/routes/agents/AgentsListRoute/RegisterAgentModal'; import { getAgentDetailRoute, getAgentsListRoute } from '@studio/routes/utils'; import { type FC, useState } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; @@ -27,6 +28,7 @@ export const AgentsListRoute: FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const [createDeploymentAgent, setCreateDeploymentAgent] = useState(null); const [isCreateExampleOpen, setCreateExampleOpen] = useState(false); + const [isRegisterOpen, setRegisterOpen] = useState(false); const [cloneSource, setCloneSource] = useState(null); const [loadedAgents, setLoadedAgents] = useState([]); const { [ROUTE_PARAMS.agentName]: agentNameParam } = useParams<{ agentName?: string }>(); @@ -55,9 +57,14 @@ export const AgentsListRoute: FC = () => { slotHeading="Agents" slotDescription="View and manage AI agents and their deployments." slotActions={ - + + + + } /> { onCreateDeployment={(agentName) => setCreateDeploymentAgent(agentName)} onCloneAgent={setCloneSource} onAgentsLoaded={setLoadedAgents} + onRegisterAgent={() => setRegisterOpen(true)} + onCreateExampleAgent={() => setCreateExampleOpen(true)} /> { workspace={workspace} existingAgents={loadedAgents} /> + setRegisterOpen(false)} + workspace={workspace} + /> setCloneSource(null)} From b901953cfb302033d21165df930ddf4926969568 Mon Sep 17 00:00:00 2001 From: mschwab Date: Thu, 16 Jul 2026 14:25:53 -0700 Subject: [PATCH 02/11] feat(agents): gate Logs tab for external agents External agents run outside NeMo Platform, so there is no NMP deployment to read logs from (NAT serve/A2A exposes no logs endpoint). Show an explanatory message on the Logs tab instead of an empty deployment log viewer, matching the Deploy/Chat gating. Signed-off-by: mschwab --- .../AgentPanels/AgentPanel/index.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/index.tsx b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/index.tsx index 7723b2ed93..a3e2d651af 100644 --- a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/index.tsx +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/index.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { AgentDeployment } from '@nemo/sdk/generated/agents/schema/AgentDeployment'; -import { Block, SegmentedControl, SidePanel } from '@nvidia/foundations-react-core'; +import { Block, SegmentedControl, SidePanel, Stack, Text } from '@nvidia/foundations-react-core'; import { DeleteConfirmationModal } from '@studio/components/DeleteConfirmationModal'; import { AgentDetailsContent } from '@studio/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent'; import { AgentWorkflowContent } from '@studio/components/sidePanels/AgentPanels/AgentPanel/AgentWorkflowContent'; @@ -18,6 +18,7 @@ import { } from '@studio/components/sidePanels/AgentPanels/AgentPanel/walkthroughStorage'; import { CreateDeploymentModal } from '@studio/routes/agents/AgentDeploymentsListRoute/CreateDeploymentModal'; import { SubmitEvaluationModal } from '@studio/routes/agents/AgentEvaluationsRoute/components/SubmitEvaluationModal'; +import { Globe } from 'lucide-react'; import { type ComponentProps, type FC, useEffect, useMemo, useRef, useState } from 'react'; export type { AgentPanelTab }; @@ -112,7 +113,21 @@ export const AgentPanel: FC = ({ let content: React.ReactNode; if (selectedTab === 'deployment-logs') { - content = ; + content = + agent?.source === 'external' ? ( + + + + This agent runs outside NeMo Platform + + There is no NeMo deployment to read logs from. Logs are available on the host where + the agent runs. + + + + ) : ( + + ); } else if (selectedTab === 'agent-workflow') { content = ; } else if (selectedTab === 'chat-playground') { From de196fad6c69e932288fa35db22b9ef25aee9639 Mon Sep 17 00:00:00 2001 From: mschwab Date: Thu, 16 Jul 2026 14:39:27 -0700 Subject: [PATCH 03/11] feat(agents): chat with external agents via A2A bridge Studio's chat playground can now talk to a registered external agent. The gateway exposes POST /agents/{name}/chat/completions for external agents: it accepts an OpenAI chat/completions request, forwards the latest user turn to the agent over A2A message/send (JSON-RPC, no A2A client dependency), and returns the reply in OpenAI shape (SSE when the client streams). - a2a.py: send_a2a_message + extract_message_text (Task/Message parts) - gateway.py: external-only chat route with OpenAI<->A2A translation; 400 managed - ChatPlaygroundContent: external agents render ModelChat against the bridge - tests: extract/send (respx + real socket) and route (stream, non-stream, latest-user forwarding, 400/404/502) MVP: single-turn (latest user message; no server-side context_id) and no auth to the external agent. SSRF on the outbound call flagged for review. Signed-off-by: mschwab --- .../nemo-agents/src/nemo_agents_plugin/a2a.py | 81 +++++++++++ .../src/nemo_agents_plugin/api/v2/gateway.py | 126 +++++++++++++++++- plugins/nemo-agents/tests/unit/test_a2a.py | 72 +++++++++- .../nemo-agents/tests/unit/test_gateway.py | 106 +++++++++++++++ .../AgentPanel/ChatPlaygroundContent.tsx | 31 +++-- .../AgentPanels/AgentPanel/index.tsx | 1 - 6 files changed, 397 insertions(+), 20 deletions(-) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py b/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py index fcdb2d3800..b1d2a8a91b 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py @@ -14,6 +14,7 @@ from typing import Any from urllib.parse import urljoin +from uuid import uuid4 import httpx @@ -26,11 +27,18 @@ _MAX_CARD_BYTES = 512 * 1024 _FETCH_TIMEOUT_S = 10.0 +# Agents can take a while to answer; allow more headroom than card discovery. +_MESSAGE_TIMEOUT_S = 120.0 + class AgentCardError(Exception): """Raised when an external agent's card can't be fetched or parsed.""" +class A2AMessageError(Exception): + """Raised when a ``message/send`` call to an external agent fails.""" + + def _card_url(base_url: str, path: str) -> str: # urljoin needs a trailing slash on the base to preserve any path prefix, # and the well-known path is absolute, so join against the origin. @@ -73,3 +81,76 @@ async def fetch_agent_card(base_url: str) -> dict[str, Any]: return card raise AgentCardError(f"Could not fetch A2A agent card: {last_error}.") + + +def _collect_text_parts(parts: Any) -> list[str]: + """Pull the text out of an A2A ``parts`` array, ignoring non-text parts.""" + out: list[str] = [] + if isinstance(parts, list): + for part in parts: + if isinstance(part, dict) and isinstance(part.get("text"), str) and part["text"]: + out.append(part["text"]) + return out + + +def extract_message_text(result: Any) -> str: + """Extract assistant text from an A2A ``message/send`` result. + + The result is either a Message (``parts`` directly) or a Task (text lives in + ``artifacts[].parts`` and/or ``status.message.parts``). Concatenate whatever + text parts are present; return "" if none. + """ + if not isinstance(result, dict): + return "" + texts: list[str] = [] + texts += _collect_text_parts(result.get("parts")) + artifacts = result.get("artifacts") + if isinstance(artifacts, list): + for artifact in artifacts: + if isinstance(artifact, dict): + texts += _collect_text_parts(artifact.get("parts")) + status = result.get("status") + if isinstance(status, dict) and isinstance(status.get("message"), dict): + texts += _collect_text_parts(status["message"].get("parts")) + return "\n".join(texts) + + +async def send_a2a_message(endpoint: str, text: str) -> str: + """Send *text* to an external A2A agent via ``message/send`` and return its reply. + + Speaks A2A JSON-RPC 2.0 directly (no A2A client dependency). Raises + :class:`A2AMessageError` on transport failure, a JSON-RPC error, or a + non-JSON response. + """ + payload = { + "jsonrpc": "2.0", + "id": uuid4().hex, + "method": "message/send", + "params": { + "message": { + "role": "user", + "parts": [{"kind": "text", "text": text}], + "messageId": uuid4().hex, + "kind": "message", + } + }, + } + try: + async with httpx.AsyncClient(timeout=_MESSAGE_TIMEOUT_S, follow_redirects=True) as client: + resp = await client.post(endpoint, json=payload, headers={"Accept": "application/json"}) + except httpx.HTTPError as exc: + raise A2AMessageError(f"could not reach agent at {endpoint} ({exc.__class__.__name__})") from exc + + if resp.status_code != 200: + raise A2AMessageError(f"agent returned HTTP {resp.status_code}") + try: + data = resp.json() + except ValueError as exc: + raise A2AMessageError("agent response was not valid JSON") from exc + + if isinstance(data, dict) and data.get("error"): + err = data["error"] + msg = err.get("message") if isinstance(err, dict) else str(err) + raise A2AMessageError(f"agent error: {msg}") + + return extract_message_text(data.get("result") if isinstance(data, dict) else None) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py index 99a0f485f2..91b22154c5 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py @@ -27,12 +27,15 @@ import json import logging import os -from typing import AsyncIterator +import time +from typing import Any, AsyncIterator from urllib.parse import urljoin, urlparse, urlunparse +from uuid import uuid4 import httpx from fastapi import APIRouter, Depends, HTTPException, Request -from fastapi.responses import StreamingResponse +from fastapi.responses import JSONResponse, StreamingResponse +from nemo_agents_plugin.a2a import A2AMessageError, send_a2a_message from nemo_agents_plugin.api.v2._perms import GatewayPerms from nemo_agents_plugin.api.v2.dependencies import get_entity_client from nemo_agents_plugin.authz import scope @@ -416,3 +419,122 @@ async def _buffered() -> AsyncIterator[bytes]: headers={k: v for k, v in response_headers.items() if k.lower() != "content-length"}, media_type=content_type, ) + + +# --------------------------------------------------------------------------- +# External agent chat — OpenAI chat/completions <-> A2A message/send bridge +# --------------------------------------------------------------------------- +# +# External agents run outside NeMo Platform and speak A2A JSON-RPC, not the +# OpenAI chat API the Studio chat playground uses. This route accepts an +# OpenAI chat/completions request, forwards the latest user turn to the agent +# via ``message/send``, and returns the reply in OpenAI shape (SSE when the +# client asked to stream). Managed agents keep using the deployment proxy above. + + +def _latest_user_text(messages: Any) -> str: + """Return the text of the last user message in an OpenAI messages array. + + ``content`` may be a plain string or an array of content parts; join text parts. + """ + if not isinstance(messages, list): + return "" + for message in reversed(messages): + if not isinstance(message, dict) or message.get("role") != "user": + continue + content = message.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for part in content: + if isinstance(part, dict): + text = part.get("text") + if isinstance(text, str): + parts.append(text) + return "".join(parts) + return "" + + +def _openai_completion(text: str, model: str) -> dict[str, Any]: + return { + "id": f"chatcmpl-{uuid4().hex}", + "object": "chat.completion", + "created": int(time.time()), + "model": model, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": text}, + "finish_reason": "stop", + } + ], + } + + +async def _openai_sse(text: str, model: str) -> AsyncIterator[bytes]: + created = int(time.time()) + completion_id = f"chatcmpl-{uuid4().hex}" + + def _chunk(delta: dict[str, Any], finish: str | None) -> bytes: + payload = { + "id": completion_id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [{"index": 0, "delta": delta, "finish_reason": finish}], + } + return f"data: {json.dumps(payload)}\n\n".encode() + + yield _chunk({"role": "assistant", "content": text}, None) + yield _chunk({}, "stop") + yield b"data: [DONE]\n\n" + + +@router.post( + "/agents/{name}/chat/completions", + tags=["Agent Gateway"], + include_in_schema=False, + response_model=None, +) +@scope.write +@path_rule( + callers=[CallerKind.PRINCIPAL], + permissions=[GatewayPerms.INVOKE], +) +async def external_agent_chat_completions( + workspace: str, + name: str, + request: Request, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> StreamingResponse | JSONResponse: + """Bridge an OpenAI chat/completions call to an external agent's A2A endpoint.""" + try: + agent = await entity_client.get(Agent, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException(status_code=404, detail=f"Agent '{name}' not found in workspace '{workspace}'.") from exc + + if agent.source != "external": + raise HTTPException( + status_code=400, + detail=f"Agent '{name}' is managed; chat through its deployment, not this endpoint.", + ) + + a2a_endpoint = (agent.card or {}).get("url") or agent.endpoint + if not isinstance(a2a_endpoint, str) or not a2a_endpoint: + raise HTTPException(status_code=400, detail=f"External agent '{name}' has no endpoint to chat with.") + + body = await request.json() + text = _latest_user_text(body.get("messages")) + if not text: + raise HTTPException(status_code=400, detail="Request has no user message to send.") + + try: + reply = await send_a2a_message(a2a_endpoint, text) + except A2AMessageError as exc: + raise HTTPException(status_code=502, detail=f"External agent chat failed: {exc}") from exc + + model = body.get("model") or name + if body.get("stream"): + return StreamingResponse(_openai_sse(reply, model), media_type="text/event-stream") + return JSONResponse(_openai_completion(reply, model)) diff --git a/plugins/nemo-agents/tests/unit/test_a2a.py b/plugins/nemo-agents/tests/unit/test_a2a.py index 68e9d9b085..e4b74aa8a4 100644 --- a/plugins/nemo-agents/tests/unit/test_a2a.py +++ b/plugins/nemo-agents/tests/unit/test_a2a.py @@ -5,10 +5,18 @@ from __future__ import annotations +import json + import httpx import pytest import respx -from nemo_agents_plugin.a2a import AgentCardError, fetch_agent_card +from nemo_agents_plugin.a2a import ( + A2AMessageError, + AgentCardError, + extract_message_text, + fetch_agent_card, + send_a2a_message, +) CARD = {"name": "Calculator Agent", "description": "does math", "skills": [{"id": "add", "name": "add"}]} @@ -56,3 +64,65 @@ async def test_non_card_json_rejected() -> None: ) with pytest.raises(AgentCardError): await fetch_agent_card("http://host:10000") + + +class TestExtractMessageText: + def test_message_parts(self) -> None: + result = {"kind": "message", "parts": [{"kind": "text", "text": "hello"}]} + assert extract_message_text(result) == "hello" + + def test_task_artifacts(self) -> None: + result = { + "kind": "task", + "artifacts": [{"parts": [{"kind": "text", "text": "42"}]}], + "status": {"state": "completed"}, + } + assert extract_message_text(result) == "42" + + def test_task_status_message(self) -> None: + result = {"status": {"message": {"parts": [{"kind": "text", "text": "done"}]}}} + assert extract_message_text(result) == "done" + + def test_no_text_returns_empty(self) -> None: + assert extract_message_text({"artifacts": [{"parts": [{"kind": "file"}]}]}) == "" + assert extract_message_text(None) == "" + + +@pytest.mark.asyncio +@respx.mock +async def test_send_message_returns_reply_text() -> None: + route = respx.post("http://host:10000/").mock( + return_value=httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": "1", + "result": {"kind": "message", "parts": [{"kind": "text", "text": "the answer"}]}, + }, + ) + ) + reply = await send_a2a_message("http://host:10000/", "what is it?") + assert reply == "the answer" + sent = json.loads(route.calls.last.request.content) + assert sent["method"] == "message/send" + assert sent["params"]["message"]["parts"][0]["text"] == "what is it?" + + +@pytest.mark.asyncio +@respx.mock +async def test_send_message_jsonrpc_error_raises() -> None: + respx.post("http://host:10000/").mock( + return_value=httpx.Response( + 200, json={"jsonrpc": "2.0", "id": "1", "error": {"code": -32000, "message": "boom"}} + ) + ) + with pytest.raises(A2AMessageError, match="boom"): + await send_a2a_message("http://host:10000/", "hi") + + +@pytest.mark.asyncio +@respx.mock +async def test_send_message_transport_error_raises() -> None: + respx.post("http://host:10000/").mock(side_effect=httpx.ConnectError("refused")) + with pytest.raises(A2AMessageError, match="could not reach"): + await send_a2a_message("http://host:10000/", "hi") diff --git a/plugins/nemo-agents/tests/unit/test_gateway.py b/plugins/nemo-agents/tests/unit/test_gateway.py index 5ea7ba8e70..3cf47f84b0 100644 --- a/plugins/nemo-agents/tests/unit/test_gateway.py +++ b/plugins/nemo-agents/tests/unit/test_gateway.py @@ -615,3 +615,109 @@ def test_no_ready_container_returns_503(self, client: TestClient, mock_entity_cl ) assert resp.status_code == 503 + + +# --------------------------------------------------------------------------- +# External agent chat — OpenAI chat/completions <-> A2A message/send bridge +# --------------------------------------------------------------------------- + + +def _make_external_agent(name: str = "ext", url: str = "http://host:10000") -> Agent: + return Agent( + name=name, + workspace="default", + source="external", + endpoint=url, + card={"url": url, "name": "Ext Agent"}, + ) + + +class TestExternalAgentChat: + _URL = "/apis/agents/v2/workspaces/default/agents/ext/chat/completions" + + def test_non_stream_returns_openai_completion( + self, client: TestClient, mock_entity_client: AsyncMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + mock_entity_client.get = AsyncMock(return_value=_make_external_agent()) + monkeypatch.setattr(gateway_module, "send_a2a_message", AsyncMock(return_value="42 degrees")) + + resp = client.post(self._URL, json={"messages": [{"role": "user", "content": "temp?"}]}) + + assert resp.status_code == 200 + body = resp.json() + assert body["object"] == "chat.completion" + assert body["choices"][0]["message"]["content"] == "42 degrees" + + def test_stream_returns_sse( + self, client: TestClient, mock_entity_client: AsyncMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + mock_entity_client.get = AsyncMock(return_value=_make_external_agent()) + monkeypatch.setattr(gateway_module, "send_a2a_message", AsyncMock(return_value="sunny")) + + resp = client.post( + self._URL, + json={"stream": True, "messages": [{"role": "user", "content": "weather?"}]}, + ) + + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/event-stream") + assert "sunny" in resp.text + assert "[DONE]" in resp.text + + def test_forwards_latest_user_text( + self, client: TestClient, mock_entity_client: AsyncMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + mock_entity_client.get = AsyncMock(return_value=_make_external_agent()) + send = AsyncMock(return_value="ok") + monkeypatch.setattr(gateway_module, "send_a2a_message", send) + + client.post( + self._URL, + json={ + "messages": [ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "reply"}, + {"role": "user", "content": "second"}, + ] + }, + ) + + send.assert_awaited_once() + assert send.await_args is not None + assert send.await_args.args[1] == "second" + + def test_managed_agent_returns_400(self, client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get = AsyncMock(return_value=_make_agent("calc")) + + resp = client.post( + "/apis/agents/v2/workspaces/default/agents/calc/chat/completions", + json={"messages": [{"role": "user", "content": "hi"}]}, + ) + + assert resp.status_code == 400 + + def test_agent_not_found_returns_404(self, client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get = AsyncMock(side_effect=NemoEntityNotFoundError("nope")) + + resp = client.post(self._URL, json={"messages": [{"role": "user", "content": "hi"}]}) + + assert resp.status_code == 404 + + def test_no_user_message_returns_400(self, client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get = AsyncMock(return_value=_make_external_agent()) + + resp = client.post(self._URL, json={"messages": [{"role": "system", "content": "x"}]}) + + assert resp.status_code == 400 + + def test_a2a_failure_returns_502( + self, client: TestClient, mock_entity_client: AsyncMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + from nemo_agents_plugin.a2a import A2AMessageError + + mock_entity_client.get = AsyncMock(return_value=_make_external_agent()) + monkeypatch.setattr(gateway_module, "send_a2a_message", AsyncMock(side_effect=A2AMessageError("boom"))) + + resp = client.post(self._URL, json={"messages": [{"role": "user", "content": "hi"}]}) + + assert resp.status_code == 502 diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ChatPlaygroundContent.tsx b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ChatPlaygroundContent.tsx index d0e71f08c4..57bbbe81e6 100644 --- a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ChatPlaygroundContent.tsx +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ChatPlaygroundContent.tsx @@ -2,11 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import type { AgentDeployment } from '@nemo/sdk/generated/agents/schema/AgentDeployment'; -import { Block, Select, Stack, Text } from '@nvidia/foundations-react-core'; +import { Block, Select } from '@nvidia/foundations-react-core'; import { ModelChat } from '@studio/components/ModelChat'; import { NoHealthyDeploymentsBanner } from '@studio/components/sidePanels/AgentPanels/AgentPanel/NoHealthyDeploymentsBanner'; import { PLATFORM_BASE_URL } from '@studio/constants/environment'; -import { Globe } from 'lucide-react'; import type { FC, RefObject } from 'react'; interface ChatPlaygroundContentProps { @@ -17,7 +16,6 @@ interface ChatPlaygroundContentProps { isDeploymentsLoading: boolean; isDeploying: boolean; isExternal?: boolean; - externalEndpoint?: string; chatAreaRef: RefObject; onSelectDeployment: (name: string) => void; onDeploy: () => void; @@ -31,7 +29,6 @@ export const ChatPlaygroundContent: FC = ({ isDeploymentsLoading, isDeploying, isExternal, - externalEndpoint, chatAreaRef, onSelectDeployment, onDeploy, @@ -48,21 +45,23 @@ export const ChatPlaygroundContent: FC = ({ ); const noHealthyDeployments = !isDeploymentsLoading && healthyDeployments.length === 0; - // External agents run outside NeMo Platform, so there's no deployment to - // chat through. Direct chat (A2A) isn't wired up yet — show a clear message - // instead of the managed "no deployment / deploy" flow. + // External agents run outside NeMo Platform: there's no NMP deployment. Chat + // is bridged to the agent's A2A endpoint by the gateway's external-chat route, + // which speaks OpenAI chat/completions on `.../agents/{name}/chat/completions`. if (isExternal) { return (
- - - - This agent runs outside NeMo Platform - - There is no NeMo deployment to chat with. Chat with it directly at its own endpoint - {externalEndpoint ? `: ${externalEndpoint}` : '.'} - - + +
); diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/index.tsx b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/index.tsx index a3e2d651af..3b8a7b2a00 100644 --- a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/index.tsx +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/index.tsx @@ -140,7 +140,6 @@ export const AgentPanel: FC = ({ isDeploymentsLoading={isDeploymentsLoading} isDeploying={isDeploying} isExternal={agent?.source === 'external'} - externalEndpoint={agent?.endpoint} chatAreaRef={chatAreaRef} onSelectDeployment={(v) => setSelectedDeploymentName(v)} onDeploy={() => setCreateDeploymentOpen(true)} From f4684ba0fda8e7b30363fa2b6b70df74b08e23aa Mon Sep 17 00:00:00 2001 From: mschwab Date: Thu, 16 Jul 2026 15:31:50 -0700 Subject: [PATCH 04/11] feat(agents): evaluate external agents via NAT /generate -> A2A bridge nat eval drives an agent over NAT's /generate protocol; external agents expose only A2A, so System-A eval couldn't reach them. Bridge /generate/full -> A2A message/send inside the agent-name gateway proxy, so external agents evaluate through the existing nat eval pipeline with unchanged eval configs. - gateway.py: _serve_agent_proxy branches external -> _serve_external_generate, translating {"input_message": q} -> A2A message/send and emitting the data: {"value": ...} line nat eval reads - AgentDetailsContent: un-gate Evaluate + Recent Evaluations for external; Deploy stays gated - tests: generate bridge (full path, missing input_message, non-generate path) Eval config is unchanged (agent-agnostic; agent comes from --endpoint). Signed-off-by: mschwab --- .../src/nemo_agents_plugin/api/v2/gateway.py | 66 +++++++++- .../nemo-agents/tests/unit/test_gateway.py | 41 ++++++ .../AgentPanel/AgentDetailsContent.tsx | 121 +++++++++--------- 3 files changed, 163 insertions(+), 65 deletions(-) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py index 91b22154c5..e46f15771d 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py @@ -128,11 +128,23 @@ async def _serve_agent_proxy( request: Request, entity_client: NemoEntitiesClient, ) -> StreamingResponse: - """Find the first ``running`` deployment for the named agent and forward the request to it. + """Forward a request addressed by agent name to the agent behind it. - Returns ``503`` if no running deployment is found. Shared by the read/write route handlers, - which differ only in their authorization scope (``agents:read`` vs ``agents:write``). + Managed agents proxy to their first ``running`` deployment (``503`` if none). + External agents have no deployment; their NAT ``/generate`` traffic (e.g. from + ``nat eval``) is bridged to A2A ``message/send`` instead. Shared by the + read/write route handlers, which differ only in authorization scope. """ + try: + agent = await entity_client.get(Agent, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException(status_code=404, detail=f"Agent '{name}' not found in workspace '{workspace}'.") from exc + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + if agent.source == "external": + return await _serve_external_generate(name, trailing_uri, request, agent) + endpoint = await _resolve_agent_endpoint(name, workspace, entity_client) return await _proxy(request, endpoint, trailing_uri, model_name=name) @@ -538,3 +550,51 @@ async def external_agent_chat_completions( if body.get("stream"): return StreamingResponse(_openai_sse(reply, model), media_type="text/event-stream") return JSONResponse(_openai_completion(reply, model)) + + +# --------------------------------------------------------------------------- +# External agent generate — NAT /generate <-> A2A message/send bridge +# --------------------------------------------------------------------------- +# +# ``nat eval`` drives an agent by POSTing ``{endpoint}/generate/full`` with +# ``{"input_message": ""}`` and reading ``data: {"value": ""}`` +# lines. For an external agent the eval endpoint resolves to the agent-name +# proxy, so those requests land here. We translate them to A2A ``message/send`` +# so external agents evaluate through the existing System-A (nat eval) pipeline +# with unchanged eval configs. + + +async def _generate_full_stream(text: str) -> AsyncIterator[bytes]: + # nat eval joins the ``value`` fields of every ``data:`` line; one line is enough. + yield f"data: {json.dumps({'value': text})}\n\n".encode() + + +async def _serve_external_generate(name: str, trailing_uri: str, request: Request, agent: Agent) -> StreamingResponse: + """Bridge a NAT ``/generate`` request for an external agent to A2A ``message/send``.""" + if not trailing_uri.startswith("generate"): + raise HTTPException( + status_code=400, + detail=( + f"External agent '{name}' only supports generate/chat; " + f"'{trailing_uri}' is not available (the agent runs outside NeMo Platform)." + ), + ) + + a2a_endpoint = (agent.card or {}).get("url") or agent.endpoint + if not isinstance(a2a_endpoint, str) or not a2a_endpoint: + raise HTTPException(status_code=400, detail=f"External agent '{name}' has no endpoint.") + + try: + body = await request.json() + except Exception: + body = {} + question = body.get("input_message") if isinstance(body, dict) else None + if not isinstance(question, str) or not question: + raise HTTPException(status_code=400, detail="Request has no 'input_message' to send.") + + try: + reply = await send_a2a_message(a2a_endpoint, question) + except A2AMessageError as exc: + raise HTTPException(status_code=502, detail=f"External agent generate failed: {exc}") from exc + + return StreamingResponse(_generate_full_stream(reply), media_type="text/event-stream") diff --git a/plugins/nemo-agents/tests/unit/test_gateway.py b/plugins/nemo-agents/tests/unit/test_gateway.py index 3cf47f84b0..618fda162e 100644 --- a/plugins/nemo-agents/tests/unit/test_gateway.py +++ b/plugins/nemo-agents/tests/unit/test_gateway.py @@ -721,3 +721,44 @@ def test_a2a_failure_returns_502( resp = client.post(self._URL, json={"messages": [{"role": "user", "content": "hi"}]}) assert resp.status_code == 502 + + +class TestExternalAgentGenerate: + """nat eval hits .../agents/{name}/-/generate/full -> A2A bridge for external agents.""" + + _URL = "/apis/agents/v2/workspaces/default/agents/ext/-/generate/full" + + def test_generate_full_bridges_to_a2a( + self, client: TestClient, mock_entity_client: AsyncMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + mock_entity_client.get = AsyncMock(return_value=_make_external_agent()) + send = AsyncMock(return_value="156") + monkeypatch.setattr(gateway_module, "send_a2a_message", send) + + resp = client.post(self._URL, json={"input_message": "What is 12 * 13?"}) + + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/event-stream") + assert '"value": "156"' in resp.text + assert send.await_args is not None + assert send.await_args.args[1] == "What is 12 * 13?" + + def test_missing_input_message_returns_400( + self, client: TestClient, mock_entity_client: AsyncMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + mock_entity_client.get = AsyncMock(return_value=_make_external_agent()) + monkeypatch.setattr(gateway_module, "send_a2a_message", AsyncMock(return_value="x")) + + resp = client.post(self._URL, json={"nope": "x"}) + + assert resp.status_code == 400 + + def test_non_generate_path_returns_400(self, client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get = AsyncMock(return_value=_make_external_agent()) + + resp = client.post( + "/apis/agents/v2/workspaces/default/agents/ext/-/v1/chat/completions", + json={"input_message": "hi"}, + ) + + assert resp.status_code == 400 diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx index 47bbf3131a..50316343b8 100644 --- a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx @@ -68,20 +68,11 @@ export const AgentDetailsContent: FC = ({ {agent.description} )} - {isExternal ? ( - - Runs outside NeMo Platform. Deploy and evaluate are unavailable for external agents. - - ) : ( - - + + + {!isExternal && (
= ({ {isDeploying ? 'Deploying…' : 'Deploy this Agent'}
-
+ )} +
+ {isExternal && ( + + Runs outside NeMo Platform. Deploy is unavailable; evaluation runs against the agent's + endpoint. + )} @@ -205,54 +202,54 @@ export const AgentDetailsContent: FC = ({ ), value: 'deployments', }, - { - chevronPosition: 'start' as const, - slotTrigger: 'Recent Evaluations', - slotContent: - agentEvals.length === 0 ? ( - - No evaluation jobs found for this agent. - - - View all evaluations → - - - - ) : ( - - {agentEvals.map((job) => ( - - - - - {job.name} - - - - - - - - - ))} - - - View all evaluations → - - - - ), - value: 'evaluations', - }, ]), + { + chevronPosition: 'start' as const, + slotTrigger: 'Recent Evaluations', + slotContent: + agentEvals.length === 0 ? ( + + No evaluation jobs found for this agent. + + + View all evaluations → + + + + ) : ( + + {agentEvals.map((job) => ( + + + + + {job.name} + + + + + + + + + ))} + + + View all evaluations → + + + + ), + value: 'evaluations', + }, ]} /> From 389df311795f7415ec62039b4cfb4aac83cf1c7b Mon Sep 17 00:00:00 2001 From: mschwab Date: Thu, 16 Jul 2026 18:08:37 -0700 Subject: [PATCH 05/11] feat(agents): stream external agent chat via A2A message/stream External chat used non-streaming message/send + one buffered SSE chunk, so the UI froze until the agent finished. The streaming path now uses A2A message/stream and forwards each text delta as an OpenAI chat.completion.chunk: token-streaming agents show progress, buffering agents (e.g. NAT react_agent) still send one final chunk. Non-streaming requests keep using message/send. - a2a.py: stream_a2a_message (SSE JSON-RPC) + extract_stream_delta - gateway.py: streaming path emits OpenAI chunks from A2A deltas; a mid-stream error is surfaced as content since the HTTP status is already 200 - tests: stream delta extraction, delta forwarding, jsonrpc + mid-stream errors Signed-off-by: mschwab --- .../nemo-agents/src/nemo_agents_plugin/a2a.py | 66 +++++++++++++++++++ .../src/nemo_agents_plugin/api/v2/gateway.py | 59 +++++++++++------ plugins/nemo-agents/tests/unit/test_a2a.py | 43 ++++++++++++ .../nemo-agents/tests/unit/test_gateway.py | 35 +++++++++- 4 files changed, 181 insertions(+), 22 deletions(-) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py b/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py index b1d2a8a91b..5dd03b2b02 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py @@ -12,6 +12,8 @@ from __future__ import annotations +import json +from collections.abc import AsyncIterator from typing import Any from urllib.parse import urljoin from uuid import uuid4 @@ -154,3 +156,67 @@ async def send_a2a_message(endpoint: str, text: str) -> str: raise A2AMessageError(f"agent error: {msg}") return extract_message_text(data.get("result") if isinstance(data, dict) else None) + + +def extract_stream_delta(result: Any) -> str: + """Extract the text delta from a single A2A ``message/stream`` event result. + + Streaming events are Messages (``parts``), artifact updates (``artifact.parts``), + or status updates (skipped — usually just lifecycle state). Returns "" for + events carrying no text. + """ + if not isinstance(result, dict): + return "" + artifact = result.get("artifact") + if isinstance(artifact, dict): + return "".join(_collect_text_parts(artifact.get("parts"))) + if result.get("kind") == "message" or "parts" in result: + return "".join(_collect_text_parts(result.get("parts"))) + return "" + + +async def stream_a2a_message(endpoint: str, text: str) -> AsyncIterator[str]: + """Stream an external A2A agent's reply via ``message/stream``, yielding text deltas. + + Speaks A2A JSON-RPC over SSE. Agents that stream tokens (artifact-update + deltas) yield incrementally; agents that buffer (e.g. NAT react_agent) yield + a single final chunk. Raises :class:`A2AMessageError` on transport/JSON-RPC + failure before the first token; mid-stream transport drops end the iterator. + """ + payload = { + "jsonrpc": "2.0", + "id": uuid4().hex, + "method": "message/stream", + "params": { + "message": { + "role": "user", + "parts": [{"kind": "text", "text": text}], + "messageId": uuid4().hex, + "kind": "message", + } + }, + } + try: + async with httpx.AsyncClient(timeout=_MESSAGE_TIMEOUT_S, follow_redirects=True) as client: + async with client.stream("POST", endpoint, json=payload, headers={"Accept": "text/event-stream"}) as resp: + if resp.status_code != 200: + raise A2AMessageError(f"agent returned HTTP {resp.status_code}") + async for line in resp.aiter_lines(): + line = line.strip() + if not line or line.startswith(":"): # keepalive / comment + continue + if not line.startswith("data:"): + continue + try: + data = json.loads(line[len("data:") :].strip()) + except ValueError: + continue + if isinstance(data, dict) and data.get("error"): + err = data["error"] + msg = err.get("message") if isinstance(err, dict) else str(err) + raise A2AMessageError(f"agent error: {msg}") + delta = extract_stream_delta(data.get("result") if isinstance(data, dict) else None) + if delta: + yield delta + except httpx.HTTPError as exc: + raise A2AMessageError(f"could not reach agent at {endpoint} ({exc.__class__.__name__})") from exc diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py index e46f15771d..17aa7a8b91 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py @@ -35,7 +35,7 @@ import httpx from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse, StreamingResponse -from nemo_agents_plugin.a2a import A2AMessageError, send_a2a_message +from nemo_agents_plugin.a2a import A2AMessageError, send_a2a_message, stream_a2a_message from nemo_agents_plugin.api.v2._perms import GatewayPerms from nemo_agents_plugin.api.v2.dependencies import get_entity_client from nemo_agents_plugin.authz import scope @@ -484,22 +484,39 @@ def _openai_completion(text: str, model: str) -> dict[str, Any]: } -async def _openai_sse(text: str, model: str) -> AsyncIterator[bytes]: +def _openai_chunk(completion_id: str, created: int, model: str, delta: dict[str, Any], finish: str | None) -> bytes: + payload = { + "id": completion_id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [{"index": 0, "delta": delta, "finish_reason": finish}], + } + return f"data: {json.dumps(payload)}\n\n".encode() + + +async def _stream_openai_from_a2a(endpoint: str, text: str, model: str) -> AsyncIterator[bytes]: + """Stream an external agent's A2A reply as OpenAI chat.completion.chunk SSE. + + Forwards each A2A text delta as a content chunk. A mid-stream error is + surfaced as content (the HTTP status is already 200 once streaming starts), + so the user sees it in the chat rather than a silent stall. + """ created = int(time.time()) completion_id = f"chatcmpl-{uuid4().hex}" - - def _chunk(delta: dict[str, Any], finish: str | None) -> bytes: - payload = { - "id": completion_id, - "object": "chat.completion.chunk", - "created": created, - "model": model, - "choices": [{"index": 0, "delta": delta, "finish_reason": finish}], - } - return f"data: {json.dumps(payload)}\n\n".encode() - - yield _chunk({"role": "assistant", "content": text}, None) - yield _chunk({}, "stop") + role_sent = False + try: + async for delta in stream_a2a_message(endpoint, text): + payload = {"content": delta} if role_sent else {"role": "assistant", "content": delta} + role_sent = True + yield _openai_chunk(completion_id, created, model, payload, None) + if not role_sent: + yield _openai_chunk(completion_id, created, model, {"role": "assistant", "content": ""}, None) + except A2AMessageError as exc: + note = f"[external agent error: {exc}]" + payload = {"content": f"\n{note}"} if role_sent else {"role": "assistant", "content": note} + yield _openai_chunk(completion_id, created, model, payload, None) + yield _openai_chunk(completion_id, created, model, {}, "stop") yield b"data: [DONE]\n\n" @@ -541,14 +558,18 @@ async def external_agent_chat_completions( if not text: raise HTTPException(status_code=400, detail="Request has no user message to send.") + model = body.get("model") or name + + # Streaming: forward A2A deltas as they arrive (message/stream), so the UI + # shows progress instead of freezing until the agent finishes. + if body.get("stream"): + return StreamingResponse(_stream_openai_from_a2a(a2a_endpoint, text, model), media_type="text/event-stream") + + # Non-streaming: single message/send, returned as one OpenAI completion. try: reply = await send_a2a_message(a2a_endpoint, text) except A2AMessageError as exc: raise HTTPException(status_code=502, detail=f"External agent chat failed: {exc}") from exc - - model = body.get("model") or name - if body.get("stream"): - return StreamingResponse(_openai_sse(reply, model), media_type="text/event-stream") return JSONResponse(_openai_completion(reply, model)) diff --git a/plugins/nemo-agents/tests/unit/test_a2a.py b/plugins/nemo-agents/tests/unit/test_a2a.py index e4b74aa8a4..5b73083437 100644 --- a/plugins/nemo-agents/tests/unit/test_a2a.py +++ b/plugins/nemo-agents/tests/unit/test_a2a.py @@ -14,8 +14,10 @@ A2AMessageError, AgentCardError, extract_message_text, + extract_stream_delta, fetch_agent_card, send_a2a_message, + stream_a2a_message, ) CARD = {"name": "Calculator Agent", "description": "does math", "skills": [{"id": "add", "name": "add"}]} @@ -126,3 +128,44 @@ async def test_send_message_transport_error_raises() -> None: respx.post("http://host:10000/").mock(side_effect=httpx.ConnectError("refused")) with pytest.raises(A2AMessageError, match="could not reach"): await send_a2a_message("http://host:10000/", "hi") + + +class TestExtractStreamDelta: + def test_message_event(self) -> None: + assert extract_stream_delta({"kind": "message", "parts": [{"kind": "text", "text": "hi"}]}) == "hi" + + def test_artifact_update_event(self) -> None: + event = {"kind": "artifact-update", "artifact": {"parts": [{"kind": "text", "text": "tok"}]}} + assert extract_stream_delta(event) == "tok" + + def test_status_update_and_empty_yield_nothing(self) -> None: + assert extract_stream_delta({"kind": "status-update", "status": {"state": "working"}}) == "" + assert extract_stream_delta(None) == "" + + +@pytest.mark.asyncio +@respx.mock +async def test_stream_message_yields_deltas() -> None: + sse = ( + ": ping\n\n" + 'data: {"jsonrpc":"2.0","id":"1","result":{"kind":"artifact-update",' + '"artifact":{"parts":[{"kind":"text","text":"2 + 2"}]}}}\n\n' + 'data: {"jsonrpc":"2.0","id":"1","result":{"kind":"message",' + '"parts":[{"kind":"text","text":" = 4"}]}}\n\n' + ) + respx.post("http://host:10000/").mock( + return_value=httpx.Response(200, text=sse, headers={"content-type": "text/event-stream"}) + ) + deltas = [d async for d in stream_a2a_message("http://host:10000/", "what is 2+2?")] + assert deltas == ["2 + 2", " = 4"] + + +@pytest.mark.asyncio +@respx.mock +async def test_stream_jsonrpc_error_raises() -> None: + sse = 'data: {"jsonrpc":"2.0","id":"1","error":{"code":-32000,"message":"kaboom"}}\n\n' + respx.post("http://host:10000/").mock( + return_value=httpx.Response(200, text=sse, headers={"content-type": "text/event-stream"}) + ) + with pytest.raises(A2AMessageError, match="kaboom"): + _ = [d async for d in stream_a2a_message("http://host:10000/", "hi")] diff --git a/plugins/nemo-agents/tests/unit/test_gateway.py b/plugins/nemo-agents/tests/unit/test_gateway.py index 618fda162e..bfe4ff6822 100644 --- a/plugins/nemo-agents/tests/unit/test_gateway.py +++ b/plugins/nemo-agents/tests/unit/test_gateway.py @@ -648,11 +648,16 @@ def test_non_stream_returns_openai_completion( assert body["object"] == "chat.completion" assert body["choices"][0]["message"]["content"] == "42 degrees" - def test_stream_returns_sse( + def test_stream_forwards_a2a_deltas_as_sse( self, client: TestClient, mock_entity_client: AsyncMock, monkeypatch: pytest.MonkeyPatch ) -> None: mock_entity_client.get = AsyncMock(return_value=_make_external_agent()) - monkeypatch.setattr(gateway_module, "send_a2a_message", AsyncMock(return_value="sunny")) + + async def fake_stream(endpoint: str, text: str): + for delta in ["sun", "ny ", "24C"]: + yield delta + + monkeypatch.setattr(gateway_module, "stream_a2a_message", fake_stream) resp = client.post( self._URL, @@ -661,7 +666,31 @@ def test_stream_returns_sse( assert resp.status_code == 200 assert resp.headers["content-type"].startswith("text/event-stream") - assert "sunny" in resp.text + assert '"content": "sun"' in resp.text + assert '"content": "24C"' in resp.text + assert '"finish_reason": "stop"' in resp.text + assert "[DONE]" in resp.text + + def test_stream_mid_error_surfaced_as_content( + self, client: TestClient, mock_entity_client: AsyncMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + from nemo_agents_plugin.a2a import A2AMessageError + + mock_entity_client.get = AsyncMock(return_value=_make_external_agent()) + + async def boom(endpoint: str, text: str): + raise A2AMessageError("upstream gone") + yield # pragma: no cover - makes this an async generator + + monkeypatch.setattr(gateway_module, "stream_a2a_message", boom) + + resp = client.post( + self._URL, + json={"stream": True, "messages": [{"role": "user", "content": "hi"}]}, + ) + + assert resp.status_code == 200 + assert "external agent error" in resp.text assert "[DONE]" in resp.text def test_forwards_latest_user_text( From 68ad0f2a563988cd6d16fef84987b8796e728dda Mon Sep 17 00:00:00 2001 From: mschwab Date: Thu, 16 Jul 2026 18:37:12 -0700 Subject: [PATCH 06/11] feat(agents): register external agents by URL only (drop paste-config) Simplify the Register Existing Agent modal to endpoint-URL only for now: remove the "Paste config" YAML tab, the mode toggle, and the now-unused parseAgentConfig helper + test. Update the empty-state copy to match. Backend create still accepts config XOR url; this is a UI scope reduction only. Signed-off-by: mschwab --- .../src/api/agents/parseAgentConfig.test.ts | 46 ------- .../studio/src/api/agents/parseAgentConfig.ts | 38 ------ .../dataViews/AgentsDataView/index.tsx | 2 +- .../RegisterAgentModal/index.tsx | 116 +++++------------- .../RegisterAgentModal/type.ts | 29 +---- 5 files changed, 38 insertions(+), 193 deletions(-) delete mode 100644 web/packages/studio/src/api/agents/parseAgentConfig.test.ts delete mode 100644 web/packages/studio/src/api/agents/parseAgentConfig.ts diff --git a/web/packages/studio/src/api/agents/parseAgentConfig.test.ts b/web/packages/studio/src/api/agents/parseAgentConfig.test.ts deleted file mode 100644 index 63cb6d22d8..0000000000 --- a/web/packages/studio/src/api/agents/parseAgentConfig.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { parseAgentConfig } from '@studio/api/agents/parseAgentConfig'; - -describe('parseAgentConfig', () => { - it('parses a NAT workflow YAML into a config dict', () => { - const config = parseAgentConfig(` -llms: - llm: - _type: openai - model_name: gpt-4o -workflow: - _type: react_agent - llm_name: llm -`); - expect(config).toMatchObject({ - workflow: { _type: 'react_agent', llm_name: 'llm' }, - llms: { llm: { model_name: 'gpt-4o' } }, - }); - }); - - it('accepts JSON input (YAML is a JSON superset)', () => { - expect(parseAgentConfig('{"workflow": {"_type": "react_agent"}}')).toEqual({ - workflow: { _type: 'react_agent' }, - }); - }); - - it('rejects empty input', () => { - expect(() => parseAgentConfig(' ')).toThrow(/paste your nat workflow config/i); - }); - - it('rejects invalid YAML', () => { - expect(() => parseAgentConfig('foo: [1, 2')).toThrow(/not valid yaml/i); - }); - - it('rejects a scalar', () => { - expect(() => parseAgentConfig('just a string')).toThrow(/must be a yaml mapping/i); - }); - - it('rejects a config with no workflow section', () => { - expect(() => parseAgentConfig('llms:\n llm:\n _type: openai')).toThrow( - /missing.*workflow/i - ); - }); -}); diff --git a/web/packages/studio/src/api/agents/parseAgentConfig.ts b/web/packages/studio/src/api/agents/parseAgentConfig.ts deleted file mode 100644 index 8bdb4f5717..0000000000 --- a/web/packages/studio/src/api/agents/parseAgentConfig.ts +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import YAML from 'yaml'; - -/** - * Parses a user-supplied NAT workflow config (YAML or JSON text) into the dict - * the agents API stores as `config`. Validates enough to catch paste mistakes - * early: it must parse to an object and carry a top-level `workflow` section. - * Deep NAT validation only happens at deploy time, so we stay intentionally - * light here and surface a clear message instead. - */ -export const parseAgentConfig = (text: string): Record => { - const trimmed = text.trim(); - if (!trimmed) { - throw new Error('Paste your NAT workflow config to register an agent.'); - } - - let parsed: unknown; - try { - parsed = YAML.parse(trimmed); - } catch (err) { - throw new Error(`Config is not valid YAML: ${(err as Error).message}`); - } - - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('Config must be a YAML mapping (a NAT workflow config).'); - } - - const config = parsed as Record; - if (!('workflow' in config)) { - throw new Error( - "Config is missing a top-level 'workflow' section — this is not a NAT workflow." - ); - } - - return config; -}; diff --git a/web/packages/studio/src/components/dataViews/AgentsDataView/index.tsx b/web/packages/studio/src/components/dataViews/AgentsDataView/index.tsx index ff71374071..e1f5912cd9 100644 --- a/web/packages/studio/src/components/dataViews/AgentsDataView/index.tsx +++ b/web/packages/studio/src/components/dataViews/AgentsDataView/index.tsx @@ -383,7 +383,7 @@ export const AgentsTable: FC = ({ renderEmptyState: () => ( } actions={ diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/index.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/index.tsx index 0a17de0a26..9d53106817 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/index.tsx +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/index.tsx @@ -2,15 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { zodResolver } from '@hookform/resolvers/zod'; -import { ControlledTextArea } from '@nemo/common/src/components/form/ControlledTextArea'; import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; import { FormModal } from '@nemo/common/src/components/FormModal'; import { useToast } from '@nemo/common/src/providers/toast/useToast'; import { getAgentsListAgentsQueryKey, useAgentsCreateAgent } from '@nemo/sdk/generated/agents/api'; -import { Anchor, Block, SegmentedControl, Text } from '@nvidia/foundations-react-core'; -import { parseAgentConfig } from '@studio/api/agents/parseAgentConfig'; +import { Block, Text } from '@nvidia/foundations-react-core'; import { getErrorMessage } from '@studio/api/common/utils'; -import { LINK_DOCS_AGENTS } from '@studio/constants/links'; import { registerAgentFormSchema, type RegisterAgentFormData, @@ -19,27 +16,16 @@ import { import { getAgentDetailRoute } from '@studio/routes/utils'; import { useQueryClient } from '@tanstack/react-query'; import { type FC, useState } from 'react'; -import { type SubmitHandler, useForm, useWatch } from 'react-hook-form'; +import { type SubmitHandler, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; -const DEFAULT_VALUES: RegisterAgentFormData = { - mode: 'url', - name: '', - description: '', - url: '', - configText: '', -}; - -const MODE_ITEMS = [ - { value: 'url', children: 'Connect running agent' }, - { value: 'config', children: 'Paste config' }, -]; +const DEFAULT_VALUES: RegisterAgentFormData = { name: '', description: '', url: '' }; export const RegisterAgentModal: FC = ({ open, onClose, workspace }) => { const toast = useToast(); const navigate = useNavigate(); const queryClient = useQueryClient(); - const [parseError, setParseError] = useState(undefined); + const [submitError, setSubmitError] = useState(undefined); const { mutateAsync: createAgent, @@ -69,42 +55,31 @@ export const RegisterAgentModal: FC = ({ open, onClose, mode: 'onChange', }); - const mode = useWatch({ control, name: 'mode' }); - const resetAndClose = () => { resetMutation(); - setParseError(undefined); + setSubmitError(undefined); resetForm(DEFAULT_VALUES); onClose(); }; const onSubmit: SubmitHandler = async (formData) => { - setParseError(undefined); - const description = formData.description?.trim() || ''; - - let data: Parameters[0]['data']; - if (formData.mode === 'url') { - data = { name: formData.name.trim(), description, url: formData.url?.trim() }; - } else { - let config: Record; - try { - config = parseAgentConfig(formData.configText ?? ''); - } catch (err) { - setParseError((err as Error).message); - return; - } - data = { name: formData.name.trim(), description, config }; - } - + setSubmitError(undefined); try { - await createAgent({ workspace, data }); + await createAgent({ + workspace, + data: { + name: formData.name.trim(), + description: formData.description?.trim() || '', + url: formData.url.trim(), + }, + }); } catch { // surfaced via errorText } }; const errorMessage = - parseError ?? + submitError ?? (createError ? getErrorMessage(createError as Error, 'Failed to register agent') : undefined); return ( @@ -118,15 +93,6 @@ export const RegisterAgentModal: FC = ({ open, onClose, loading={isPending} errorText={errorMessage} > - - resetForm({ ...DEFAULT_VALUES, mode: v as RegisterAgentFormData['mode'] }) - } - /> - = ({ open, onClose, placeholder="Optional" formFieldProps={{ slotError: errors.description?.message }} /> - - {mode === 'url' ? ( - - - Points at a NAT agent already running (A2A). NeMo Platform fetches its agent card - and does not run it. - - - ), - }} - /> - ) : ( - - How to structure a NAT workflow config - - ), - }} - /> - )} + + + Points at a NAT agent already running (A2A). NeMo Platform fetches its agent card + and does not run it. + + + ), + }} + /> ); }; diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/type.ts b/web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/type.ts index 158db9e6ac..09a7a05f4f 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/type.ts +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/type.ts @@ -4,30 +4,11 @@ import type { FormModalProps } from '@nemo/common/src/components/FormModal'; import { z } from 'zod'; -export const registerAgentFormSchema = z - .object({ - mode: z.enum(['url', 'config']), - name: z.string().min(1, 'Name is required'), - description: z.string().optional(), - url: z.string().optional(), - configText: z.string().optional(), - }) - .superRefine((v, ctx) => { - if (v.mode === 'url' && !v.url?.trim()) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['url'], - message: 'Endpoint URL is required', - }); - } - if (v.mode === 'config' && !v.configText?.trim()) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['configText'], - message: 'Config is required', - }); - } - }); +export const registerAgentFormSchema = z.object({ + name: z.string().min(1, 'Name is required'), + description: z.string().optional(), + url: z.string().trim().min(1, 'Endpoint URL is required'), +}); export type RegisterAgentFormData = z.infer; From 912ac4f4384d5bc0e4999a0077a218a9bd082fa2 Mon Sep 17 00:00:00 2001 From: mschwab Date: Thu, 16 Jul 2026 19:26:16 -0700 Subject: [PATCH 07/11] =?UTF-8?q?fix(agents):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20security,=20CLI=20parity,=20and=20correctness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex + roundtable review of the external-agent feature. Fixes: Security / robustness (backend, a2a.py + gateway.py): - Invoke external agents at the vetted registered endpoint, never the card's self-reported url (remote-controlled → SSRF/exfil target) - Collapse card-fetch errors to one generic message (distinct errors were an internal port-scan oracle); log the specific reason server-side - Cap card + message + stream response bytes with an incremental read (the old cap ran after the body was fully buffered); log A2A failures - Chat route catches Exception -> 500 like its siblings - Reject structurally invalid endpoint URLs in the create schema (http://[ 500) CLI/SDK parity: - Bridge /agents/{name}/-/v1/chat/completions (nemo agents invoke / SDK) to A2A, not just /generate — external agents were unreachable via the standard path Frontend: - Redact api_key/token/secret in the Workflow tab config/card view - Resolve the panel agent via GET /agents/{name} not a page-1 list scan (external agents past page 1 were mis-rendered as managed) - Validate the endpoint URL in the register form - Repair Studio tests broken by the dual-CTA empty state + copy change; add a GET-by-name mock handler Tests: a2a stream/size, chat-via-proxy bridge, malformed-URL 422, redactSecrets. Signed-off-by: mschwab --- .../nemo-agents/src/nemo_agents_plugin/a2a.py | 74 ++++++++-- .../src/nemo_agents_plugin/api/v2/gateway.py | 134 +++++++++++------- .../src/nemo_agents_plugin/schema.py | 4 + plugins/nemo-agents/tests/unit/test_a2a.py | 2 +- .../nemo-agents/tests/unit/test_agents_api.py | 8 ++ .../nemo-agents/tests/unit/test_gateway.py | 20 ++- .../dataViews/AgentsDataView/index.test.tsx | 6 +- .../AgentPanel/AgentWorkflowContent.tsx | 5 +- .../AgentPanel/redactSecrets.test.ts | 30 ++++ .../AgentPanels/AgentPanel/redactSecrets.ts | 26 ++++ .../AgentPanels/AgentPanel/useAgentPanel.ts | 9 +- web/packages/studio/src/mocks/handlers.ts | 33 +++++ .../RegisterAgentModal/type.ts | 15 +- .../agents/AgentsListRoute/index.test.tsx | 4 +- 14 files changed, 295 insertions(+), 75 deletions(-) create mode 100644 web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/redactSecrets.test.ts create mode 100644 web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/redactSecrets.ts diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py b/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py index 5dd03b2b02..67be0d91a8 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py @@ -13,6 +13,7 @@ from __future__ import annotations import json +import logging from collections.abc import AsyncIterator from typing import Any from urllib.parse import urljoin @@ -20,6 +21,8 @@ import httpx +logger = logging.getLogger(__name__) + # Well-known paths that expose an A2A agent card. The spec moved from # ``agent.json`` to ``agent-card.json``; try the current name first. AGENT_CARD_PATHS = ("/.well-known/agent-card.json", "/.well-known/agent.json") @@ -32,6 +35,10 @@ # Agents can take a while to answer; allow more headroom than card discovery. _MESSAGE_TIMEOUT_S = 120.0 +# Cap message/stream bodies too — replies are larger than cards but still bounded, +# so one hostile endpoint can't OOM the shared gateway worker. +_MAX_MESSAGE_BYTES = 8 * 1024 * 1024 + class AgentCardError(Exception): """Raised when an external agent's card can't be fetched or parsed.""" @@ -41,6 +48,23 @@ class A2AMessageError(Exception): """Raised when a ``message/send`` call to an external agent fails.""" +async def _read_capped(response: httpx.Response, cap: int) -> bytes: + """Read a streamed response body, aborting once *cap* bytes are exceeded. + + Reads incrementally (unlike ``response.content``, which buffers the whole + decoded body first) so an oversized/compressed body can't exhaust memory + before the size check runs. + """ + chunks: list[bytes] = [] + total = 0 + async for chunk in response.aiter_bytes(): + total += len(chunk) + if total > cap: + raise A2AMessageError("agent response exceeded the size limit") + chunks.append(chunk) + return b"".join(chunks) + + def _card_url(base_url: str, path: str) -> str: # urljoin needs a trailing slash on the base to preserve any path prefix, # and the well-known path is absolute, so join against the origin. @@ -58,31 +82,48 @@ async def fetch_agent_card(base_url: str) -> dict[str, Any]: if not base.startswith(("http://", "https://")): raise AgentCardError("Endpoint must be an http(s) URL.") + # Track the specific reason for logging, but never surface it to the caller: + # distinct "could not reach" vs "HTTP 401" vs "not JSON" messages would let a + # caller probe which internal hosts/ports exist (an SSRF oracle). last_error = "no agent card found" async with httpx.AsyncClient(timeout=_FETCH_TIMEOUT_S, follow_redirects=True) as client: for path in AGENT_CARD_PATHS: url = _card_url(base, path) try: - resp = await client.get(url, headers={"Accept": "application/json"}) + async with client.stream("GET", url, headers={"Accept": "application/json"}) as resp: + if resp.status_code != 200: + last_error = f"HTTP {resp.status_code} from {path}" + continue + try: + raw = await _read_capped(resp, _MAX_CARD_BYTES) + except A2AMessageError: + last_error = "card response exceeded the size limit" + continue except httpx.HTTPError as exc: - last_error = f"could not reach agent at {base} ({exc.__class__.__name__})" - continue - if resp.status_code != 200: - last_error = f"agent card request returned HTTP {resp.status_code}" + last_error = f"transport error ({exc.__class__.__name__})" continue - if len(resp.content) > _MAX_CARD_BYTES: - raise AgentCardError("Agent card response is too large.") try: - card = resp.json() + card = json.loads(raw) except ValueError: - last_error = "agent card response was not valid JSON" + last_error = "card response was not valid JSON" continue if not isinstance(card, dict) or not (card.get("name") or card.get("skills")): last_error = "response did not look like an A2A agent card" continue return card - raise AgentCardError(f"Could not fetch A2A agent card: {last_error}.") + logger.info("Agent card fetch failed for %s: %s", _endpoint_host(base), last_error) + raise AgentCardError("Could not fetch a valid A2A agent card from the provided URL.") + + +def _endpoint_host(url: str) -> str: + """Host of *url* for logging — avoid echoing the full URL back anywhere.""" + from urllib.parse import urlparse + + try: + return urlparse(url).hostname or "unknown" + except ValueError: + return "unknown" def _collect_text_parts(parts: Any) -> list[str]: @@ -139,14 +180,15 @@ async def send_a2a_message(endpoint: str, text: str) -> str: } try: async with httpx.AsyncClient(timeout=_MESSAGE_TIMEOUT_S, follow_redirects=True) as client: - resp = await client.post(endpoint, json=payload, headers={"Accept": "application/json"}) + async with client.stream("POST", endpoint, json=payload, headers={"Accept": "application/json"}) as resp: + if resp.status_code != 200: + raise A2AMessageError(f"agent returned HTTP {resp.status_code}") + raw = await _read_capped(resp, _MAX_MESSAGE_BYTES) except httpx.HTTPError as exc: raise A2AMessageError(f"could not reach agent at {endpoint} ({exc.__class__.__name__})") from exc - if resp.status_code != 200: - raise A2AMessageError(f"agent returned HTTP {resp.status_code}") try: - data = resp.json() + data = json.loads(raw) except ValueError as exc: raise A2AMessageError("agent response was not valid JSON") from exc @@ -201,7 +243,11 @@ async def stream_a2a_message(endpoint: str, text: str) -> AsyncIterator[str]: async with client.stream("POST", endpoint, json=payload, headers={"Accept": "text/event-stream"}) as resp: if resp.status_code != 200: raise A2AMessageError(f"agent returned HTTP {resp.status_code}") + total = 0 async for line in resp.aiter_lines(): + total += len(line) + if total > _MAX_MESSAGE_BYTES: + raise A2AMessageError("agent stream exceeded the size limit") line = line.strip() if not line or line.startswith(":"): # keepalive / comment continue diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py index 17aa7a8b91..8cac385066 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py @@ -127,13 +127,13 @@ async def _serve_agent_proxy( trailing_uri: str, request: Request, entity_client: NemoEntitiesClient, -) -> StreamingResponse: +) -> StreamingResponse | JSONResponse: """Forward a request addressed by agent name to the agent behind it. Managed agents proxy to their first ``running`` deployment (``503`` if none). - External agents have no deployment; their NAT ``/generate`` traffic (e.g. from - ``nat eval``) is bridged to A2A ``message/send`` instead. Shared by the - read/write route handlers, which differ only in authorization scope. + External agents have no deployment; their chat/completions and NAT ``generate`` + traffic is bridged to A2A instead. Shared by the read/write route handlers, + which differ only in authorization scope. """ try: agent = await entity_client.get(Agent, name=name, workspace=workspace) @@ -143,7 +143,7 @@ async def _serve_agent_proxy( raise HTTPException(status_code=500, detail=str(exc)) from exc if agent.source == "external": - return await _serve_external_generate(name, trailing_uri, request, agent) + return await _serve_external_agent(name, trailing_uri, request, agent) endpoint = await _resolve_agent_endpoint(name, workspace, entity_client) return await _proxy(request, endpoint, trailing_uri, model_name=name) @@ -154,6 +154,7 @@ async def _serve_agent_proxy( methods=_PROXY_READ_METHODS, tags=["Agent Gateway"], include_in_schema=False, + response_model=None, ) @scope.read @path_rule( @@ -166,7 +167,7 @@ async def proxy_by_agent_name_read( trailing_uri: str, request: Request, entity_client: NemoEntitiesClient = Depends(get_entity_client), -) -> StreamingResponse: +) -> StreamingResponse | JSONResponse: """Read-scoped (GET/HEAD/OPTIONS) proxy to the active deployment for *agent name*.""" return await _serve_agent_proxy(workspace, name, trailing_uri, request, entity_client) @@ -176,6 +177,7 @@ async def proxy_by_agent_name_read( methods=_PROXY_WRITE_METHODS, tags=["Agent Gateway"], include_in_schema=False, + response_model=None, ) @scope.write @path_rule( @@ -188,7 +190,7 @@ async def proxy_by_agent_name_write( trailing_uri: str, request: Request, entity_client: NemoEntitiesClient = Depends(get_entity_client), -) -> StreamingResponse: +) -> StreamingResponse | JSONResponse: """Write-scoped (POST/PUT/PATCH/DELETE) proxy to the active deployment for *agent name*.""" return await _serve_agent_proxy(workspace, name, trailing_uri, request, entity_client) @@ -542,6 +544,8 @@ async def external_agent_chat_completions( agent = await entity_client.get(Agent, name=name, workspace=workspace) except NemoEntityNotFoundError as exc: raise HTTPException(status_code=404, detail=f"Agent '{name}' not found in workspace '{workspace}'.") from exc + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc if agent.source != "external": raise HTTPException( @@ -549,11 +553,51 @@ async def external_agent_chat_completions( detail=f"Agent '{name}' is managed; chat through its deployment, not this endpoint.", ) - a2a_endpoint = (agent.card or {}).get("url") or agent.endpoint - if not isinstance(a2a_endpoint, str) or not a2a_endpoint: - raise HTTPException(status_code=400, detail=f"External agent '{name}' has no endpoint to chat with.") + endpoint = _external_a2a_endpoint(agent, name) + body = await _read_json_object(request) + return await _external_openai_chat(name, endpoint, body) + + +# --------------------------------------------------------------------------- +# External agent bridge — OpenAI chat/completions and NAT /generate <-> A2A +# --------------------------------------------------------------------------- +# +# External agents have no deployment, so requests addressed to the agent-name +# proxy (``/agents/{name}/-/...``) land in :func:`_serve_external_agent`. Two +# invocation shapes are bridged to A2A: OpenAI ``chat/completions`` (Studio, the +# CLI ``nemo agents invoke``, and the SDK) and NAT ``generate/full`` (``nat eval``). + + +def _endpoint_host(url: str) -> str: + """Host of *url* for logging — never the full URL (may carry embedded creds).""" + try: + return urlparse(url).hostname or "unknown" + except ValueError: + return "unknown" + + +def _external_a2a_endpoint(agent: Agent, name: str) -> str: + """Return the vetted URL to reach an external agent. - body = await request.json() + Always the registered ``endpoint`` — never the card's self-reported ``url``, + which is remote-controlled content and would let a registered agent redirect + all subsequent traffic to an arbitrary (e.g. internal) host. + """ + if not agent.endpoint: + raise HTTPException(status_code=400, detail=f"External agent '{name}' has no endpoint.") + return agent.endpoint + + +async def _read_json_object(request: Request) -> dict[str, Any]: + try: + body = await request.json() + except Exception: + return {} + return body if isinstance(body, dict) else {} + + +async def _external_openai_chat(name: str, endpoint: str, body: dict[str, Any]) -> StreamingResponse | JSONResponse: + """Translate an OpenAI chat/completions body to A2A and return OpenAI shape.""" text = _latest_user_text(body.get("messages")) if not text: raise HTTPException(status_code=400, detail="Request has no user message to send.") @@ -563,59 +607,53 @@ async def external_agent_chat_completions( # Streaming: forward A2A deltas as they arrive (message/stream), so the UI # shows progress instead of freezing until the agent finishes. if body.get("stream"): - return StreamingResponse(_stream_openai_from_a2a(a2a_endpoint, text, model), media_type="text/event-stream") + return StreamingResponse(_stream_openai_from_a2a(endpoint, text, model), media_type="text/event-stream") # Non-streaming: single message/send, returned as one OpenAI completion. try: - reply = await send_a2a_message(a2a_endpoint, text) + reply = await send_a2a_message(endpoint, text) except A2AMessageError as exc: + logger.warning("External agent '%s' chat failed (%s): %s", name, _endpoint_host(endpoint), exc) raise HTTPException(status_code=502, detail=f"External agent chat failed: {exc}") from exc return JSONResponse(_openai_completion(reply, model)) -# --------------------------------------------------------------------------- -# External agent generate — NAT /generate <-> A2A message/send bridge -# --------------------------------------------------------------------------- -# -# ``nat eval`` drives an agent by POSTing ``{endpoint}/generate/full`` with -# ``{"input_message": ""}`` and reading ``data: {"value": ""}`` -# lines. For an external agent the eval endpoint resolves to the agent-name -# proxy, so those requests land here. We translate them to A2A ``message/send`` -# so external agents evaluate through the existing System-A (nat eval) pipeline -# with unchanged eval configs. - - async def _generate_full_stream(text: str) -> AsyncIterator[bytes]: # nat eval joins the ``value`` fields of every ``data:`` line; one line is enough. yield f"data: {json.dumps({'value': text})}\n\n".encode() -async def _serve_external_generate(name: str, trailing_uri: str, request: Request, agent: Agent) -> StreamingResponse: - """Bridge a NAT ``/generate`` request for an external agent to A2A ``message/send``.""" - if not trailing_uri.startswith("generate"): - raise HTTPException( - status_code=400, - detail=( - f"External agent '{name}' only supports generate/chat; " - f"'{trailing_uri}' is not available (the agent runs outside NeMo Platform)." - ), - ) - - a2a_endpoint = (agent.card or {}).get("url") or agent.endpoint - if not isinstance(a2a_endpoint, str) or not a2a_endpoint: - raise HTTPException(status_code=400, detail=f"External agent '{name}' has no endpoint.") - - try: - body = await request.json() - except Exception: - body = {} - question = body.get("input_message") if isinstance(body, dict) else None +async def _serve_external_generate_full(name: str, endpoint: str, request: Request) -> StreamingResponse: + body = await _read_json_object(request) + question = body.get("input_message") if not isinstance(question, str) or not question: raise HTTPException(status_code=400, detail="Request has no 'input_message' to send.") - try: - reply = await send_a2a_message(a2a_endpoint, question) + reply = await send_a2a_message(endpoint, question) except A2AMessageError as exc: + logger.warning("External agent '%s' generate failed (%s): %s", name, _endpoint_host(endpoint), exc) raise HTTPException(status_code=502, detail=f"External agent generate failed: {exc}") from exc - return StreamingResponse(_generate_full_stream(reply), media_type="text/event-stream") + + +async def _serve_external_agent( + name: str, trailing_uri: str, request: Request, agent: Agent +) -> StreamingResponse | JSONResponse: + """Bridge an agent-name proxy request for an external agent to A2A. + + ``chat/completions`` (Studio / CLI invoke / SDK) → OpenAI↔A2A translation; + ``generate`` (nat eval) → NAT-generate↔A2A translation. Anything else 400s — + external agents have no deployment to proxy arbitrary paths to. + """ + endpoint = _external_a2a_endpoint(agent, name) + if trailing_uri.endswith("chat/completions"): + return await _external_openai_chat(name, endpoint, await _read_json_object(request)) + if trailing_uri.startswith("generate"): + return await _serve_external_generate_full(name, endpoint, request) + raise HTTPException( + status_code=400, + detail=( + f"External agent '{name}' supports chat/completions and generate only; " + f"'{trailing_uri}' is not available (the agent runs outside NeMo Platform)." + ), + ) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/schema.py b/plugins/nemo-agents/src/nemo_agents_plugin/schema.py index 49538c23e4..5a8dd755fe 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/schema.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/schema.py @@ -21,6 +21,7 @@ from __future__ import annotations from typing import Any +from urllib.parse import urlparse from nemo_agents_plugin.entities import Agent, AgentDeployment, DeploymentMode, DeploymentStatus from nemo_platform_plugin.schema import NemoFilter, NemoListResponse @@ -60,6 +61,9 @@ def _require_config_xor_url(self) -> CreateAgentRequest: if self.url: if self.config is not None: raise ValueError("Provide either 'config' (managed agent) or 'url' (external agent), not both.") + parsed = urlparse(self.url.strip()) + if parsed.scheme not in ("http", "https") or not parsed.hostname: + raise ValueError("'url' must be a valid http(s) URL, e.g. http://host:10000.") elif self.config is None: raise ValueError("'config' is required for a managed agent, or provide 'url' to register an external one.") return self diff --git a/plugins/nemo-agents/tests/unit/test_a2a.py b/plugins/nemo-agents/tests/unit/test_a2a.py index 5b73083437..fbb7854b27 100644 --- a/plugins/nemo-agents/tests/unit/test_a2a.py +++ b/plugins/nemo-agents/tests/unit/test_a2a.py @@ -51,7 +51,7 @@ async def test_rejects_non_http_url() -> None: async def test_unreachable_raises() -> None: respx.get("http://host:10000/.well-known/agent-card.json").mock(side_effect=httpx.ConnectError("refused")) respx.get("http://host:10000/.well-known/agent.json").mock(side_effect=httpx.ConnectError("refused")) - with pytest.raises(AgentCardError, match="could not reach"): + with pytest.raises(AgentCardError, match="Could not fetch a valid A2A agent card"): await fetch_agent_card("http://host:10000") diff --git a/plugins/nemo-agents/tests/unit/test_agents_api.py b/plugins/nemo-agents/tests/unit/test_agents_api.py index ea4dd8cb98..31164faead 100644 --- a/plugins/nemo-agents/tests/unit/test_agents_api.py +++ b/plugins/nemo-agents/tests/unit/test_agents_api.py @@ -282,6 +282,14 @@ def test_neither_config_nor_url_returns_422(self, client: TestClient, mock_entit ) assert resp.status_code == 422 + @pytest.mark.parametrize("bad_url", ["http://[", "not-a-url", "ftp://host", "http://"]) + def test_malformed_url_returns_422(self, client: TestClient, mock_entity_client: AsyncMock, bad_url: str) -> None: + resp = client.post( + "/apis/agents/v2/workspaces/default/agents", + json={"name": "ext", "url": bad_url}, + ) + assert resp.status_code == 422 + # --------------------------------------------------------------------------- # GET /agents — list (NemoListResponse envelope) diff --git a/plugins/nemo-agents/tests/unit/test_gateway.py b/plugins/nemo-agents/tests/unit/test_gateway.py index bfe4ff6822..a9f85beab0 100644 --- a/plugins/nemo-agents/tests/unit/test_gateway.py +++ b/plugins/nemo-agents/tests/unit/test_gateway.py @@ -782,12 +782,28 @@ def test_missing_input_message_returns_400( assert resp.status_code == 400 - def test_non_generate_path_returns_400(self, client: TestClient, mock_entity_client: AsyncMock) -> None: + def test_chat_completions_via_proxy_bridges_to_a2a( + self, client: TestClient, mock_entity_client: AsyncMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + # The CLI `nemo agents invoke` and SDK POST /-/v1/chat/completions — the + # external branch must bridge that (OpenAI chat), not just generate. mock_entity_client.get = AsyncMock(return_value=_make_external_agent()) + monkeypatch.setattr(gateway_module, "send_a2a_message", AsyncMock(return_value="4")) resp = client.post( "/apis/agents/v2/workspaces/default/agents/ext/-/v1/chat/completions", - json={"input_message": "hi"}, + json={"messages": [{"role": "user", "content": "2+2?"}]}, + ) + + assert resp.status_code == 200 + assert resp.json()["choices"][0]["message"]["content"] == "4" + + def test_unsupported_path_returns_400(self, client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get = AsyncMock(return_value=_make_external_agent()) + + resp = client.post( + "/apis/agents/v2/workspaces/default/agents/ext/-/health", + json={}, ) assert resp.status_code == 400 diff --git a/web/packages/studio/src/components/dataViews/AgentsDataView/index.test.tsx b/web/packages/studio/src/components/dataViews/AgentsDataView/index.test.tsx index c44ad302f9..56e4b2a3d5 100644 --- a/web/packages/studio/src/components/dataViews/AgentsDataView/index.test.tsx +++ b/web/packages/studio/src/components/dataViews/AgentsDataView/index.test.tsx @@ -109,9 +109,11 @@ describe('CombinedAgentsTable', () => { renderTable(); expect( - await screen.findByText('No Agents Found', undefined, { timeout: XL_SELECTOR_TIMEOUT }) + await screen.findByText('No agents yet', undefined, { timeout: XL_SELECTOR_TIMEOUT }) + ).toBeInTheDocument(); + expect( + screen.getByText(/Register an existing NAT agent by its endpoint URL/) ).toBeInTheDocument(); - expect(screen.getByText('No agents have been created yet.')).toBeInTheDocument(); }); }); diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentWorkflowContent.tsx b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentWorkflowContent.tsx index 2351f11ed1..2cce5f5114 100644 --- a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentWorkflowContent.tsx +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentWorkflowContent.tsx @@ -4,6 +4,7 @@ import type { Agent } from '@nemo/sdk/generated/agents/schema/Agent'; import { Accordion, Badge, Block, Flex, Stack, Text } from '@nvidia/foundations-react-core'; import type { AgentConfig } from '@studio/components/dataViews/AgentsDataView'; +import { redactSecrets } from '@studio/components/sidePanels/AgentPanels/AgentPanel/redactSecrets'; import { summarizeAgentCard } from '@studio/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentCard'; import { summarizeAgentWorkflow } from '@studio/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow'; import { Globe, Workflow, Wrench } from 'lucide-react'; @@ -32,7 +33,7 @@ export const AgentWorkflowContent: FC = ({ agent }) = let yamlText = ''; try { - yamlText = YAML.stringify(config); + yamlText = YAML.stringify(redactSecrets(config)); } catch { yamlText = 'Unable to render config as YAML.'; } @@ -171,7 +172,7 @@ const ExternalAgentWorkflow: FC<{ agent: Agent }> = ({ agent }) => { slotContent: (
-                  {JSON.stringify(agent.card ?? {}, null, 2)}
+                  {JSON.stringify(redactSecrets(agent.card ?? {}), null, 2)}
                 
), diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/redactSecrets.test.ts b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/redactSecrets.test.ts new file mode 100644 index 0000000000..adbd22e7ff --- /dev/null +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/redactSecrets.test.ts @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { redactSecrets } from '@studio/components/sidePanels/AgentPanels/AgentPanel/redactSecrets'; + +describe('redactSecrets', () => { + it('masks credential-like keys anywhere in the tree', () => { + expect( + redactSecrets({ + llms: { llm: { _type: 'openai', model_name: 'gpt-4o', api_key: 'sk-secret' } }, + auth: { token: 'abc', bearer_secret: 'xyz' }, + password: 'hunter2', + }) + ).toEqual({ + llms: { llm: { _type: 'openai', model_name: 'gpt-4o', api_key: '***' } }, + auth: { token: '***', bearer_secret: '***' }, + password: '***', + }); + }); + + it('leaves non-secret values and structure intact', () => { + const input = { workflow: { _type: 'react_agent', tool_names: ['a', 'b'] } }; + expect(redactSecrets(input)).toEqual(input); + }); + + it('handles arrays and primitives', () => { + expect(redactSecrets([{ api_key: 'k' }, 'plain'])).toEqual([{ api_key: '***' }, 'plain']); + expect(redactSecrets(undefined)).toBeUndefined(); + }); +}); diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/redactSecrets.ts b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/redactSecrets.ts new file mode 100644 index 0000000000..d2be4ce782 --- /dev/null +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/redactSecrets.ts @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const SECRET_KEY = /(api[_-]?key|token|secret|password|credential|authorization)/i; +const MASK = '***'; + +/** + * Deep-copy a config/card object with credential-like values masked, so the + * Workflow tab never renders inline secrets (e.g. `llms.*.api_key`) to anyone + * who can view the agent. Only scalar values under a secret-looking key are + * masked; structure is preserved. + */ +export const redactSecrets = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(redactSecrets); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record).map(([key, val]) => { + if (SECRET_KEY.test(key) && (typeof val === 'string' || typeof val === 'number')) { + return [key, MASK]; + } + return [key, redactSecrets(val)]; + }) + ); + } + return value; +}; diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/useAgentPanel.ts b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/useAgentPanel.ts index 11b0d8f241..527a4fd933 100644 --- a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/useAgentPanel.ts +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/useAgentPanel.ts @@ -6,7 +6,7 @@ import { useToast } from '@nemo/common/src/providers/toast/useToast'; import { getAgentsListDeploymentsQueryKey, useAgentsDeleteDeployment, - useAgentsListAgents, + useAgentsGetAgent, useAgentsListDeployments, } from '@nemo/sdk/generated/agents/api'; import { RECENT_EVAL_LIMIT } from '@studio/components/sidePanels/AgentPanels/AgentPanel/constants'; @@ -28,7 +28,10 @@ export const useAgentPanel = ({ const queryClient = useQueryClient(); const toast = useToast(); - const { data: agentsResponse } = useAgentsListAgents(workspace, undefined, { + // Fetch the specific agent directly — a list query only returns the first + // page, so an agent selected from a later table page would resolve to + // undefined and be mis-rendered as managed. + const { data: agent } = useAgentsGetAgent(workspace, agentName ?? '', { query: { enabled: !!agentName }, }); @@ -54,7 +57,6 @@ export const useAgentPanel = ({ } ); - const agentsData = agentsResponse?.data; const deploymentsData = deploymentsResponse?.data; // Recent evaluations targeting this agent. The platform's job filter API @@ -80,7 +82,6 @@ export const useAgentPanel = ({ }, }); - const agent = agentName ? (agentsData ?? []).find((a) => a.name === agentName) : undefined; const agentDeployments = useMemo( () => (deploymentsData ?? []).filter((d) => d.agent === agentName), [deploymentsData, agentName] diff --git a/web/packages/studio/src/mocks/handlers.ts b/web/packages/studio/src/mocks/handlers.ts index 88b4d9dc9a..eba79424dc 100644 --- a/web/packages/studio/src/mocks/handlers.ts +++ b/web/packages/studio/src/mocks/handlers.ts @@ -622,6 +622,39 @@ export const handlers = [ }); } ), + http.get( + `${PLATFORM_BASE_URL}/apis/agents/v2/workspaces/:workspace/agents/:name`, + ({ params }) => { + const name = String(params['name']); + if (name !== 'react-agent' && name !== 'react-agent2') { + return new HttpResponse(null, { status: 404 }); + } + return HttpResponse.json({ + name, + workspace: params['workspace'], + description: name === 'react-agent2' ? 'Second react agent' : '', + source: 'managed', + config: { + functions: { wiki: { _type: 'wiki_search' }, clock: { _type: 'current_datetime' } }, + llms: { + llm: { + _type: 'openai', + api_key: 'not-used', + model_name: 'meta-llama-3-1-70b-instruct', + temperature: 0, + }, + }, + workflow: { + _type: 'react_agent', + tool_names: ['wiki', 'clock'], + llm_name: 'llm', + }, + }, + config_format: 'nat-workflow-v1', + created_at: '2026-04-20T10:00:00Z', + }); + } + ), http.delete( `${PLATFORM_BASE_URL}/apis/agents/v2/workspaces/:workspace/agents/:name`, () => new HttpResponse(null, { status: 204 }) diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/type.ts b/web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/type.ts index 09a7a05f4f..a728770003 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/type.ts +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/type.ts @@ -4,10 +4,23 @@ import type { FormModalProps } from '@nemo/common/src/components/FormModal'; import { z } from 'zod'; +const isHttpUrl = (value: string): boolean => { + try { + const parsed = new URL(value); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +}; + export const registerAgentFormSchema = z.object({ name: z.string().min(1, 'Name is required'), description: z.string().optional(), - url: z.string().trim().min(1, 'Endpoint URL is required'), + url: z + .string() + .trim() + .min(1, 'Endpoint URL is required') + .refine(isHttpUrl, 'Enter a valid http(s) URL, e.g. http://localhost:10000'), }); export type RegisterAgentFormData = z.infer; diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/index.test.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/index.test.tsx index 67e8ef2662..81fe7970a5 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/index.test.tsx +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/index.test.tsx @@ -48,7 +48,9 @@ const renderList = () => }); const openModal = async (user: ReturnType): Promise => { - await user.click(await screen.findByRole('button', { name: 'Create Example Agent' })); + // The empty state repeats the header CTA, so there can be two identical + // buttons; click the header one (rendered first). + await user.click((await screen.findAllByRole('button', { name: 'Create Example Agent' }))[0]); const dialog = await screen.findByRole('dialog'); await within(dialog).findByRole('combobox', { name: 'Model' }); return dialog; From e99a2c963018bd43a2c744628965aaa66b5c5b20 Mon Sep 17 00:00:00 2001 From: mschwab Date: Thu, 16 Jul 2026 23:28:34 -0700 Subject: [PATCH 08/11] fix(agents): bound A2A stream by raw bytes, not line count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stream_a2a_message capped bytes via aiter_lines(), which buffers a line internally until a newline — a newline-less body would grow unbounded before the per-line check ran. Parse SSE lines from a byte-capped buffer over aiter_bytes() instead, so total bytes are bounded regardless of newlines. Add a test that a newline-less body trips the cap. Signed-off-by: mschwab --- .../nemo-agents/src/nemo_agents_plugin/a2a.py | 44 +++++++++++-------- plugins/nemo-agents/tests/unit/test_a2a.py | 12 +++++ 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py b/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py index 67be0d91a8..adfcf09bf9 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py @@ -243,26 +243,34 @@ async def stream_a2a_message(endpoint: str, text: str) -> AsyncIterator[str]: async with client.stream("POST", endpoint, json=payload, headers={"Accept": "text/event-stream"}) as resp: if resp.status_code != 200: raise A2AMessageError(f"agent returned HTTP {resp.status_code}") + # Cap raw bytes, not aiter_lines() output: aiter_lines buffers a + # line internally until a newline, so a newline-less body would + # grow unbounded before any per-line check. Split lines ourselves + # from a byte-capped buffer instead. total = 0 - async for line in resp.aiter_lines(): - total += len(line) + buffer = "" + async for chunk in resp.aiter_bytes(): + total += len(chunk) if total > _MAX_MESSAGE_BYTES: raise A2AMessageError("agent stream exceeded the size limit") - line = line.strip() - if not line or line.startswith(":"): # keepalive / comment - continue - if not line.startswith("data:"): - continue - try: - data = json.loads(line[len("data:") :].strip()) - except ValueError: - continue - if isinstance(data, dict) and data.get("error"): - err = data["error"] - msg = err.get("message") if isinstance(err, dict) else str(err) - raise A2AMessageError(f"agent error: {msg}") - delta = extract_stream_delta(data.get("result") if isinstance(data, dict) else None) - if delta: - yield delta + buffer += chunk.decode("utf-8", errors="replace") + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if not line or line.startswith(":"): # keepalive / comment + continue + if not line.startswith("data:"): + continue + try: + data = json.loads(line[len("data:") :].strip()) + except ValueError: + continue + if isinstance(data, dict) and data.get("error"): + err = data["error"] + msg = err.get("message") if isinstance(err, dict) else str(err) + raise A2AMessageError(f"agent error: {msg}") + delta = extract_stream_delta(data.get("result") if isinstance(data, dict) else None) + if delta: + yield delta except httpx.HTTPError as exc: raise A2AMessageError(f"could not reach agent at {endpoint} ({exc.__class__.__name__})") from exc diff --git a/plugins/nemo-agents/tests/unit/test_a2a.py b/plugins/nemo-agents/tests/unit/test_a2a.py index fbb7854b27..ce037e022b 100644 --- a/plugins/nemo-agents/tests/unit/test_a2a.py +++ b/plugins/nemo-agents/tests/unit/test_a2a.py @@ -169,3 +169,15 @@ async def test_stream_jsonrpc_error_raises() -> None: ) with pytest.raises(A2AMessageError, match="kaboom"): _ = [d async for d in stream_a2a_message("http://host:10000/", "hi")] + + +@pytest.mark.asyncio +@respx.mock +async def test_stream_caps_newlineless_body(monkeypatch: pytest.MonkeyPatch) -> None: + # A newline-less body must trip the byte cap rather than buffer unbounded. + monkeypatch.setattr("nemo_agents_plugin.a2a._MAX_MESSAGE_BYTES", 1024) + respx.post("http://host:10000/").mock( + return_value=httpx.Response(200, text="x" * 5000, headers={"content-type": "text/event-stream"}) + ) + with pytest.raises(A2AMessageError, match="size limit"): + _ = [d async for d in stream_a2a_message("http://host:10000/", "hi")] From 064c89197703a8e65f648401e616cf3b19a25899 Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 17 Jul 2026 00:22:22 -0700 Subject: [PATCH 09/11] feat(agents): external agent card refresh + reachability badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External agent cards are captured once at registration, and an external endpoint can go down invisibly (looks live until you try to chat). Add: - POST /agents/{name}/refresh — re-fetch the A2A card and update the stored copy - GET /agents/{name}/reachability — lightweight liveness probe (well-known path, short timeout, never raises) - Studio: a reachability badge (polled) + "Refresh card" button on the external agent's detail panel (panel-only — a per-row table probe would hammer the backend with N outbound calls) Backend: probe_agent_reachable util + AgentReachability model + route/probe tests. Reuses CREATE/READ perms (no new perm → no OPA policy change). Signed-off-by: mschwab --- plugins/nemo-agents/openapi/openapi.yaml | 82 +++++++++++++++++++ .../nemo-agents/src/nemo_agents_plugin/a2a.py | 26 ++++++ .../src/nemo_agents_plugin/api/v2/agents.py | 61 +++++++++++++- .../src/nemo_agents_plugin/schema.py | 6 ++ plugins/nemo-agents/tests/unit/test_a2a.py | 17 ++++ .../nemo-agents/tests/unit/test_agents_api.py | 67 +++++++++++++++ .../AgentPanel/AgentDetailsContent.tsx | 14 ++-- .../AgentPanel/ExternalAgentStatus.tsx | 76 +++++++++++++++++ 8 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ExternalAgentStatus.tsx diff --git a/plugins/nemo-agents/openapi/openapi.yaml b/plugins/nemo-agents/openapi/openapi.yaml index 9c06a83671..e4bdbf0778 100644 --- a/plugins/nemo-agents/openapi/openapi.yaml +++ b/plugins/nemo-agents/openapi/openapi.yaml @@ -158,6 +158,77 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /apis/agents/v2/workspaces/{workspace}/agents/{name}/reachability: + get: + tags: + - Agents + summary: External Agent Reachability + description: Liveness probe for an external agent's endpoint (for a health badge). + operationId: external_agent_reachability_apis_agents_v2_workspaces__workspace__agents__name__reachability_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AgentReachability' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/agents/v2/workspaces/{workspace}/agents/{name}/refresh: + post: + tags: + - Agents + summary: Refresh External Agent + description: 'Re-fetch an external agent''s A2A card and update the stored copy. + + + The card is captured once at registration; this refreshes it (e.g. after the + + agent''s skills change). Returns 502 if the agent can''t be reached.' + operationId: refresh_external_agent_apis_agents_v2_workspaces__workspace__agents__name__refresh_post + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Agent' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /apis/agents/v2/workspaces/{workspace}/deployments: post: tags: @@ -2503,6 +2574,17 @@ components: \ ``agent_deployment``\nLifecycle: pending \u2192 starting \u2192 running\ \ | failed.\nThe :class:`~nemo_agents_plugin.runner.controller.AgentDeploymentController`\n\ drives state transitions by reconciling this entity against the\n:class:`~nemo_agents_plugin.runner.backend.RunnerBackend`." + AgentReachability: + properties: + reachable: + type: boolean + title: Reachable + description: Whether the external agent's endpoint answered a liveness probe. + type: object + required: + - reachable + title: AgentReachability + description: Response for ``GET /v2/workspaces/{workspace}/agents/{name}/reachability``. AnalyzeBatchConfig: properties: batch: diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py b/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py index adfcf09bf9..023dea2277 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py @@ -35,6 +35,9 @@ # Agents can take a while to answer; allow more headroom than card discovery. _MESSAGE_TIMEOUT_S = 120.0 +# Liveness probe is a quick "is the well-known path answering" check. +_PROBE_TIMEOUT_S = 5.0 + # Cap message/stream bodies too — replies are larger than cards but still bounded, # so one hostile endpoint can't OOM the shared gateway worker. _MAX_MESSAGE_BYTES = 8 * 1024 * 1024 @@ -126,6 +129,29 @@ def _endpoint_host(url: str) -> str: return "unknown" +async def probe_agent_reachable(base_url: str) -> bool: + """Cheap liveness check: does the agent's well-known card path answer 200? + + Short timeout, no redirects, no body parsing — just a reachability signal for + the UI. Never raises; returns False on any failure. + """ + base = base_url.strip() + if not base.startswith(("http://", "https://")): + return False + try: + async with httpx.AsyncClient(timeout=_PROBE_TIMEOUT_S) as client: + for path in AGENT_CARD_PATHS: + try: + resp = await client.get(_card_url(base, path)) + except httpx.HTTPError: + continue + if resp.status_code == 200: + return True + except httpx.HTTPError: + return False + return False + + def _collect_text_parts(parts: Any) -> list[str]: """Pull the text out of an A2A ``parts`` array, ignoring non-text parts.""" out: list[str] = [] diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/agents.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/agents.py index a8193da427..7513bc0ae5 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/agents.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/agents.py @@ -13,7 +13,7 @@ import logging from fastapi import APIRouter, Depends, HTTPException, Query -from nemo_agents_plugin.a2a import AgentCardError, fetch_agent_card +from nemo_agents_plugin.a2a import AgentCardError, fetch_agent_card, probe_agent_reachable from nemo_agents_plugin.api.v2._perms import AgentPerms from nemo_agents_plugin.api.v2.dependencies import get_entity_client from nemo_agents_plugin.authz import scope @@ -21,6 +21,7 @@ from nemo_agents_plugin.schema import ( AgentFilter, AgentPage, + AgentReachability, CreateAgentRequest, ) from nemo_platform_plugin.api.filters import make_filter_obj_dep @@ -93,6 +94,64 @@ async def create_agent( return saved +async def _get_external_agent(name: str, workspace: str, entity_client: NemoEntitiesClient) -> Agent: + """Fetch an agent and require it to be external, else raise 404/400.""" + try: + agent = await entity_client.get(Agent, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException(status_code=404, detail=f"Agent '{name}' not found in workspace '{workspace}'.") from exc + if agent.source != "external": + raise HTTPException(status_code=400, detail=f"Agent '{name}' is not an external agent.") + return agent + + +@router.post("/agents/{name}/refresh", response_model=Agent, tags=["Agents"]) +@scope.write +@path_rule( + callers=[CallerKind.PRINCIPAL], + permissions=[AgentPerms.CREATE], +) +async def refresh_external_agent( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> Agent: + """Re-fetch an external agent's A2A card and update the stored copy. + + The card is captured once at registration; this refreshes it (e.g. after the + agent's skills change). Returns 502 if the agent can't be reached. + """ + agent = await _get_external_agent(name, workspace, entity_client) + try: + card = await fetch_agent_card(agent.endpoint) + except AgentCardError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + agent.card = card + try: + saved = await entity_client.update(agent) + except Exception as exc: + logger.exception("Failed to refresh external agent '%s'", name) + raise HTTPException(status_code=500, detail="Failed to refresh external agent.") from exc + return saved + + +@router.get("/agents/{name}/reachability", response_model=AgentReachability, tags=["Agents"]) +@scope.read +@path_rule( + callers=[CallerKind.PRINCIPAL], + permissions=[AgentPerms.READ], +) +async def external_agent_reachability( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> AgentReachability: + """Liveness probe for an external agent's endpoint (for a health badge).""" + agent = await _get_external_agent(name, workspace, entity_client) + return AgentReachability(reachable=await probe_agent_reachable(agent.endpoint)) + + @router.get("/agents", response_model=AgentPage, tags=["Agents"]) @scope.read @path_rule( diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/schema.py b/plugins/nemo-agents/src/nemo_agents_plugin/schema.py index 5a8dd755fe..e356ad32ec 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/schema.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/schema.py @@ -69,6 +69,12 @@ def _require_config_xor_url(self) -> CreateAgentRequest: return self +class AgentReachability(BaseModel): + """Response for ``GET /v2/workspaces/{workspace}/agents/{name}/reachability``.""" + + reachable: bool = Field(description="Whether the external agent's endpoint answered a liveness probe.") + + class CreateDeploymentRequest(BaseModel): """Request body for ``POST /v2/workspaces/{workspace}/deployments``.""" diff --git a/plugins/nemo-agents/tests/unit/test_a2a.py b/plugins/nemo-agents/tests/unit/test_a2a.py index ce037e022b..e1e205b6e0 100644 --- a/plugins/nemo-agents/tests/unit/test_a2a.py +++ b/plugins/nemo-agents/tests/unit/test_a2a.py @@ -16,6 +16,7 @@ extract_message_text, extract_stream_delta, fetch_agent_card, + probe_agent_reachable, send_a2a_message, stream_a2a_message, ) @@ -68,6 +69,22 @@ async def test_non_card_json_rejected() -> None: await fetch_agent_card("http://host:10000") +@pytest.mark.asyncio +@respx.mock +async def test_probe_reachable_true_on_200() -> None: + respx.get("http://host:10000/.well-known/agent-card.json").mock(return_value=httpx.Response(200, json=CARD)) + assert await probe_agent_reachable("http://host:10000") is True + + +@pytest.mark.asyncio +@respx.mock +async def test_probe_reachable_false_on_non_200_and_unreachable() -> None: + respx.get("http://down:1/.well-known/agent-card.json").mock(return_value=httpx.Response(503)) + respx.get("http://down:1/.well-known/agent.json").mock(side_effect=httpx.ConnectError("refused")) + assert await probe_agent_reachable("http://down:1") is False + assert await probe_agent_reachable("ftp://nope") is False + + class TestExtractMessageText: def test_message_parts(self) -> None: result = {"kind": "message", "parts": [{"kind": "text", "text": "hello"}]} diff --git a/plugins/nemo-agents/tests/unit/test_agents_api.py b/plugins/nemo-agents/tests/unit/test_agents_api.py index 31164faead..89c4bae466 100644 --- a/plugins/nemo-agents/tests/unit/test_agents_api.py +++ b/plugins/nemo-agents/tests/unit/test_agents_api.py @@ -496,3 +496,70 @@ def test_delete_server_error_on_delete_returns_500(self, client: TestClient, moc resp = client.delete("/apis/agents/v2/workspaces/default/agents/calc") assert resp.status_code == 500 + + +def _make_external(name: str = "ext", url: str = "http://host:10000") -> Agent: + a = Agent(name=name, workspace="default", source="external", endpoint=url, card={"name": "old"}) + a._id = f"agent-{name}-id" + return a + + +class TestRefreshExternalAgent: + _URL = "/apis/agents/v2/workspaces/default/agents/ext/refresh" + + def test_refresh_updates_card( + self, client: TestClient, mock_entity_client: AsyncMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + mock_entity_client.get = AsyncMock(return_value=_make_external()) + monkeypatch.setattr(agents_router_module, "fetch_agent_card", AsyncMock(return_value={"name": "new"})) + mock_entity_client.update = AsyncMock(side_effect=lambda a: a) + + resp = client.post(self._URL) + + assert resp.status_code == 200 + assert resp.json()["card"]["name"] == "new" + + def test_refresh_managed_returns_400(self, client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get = AsyncMock(return_value=_make_agent("calc")) + resp = client.post("/apis/agents/v2/workspaces/default/agents/calc/refresh") + assert resp.status_code == 400 + + def test_refresh_not_found_returns_404(self, client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get = AsyncMock(side_effect=NemoEntityNotFoundError("nope")) + resp = client.post(self._URL) + assert resp.status_code == 404 + + def test_refresh_unreachable_returns_502( + self, client: TestClient, mock_entity_client: AsyncMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + from nemo_agents_plugin.a2a import AgentCardError + + mock_entity_client.get = AsyncMock(return_value=_make_external()) + monkeypatch.setattr(agents_router_module, "fetch_agent_card", AsyncMock(side_effect=AgentCardError("down"))) + resp = client.post(self._URL) + assert resp.status_code == 502 + + +class TestExternalAgentReachability: + _URL = "/apis/agents/v2/workspaces/default/agents/ext/reachability" + + @pytest.mark.parametrize("reachable", [True, False]) + def test_reachability( + self, + client: TestClient, + mock_entity_client: AsyncMock, + monkeypatch: pytest.MonkeyPatch, + reachable: bool, + ) -> None: + mock_entity_client.get = AsyncMock(return_value=_make_external()) + monkeypatch.setattr(agents_router_module, "probe_agent_reachable", AsyncMock(return_value=reachable)) + + resp = client.get(self._URL) + + assert resp.status_code == 200 + assert resp.json()["reachable"] is reachable + + def test_reachability_managed_returns_400(self, client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get = AsyncMock(return_value=_make_agent("calc")) + resp = client.get("/apis/agents/v2/workspaces/default/agents/calc/reachability") + assert resp.status_code == 400 diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx index 50316343b8..8b60d138ea 100644 --- a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx @@ -18,6 +18,7 @@ import { } from '@nvidia/foundations-react-core'; import type { AgentConfig } from '@studio/components/dataViews/AgentsDataView'; import { getAgentModelNames } from '@studio/components/dataViews/AgentsDataView/utils'; +import { ExternalAgentStatus } from '@studio/components/sidePanels/AgentPanels/AgentPanel/ExternalAgentStatus'; import { deploymentStatusColor } from '@studio/components/sidePanels/AgentPanels/AgentPanel/helpers'; import { NoHealthyDeploymentsBanner } from '@studio/components/sidePanels/AgentPanels/AgentPanel/NoHealthyDeploymentsBanner'; import type { WalkthroughStep } from '@studio/components/sidePanels/AgentPanels/AgentPanel/walkthrough'; @@ -92,11 +93,14 @@ export const AgentDetailsContent: FC = ({
)} - {isExternal && ( - - Runs outside NeMo Platform. Deploy is unavailable; evaluation runs against the agent's - endpoint. - + {isExternal && agentName && ( + + + + Runs outside NeMo Platform. Deploy is unavailable; evaluation runs against the + agent's endpoint. + + )}
diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ExternalAgentStatus.tsx b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ExternalAgentStatus.tsx new file mode 100644 index 0000000000..6af32155de --- /dev/null +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ExternalAgentStatus.tsx @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useToast } from '@nemo/common/src/providers/toast/useToast'; +import { + getAgentsExternalAgentReachabilityQueryKey, + getAgentsGetAgentQueryKey, + useAgentsExternalAgentReachability, + useAgentsRefreshExternalAgent, +} from '@nemo/sdk/generated/agents/api'; +import { Button, Flex, StatusIndicator, Text } from '@nvidia/foundations-react-core'; +import { getErrorMessage } from '@studio/api/common/utils'; +import { useQueryClient } from '@tanstack/react-query'; +import { RotateCw } from 'lucide-react'; +import type { FC } from 'react'; + +const REACHABILITY_POLL_MS = 30_000; + +interface ExternalAgentStatusProps { + workspace: string; + agentName: string; +} + +/** + * Reachability badge + card-refresh for an external agent. The card is captured + * once at registration, so refresh re-fetches it; the badge polls a lightweight + * liveness probe so a dead endpoint isn't invisible until you try to chat. + */ +export const ExternalAgentStatus: FC = ({ workspace, agentName }) => { + const toast = useToast(); + const queryClient = useQueryClient(); + + const { data: reachability, isLoading } = useAgentsExternalAgentReachability( + workspace, + agentName, + { query: { enabled: !!agentName, refetchInterval: REACHABILITY_POLL_MS } } + ); + + const { mutate: refresh, isPending } = useAgentsRefreshExternalAgent({ + mutation: { + onSuccess: () => { + toast.success('Agent card refreshed'); + void queryClient.invalidateQueries({ + queryKey: getAgentsGetAgentQueryKey(workspace, agentName), + }); + void queryClient.invalidateQueries({ + queryKey: getAgentsExternalAgentReachabilityQueryKey(workspace, agentName), + }); + }, + onError: (error) => + toast.error(getErrorMessage(error as Error, 'Failed to refresh agent card')), + }, + }); + + const status = isLoading ? 'checking' : reachability?.reachable ? 'reachable' : 'unreachable'; + const label = + status === 'checking' ? 'Checking…' : status === 'reachable' ? 'Reachable' : 'Unreachable'; + const color = status === 'reachable' ? 'green' : status === 'unreachable' ? 'red' : 'yellow'; + + return ( + + + + {label} + + + + ); +}; From e9b5b0d4eab2ae00785651fe4304aee2f744717a Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 17 Jul 2026 00:39:26 -0700 Subject: [PATCH 10/11] refactor(agents): centralize external-agent predicate + notice #3: add is_external_agent(agent) (backend, mirrors is_container_deployment_mode) and isExternalAgent() (frontend helper); replace the 4 backend + 6 frontend inline `source == "external"` / `source === 'external'` checks so the next AgentSource value only touches one predicate. #6: extract the external-agent logs notice into a reusable ExternalAgentNotice component + a shared EXTERNAL_AGENT_HEADING constant, so the "runs outside NeMo Platform" phrasing lives in one place. Signed-off-by: mschwab --- .../src/nemo_agents_plugin/api/v2/agents.py | 4 +- .../nemo_agents_plugin/api/v2/deployments.py | 9 +++- .../src/nemo_agents_plugin/api/v2/gateway.py | 11 ++-- .../src/nemo_agents_plugin/entities.py | 10 ++++ .../dataViews/AgentsDataView/index.tsx | 54 +++++++++---------- .../AgentPanel/AgentDetailsContent.tsx | 3 +- .../AgentPanel/AgentWorkflowContent.tsx | 3 +- .../AgentPanel/ExternalAgentNotice.tsx | 22 ++++++++ .../AgentPanels/AgentPanel/index.tsx | 27 ++++------ web/packages/studio/src/util/agents.ts | 10 ++++ 10 files changed, 99 insertions(+), 54 deletions(-) create mode 100644 web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ExternalAgentNotice.tsx create mode 100644 web/packages/studio/src/util/agents.ts diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/agents.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/agents.py index 7513bc0ae5..bf82ac1f22 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/agents.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/agents.py @@ -17,7 +17,7 @@ from nemo_agents_plugin.api.v2._perms import AgentPerms from nemo_agents_plugin.api.v2.dependencies import get_entity_client from nemo_agents_plugin.authz import scope -from nemo_agents_plugin.entities import Agent, AgentDeployment +from nemo_agents_plugin.entities import Agent, AgentDeployment, is_external_agent from nemo_agents_plugin.schema import ( AgentFilter, AgentPage, @@ -100,7 +100,7 @@ async def _get_external_agent(name: str, workspace: str, entity_client: NemoEnti agent = await entity_client.get(Agent, name=name, workspace=workspace) except NemoEntityNotFoundError as exc: raise HTTPException(status_code=404, detail=f"Agent '{name}' not found in workspace '{workspace}'.") from exc - if agent.source != "external": + if not is_external_agent(agent): raise HTTPException(status_code=400, detail=f"Agent '{name}' is not an external agent.") return agent diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py index 87dbc51c03..ba5f12a895 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py @@ -23,7 +23,12 @@ from nemo_agents_plugin.api.v2._perms import DeploymentPerms from nemo_agents_plugin.api.v2.dependencies import get_entity_client from nemo_agents_plugin.authz import scope -from nemo_agents_plugin.entities import Agent, AgentDeployment, is_container_deployment_mode +from nemo_agents_plugin.entities import ( + Agent, + AgentDeployment, + is_container_deployment_mode, + is_external_agent, +) from nemo_agents_plugin.schema import ( CreateDeploymentRequest, DeploymentFilter, @@ -72,7 +77,7 @@ async def create_deployment( # External agents run outside NeMo Platform; there is nothing for the # platform to deploy. Reject rather than spawn a doomed empty-config process. - if agent.source == "external": + if is_external_agent(agent): raise HTTPException( status_code=400, detail=f"Agent '{body.agent}' is external and runs outside NeMo Platform; it cannot be deployed.", diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py index 8cac385066..6b1bf78009 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py @@ -39,7 +39,12 @@ from nemo_agents_plugin.api.v2._perms import GatewayPerms from nemo_agents_plugin.api.v2.dependencies import get_entity_client from nemo_agents_plugin.authz import scope -from nemo_agents_plugin.entities import Agent, AgentDeployment, is_container_deployment_mode +from nemo_agents_plugin.entities import ( + Agent, + AgentDeployment, + is_container_deployment_mode, + is_external_agent, +) from nemo_platform_plugin.authz import CallerKind, path_rule from nemo_platform_plugin.entity_client import NemoEntitiesClient, NemoEntityNotFoundError @@ -142,7 +147,7 @@ async def _serve_agent_proxy( except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc - if agent.source == "external": + if is_external_agent(agent): return await _serve_external_agent(name, trailing_uri, request, agent) endpoint = await _resolve_agent_endpoint(name, workspace, entity_client) @@ -547,7 +552,7 @@ async def external_agent_chat_completions( except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc - if agent.source != "external": + if not is_external_agent(agent): raise HTTPException( status_code=400, detail=f"Agent '{name}' is managed; chat through its deployment, not this endpoint.", diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/entities.py b/plugins/nemo-agents/src/nemo_agents_plugin/entities.py index 9c825a7c82..933eb426cd 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/entities.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/entities.py @@ -152,6 +152,16 @@ class Agent(NemoEntity, entity_type="agent"): ) +def is_external_agent(agent: Agent) -> bool: + """Return True when *agent* runs outside NeMo Platform (``source == 'external'``). + + The single predicate for the managed/external branch — mirrors + :func:`is_container_deployment_mode` so consumers don't reimplement the + string comparison inline. + """ + return agent.source == "external" + + class AgentDeployment(NemoEntity, entity_type="agent_deployment"): """A running (or pending) deployment of an Agent. diff --git a/web/packages/studio/src/components/dataViews/AgentsDataView/index.tsx b/web/packages/studio/src/components/dataViews/AgentsDataView/index.tsx index e1f5912cd9..5b06b7688e 100644 --- a/web/packages/studio/src/components/dataViews/AgentsDataView/index.tsx +++ b/web/packages/studio/src/components/dataViews/AgentsDataView/index.tsx @@ -33,6 +33,7 @@ import { MODEL_COMPARE_ENABLED } from '@studio/constants/environment'; import { LINK_DOCS_AGENTS } from '@studio/constants/links'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { getModelCompareRoute } from '@studio/routes/utils'; +import { isExternalAgent } from '@studio/util/agents'; import { keepPreviousData, useQueryClient } from '@tanstack/react-query'; import { HatGlasses, Trash, X } from 'lucide-react'; import { ComponentProps, FC, useEffect, useMemo, useState } from 'react'; @@ -259,7 +260,7 @@ export const AgentsTable: FC = ({ cell: ({ row }) => ( {row.original.name} - {row.original.source === 'external' && External} + {isExternalAgent(row.original) && External} ), }), @@ -299,33 +300,32 @@ export const AgentsTable: FC = ({ enableResizing: false, rowActions: (row: AgentTableRow) => { // External agents run outside NeMo Platform — deploy/test/clone don't apply. - const managedActions = - row.source === 'external' - ? [] - : [ - { - children: 'Deploy', - onSelect: () => onCreateDeployment?.(row.name), - }, - ...(canTestModels - ? [ - { - children: 'Test models', - onSelect: () => { - const target = getModelCompareRoute(workspace); - const model = row.models[0]; - const urn = model ? `${row.workspace}/${model}` : null; - navigate(urn ? `${target}?model=${encodeURIComponent(urn)}` : target); - }, + const managedActions = isExternalAgent(row) + ? [] + : [ + { + children: 'Deploy', + onSelect: () => onCreateDeployment?.(row.name), + }, + ...(canTestModels + ? [ + { + children: 'Test models', + onSelect: () => { + const target = getModelCompareRoute(workspace); + const model = row.models[0]; + const urn = model ? `${row.workspace}/${model}` : null; + navigate(urn ? `${target}?model=${encodeURIComponent(urn)}` : target); }, - ] - : []), - { - children: 'Clone', - onSelect: () => onCloneAgent?.(row), - }, - { kind: 'divider' as const }, - ]; + }, + ] + : []), + { + children: 'Clone', + onSelect: () => onCloneAgent?.(row), + }, + { kind: 'divider' as const }, + ]; return [ ...managedActions, { diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx index 8b60d138ea..1e6d23dbe7 100644 --- a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx @@ -24,6 +24,7 @@ import { NoHealthyDeploymentsBanner } from '@studio/components/sidePanels/AgentP import type { WalkthroughStep } from '@studio/components/sidePanels/AgentPanels/AgentPanel/walkthrough'; import type { AgentEvalJob } from '@studio/routes/agents/AgentEvaluationsRoute/api'; import { getAgentEvaluationDetailRoute, getAgentEvaluationsListRoute } from '@studio/routes/utils'; +import { isExternalAgent } from '@studio/util/agents'; import type { FC, RefObject } from 'react'; import { Link } from 'react-router-dom'; @@ -58,7 +59,7 @@ export const AgentDetailsContent: FC = ({ onSwitchToChat, onDeleteDeployment, }) => { - const isExternal = agent?.source === 'external'; + const isExternal = isExternalAgent(agent); return ( diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentWorkflowContent.tsx b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentWorkflowContent.tsx index 2cce5f5114..cae1d34ea3 100644 --- a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentWorkflowContent.tsx +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentWorkflowContent.tsx @@ -7,6 +7,7 @@ import type { AgentConfig } from '@studio/components/dataViews/AgentsDataView'; import { redactSecrets } from '@studio/components/sidePanels/AgentPanels/AgentPanel/redactSecrets'; import { summarizeAgentCard } from '@studio/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentCard'; import { summarizeAgentWorkflow } from '@studio/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow'; +import { isExternalAgent } from '@studio/util/agents'; import { Globe, Workflow, Wrench } from 'lucide-react'; import type { FC } from 'react'; import YAML from 'yaml'; @@ -16,7 +17,7 @@ interface AgentWorkflowContentProps { } export const AgentWorkflowContent: FC = ({ agent }) => { - if (agent?.source === 'external') { + if (agent && isExternalAgent(agent)) { return ; } diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ExternalAgentNotice.tsx b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ExternalAgentNotice.tsx new file mode 100644 index 0000000000..5a641a0469 --- /dev/null +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ExternalAgentNotice.tsx @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Block, Stack, Text } from '@nvidia/foundations-react-core'; +import { Globe } from 'lucide-react'; +import type { FC, ReactNode } from 'react'; + +/** Shared heading for external-agent empty states, so the phrasing lives in one place. */ +export const EXTERNAL_AGENT_HEADING = 'This agent runs outside NeMo Platform'; + +/** Centered notice used where an external agent has no NMP-managed surface (e.g. Logs). */ +export const ExternalAgentNotice: FC<{ detail: ReactNode }> = ({ detail }) => ( + + + + {EXTERNAL_AGENT_HEADING} + + {detail} + + + +); diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/index.tsx b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/index.tsx index 3b8a7b2a00..882495b6ae 100644 --- a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/index.tsx +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/index.tsx @@ -2,12 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 import type { AgentDeployment } from '@nemo/sdk/generated/agents/schema/AgentDeployment'; -import { Block, SegmentedControl, SidePanel, Stack, Text } from '@nvidia/foundations-react-core'; +import { Block, SegmentedControl, SidePanel } from '@nvidia/foundations-react-core'; import { DeleteConfirmationModal } from '@studio/components/DeleteConfirmationModal'; import { AgentDetailsContent } from '@studio/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent'; import { AgentWorkflowContent } from '@studio/components/sidePanels/AgentPanels/AgentPanel/AgentWorkflowContent'; import { ChatPlaygroundContent } from '@studio/components/sidePanels/AgentPanels/AgentPanel/ChatPlaygroundContent'; import { DeploymentLogsView } from '@studio/components/sidePanels/AgentPanels/AgentPanel/DeploymentLogsView'; +import { ExternalAgentNotice } from '@studio/components/sidePanels/AgentPanels/AgentPanel/ExternalAgentNotice'; import type { AgentPanelTab } from '@studio/components/sidePanels/AgentPanels/AgentPanel/types'; import { useAgentPanel } from '@studio/components/sidePanels/AgentPanels/AgentPanel/useAgentPanel'; import { deriveWalkthroughStep } from '@studio/components/sidePanels/AgentPanels/AgentPanel/walkthrough'; @@ -18,7 +19,7 @@ import { } from '@studio/components/sidePanels/AgentPanels/AgentPanel/walkthroughStorage'; import { CreateDeploymentModal } from '@studio/routes/agents/AgentDeploymentsListRoute/CreateDeploymentModal'; import { SubmitEvaluationModal } from '@studio/routes/agents/AgentEvaluationsRoute/components/SubmitEvaluationModal'; -import { Globe } from 'lucide-react'; +import { isExternalAgent } from '@studio/util/agents'; import { type ComponentProps, type FC, useEffect, useMemo, useRef, useState } from 'react'; export type { AgentPanelTab }; @@ -113,21 +114,11 @@ export const AgentPanel: FC = ({ let content: React.ReactNode; if (selectedTab === 'deployment-logs') { - content = - agent?.source === 'external' ? ( - - - - This agent runs outside NeMo Platform - - There is no NeMo deployment to read logs from. Logs are available on the host where - the agent runs. - - - - ) : ( - - ); + content = isExternalAgent(agent) ? ( + + ) : ( + + ); } else if (selectedTab === 'agent-workflow') { content = ; } else if (selectedTab === 'chat-playground') { @@ -139,7 +130,7 @@ export const AgentPanel: FC = ({ healthyDeployments={healthyDeployments} isDeploymentsLoading={isDeploymentsLoading} isDeploying={isDeploying} - isExternal={agent?.source === 'external'} + isExternal={isExternalAgent(agent)} chatAreaRef={chatAreaRef} onSelectDeployment={(v) => setSelectedDeploymentName(v)} onDeploy={() => setCreateDeploymentOpen(true)} diff --git a/web/packages/studio/src/util/agents.ts b/web/packages/studio/src/util/agents.ts new file mode 100644 index 0000000000..9028516e1e --- /dev/null +++ b/web/packages/studio/src/util/agents.ts @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Single predicate for the managed/external agent branch, so callers don't + * repeat the `source === 'external'` string comparison. Accepts anything with a + * `source` field (the SDK `Agent` or a table row). + */ +export const isExternalAgent = (agent?: { source?: string | null }): boolean => + agent?.source === 'external'; From dc40c4d33c7b770e3c413a3520a8369a067fb725 Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 17 Jul 2026 00:45:07 -0700 Subject: [PATCH 11/11] feat(agents): multi-turn chat for external agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat was single-turn — only the latest user message reached the agent, so it had no memory across turns. NAT's A2A executor is message-only (no server-side contextId memory), so threading contextId wouldn't help; instead fold the OpenAI conversation history (which the client sends each turn) into the A2A message. A single turn is sent verbatim; multi-turn becomes a transcript of prior user/assistant turns followed by the latest user message. Signed-off-by: mschwab --- .../src/nemo_agents_plugin/api/v2/gateway.py | 53 ++++++++++++------- .../nemo-agents/tests/unit/test_gateway.py | 22 ++++++-- 2 files changed, 53 insertions(+), 22 deletions(-) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py index 6b1bf78009..741e6a86a9 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py @@ -451,28 +451,43 @@ async def _buffered() -> AsyncIterator[bytes]: # client asked to stream). Managed agents keep using the deployment proxy above. -def _latest_user_text(messages: Any) -> str: - """Return the text of the last user message in an OpenAI messages array. +def _message_text(message: Any) -> str: + """Extract text from one OpenAI message (``content`` may be str or parts).""" + if not isinstance(message, dict): + return "" + content = message.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + return "".join(part["text"] for part in content if isinstance(part, dict) and isinstance(part.get("text"), str)) + return "" + + +def _conversation_prompt(messages: Any) -> str: + """Build a single A2A message that carries the conversation so far. - ``content`` may be a plain string or an array of content parts; join text parts. + NAT's A2A executor is message-only (no server-side ``contextId`` memory), so + multi-turn context can't be threaded via the protocol — the client (OpenAI + chat) sends the full history each turn, and we fold prior user/assistant + turns into a transcript ahead of the latest user message. A single turn is + sent verbatim so simple agents aren't handed a transcript wrapper. """ if not isinstance(messages, list): return "" - for message in reversed(messages): - if not isinstance(message, dict) or message.get("role") != "user": - continue - content = message.get("content") - if isinstance(content, str): - return content - if isinstance(content, list): - parts: list[str] = [] - for part in content: - if isinstance(part, dict): - text = part.get("text") - if isinstance(text, str): - parts.append(text) - return "".join(parts) - return "" + turns = [ + (m.get("role"), _message_text(m)) + for m in messages + if isinstance(m, dict) and m.get("role") in ("user", "assistant") + ] + turns = [(role, text) for role, text in turns if text] + if not turns: + return "" + if len(turns) == 1: + return turns[0][1] + + *prior, (_, latest) = turns + transcript = "\n".join(f"{'User' if role == 'user' else 'Assistant'}: {text}" for role, text in prior) + return f"Continue this conversation.\n\n{transcript}\n\nUser: {latest}" def _openai_completion(text: str, model: str) -> dict[str, Any]: @@ -603,7 +618,7 @@ async def _read_json_object(request: Request) -> dict[str, Any]: async def _external_openai_chat(name: str, endpoint: str, body: dict[str, Any]) -> StreamingResponse | JSONResponse: """Translate an OpenAI chat/completions body to A2A and return OpenAI shape.""" - text = _latest_user_text(body.get("messages")) + text = _conversation_prompt(body.get("messages")) if not text: raise HTTPException(status_code=400, detail="Request has no user message to send.") diff --git a/plugins/nemo-agents/tests/unit/test_gateway.py b/plugins/nemo-agents/tests/unit/test_gateway.py index a9f85beab0..501f323b9e 100644 --- a/plugins/nemo-agents/tests/unit/test_gateway.py +++ b/plugins/nemo-agents/tests/unit/test_gateway.py @@ -693,13 +693,27 @@ async def boom(endpoint: str, text: str): assert "external agent error" in resp.text assert "[DONE]" in resp.text - def test_forwards_latest_user_text( + def test_single_turn_forwards_verbatim( self, client: TestClient, mock_entity_client: AsyncMock, monkeypatch: pytest.MonkeyPatch ) -> None: mock_entity_client.get = AsyncMock(return_value=_make_external_agent()) send = AsyncMock(return_value="ok") monkeypatch.setattr(gateway_module, "send_a2a_message", send) + client.post(self._URL, json={"messages": [{"role": "user", "content": "just this"}]}) + + assert send.await_args is not None + assert send.await_args.args[1] == "just this" + + def test_multi_turn_folds_history_into_transcript( + self, client: TestClient, mock_entity_client: AsyncMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + # NAT's A2A executor is message-only, so prior turns are folded into the + # message rather than threaded via contextId. + mock_entity_client.get = AsyncMock(return_value=_make_external_agent()) + send = AsyncMock(return_value="ok") + monkeypatch.setattr(gateway_module, "send_a2a_message", send) + client.post( self._URL, json={ @@ -711,9 +725,11 @@ def test_forwards_latest_user_text( }, ) - send.assert_awaited_once() assert send.await_args is not None - assert send.await_args.args[1] == "second" + sent = send.await_args.args[1] + assert "User: first" in sent + assert "Assistant: reply" in sent + assert "User: second" in sent def test_managed_agent_returns_400(self, client: TestClient, mock_entity_client: AsyncMock) -> None: mock_entity_client.get = AsyncMock(return_value=_make_agent("calc"))