diff --git a/plugins/nemo-agents/openapi/openapi.yaml b/plugins/nemo-agents/openapi/openapi.yaml index 5e53975db4..e4bdbf0778 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 @@ -154,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: @@ -2281,7 +2356,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 +2364,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 @@ -2476,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: @@ -2654,24 +2763,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..023dea2277 --- /dev/null +++ b/plugins/nemo-agents/src/nemo_agents_plugin/a2a.py @@ -0,0 +1,302 @@ +# 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 + +import json +import logging +from collections.abc import AsyncIterator +from typing import Any +from urllib.parse import urljoin +from uuid import uuid4 + +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") + +# 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 + +# 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 + + +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.""" + + +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. + 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.") + + # 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: + 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"transport error ({exc.__class__.__name__})" + continue + try: + card = json.loads(raw) + except ValueError: + 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 + + 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" + + +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] = [] + 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: + 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 + + try: + data = json.loads(raw) + 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) + + +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}") + # 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 + buffer = "" + async for chunk in resp.aiter_bytes(): + total += len(chunk) + if total > _MAX_MESSAGE_BYTES: + raise A2AMessageError("agent stream exceeded the size limit") + 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/src/nemo_agents_plugin/api/v2/agents.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/agents.py index 06cb89091d..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 @@ -13,13 +13,15 @@ import logging from fastapi import APIRouter, Depends, HTTPException, Query +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 -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, + AgentReachability, CreateAgentRequest, ) from nemo_platform_plugin.api.filters import make_filter_obj_dep @@ -50,14 +52,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: @@ -71,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 not is_external_agent(agent): + 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/api/v2/deployments.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py index d2c674ee2a..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, @@ -70,6 +75,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 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.", + ) + # 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/api/v2/gateway.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py index 99a0f485f2..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 @@ -27,16 +27,24 @@ 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, 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 -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 @@ -124,12 +132,24 @@ async def _serve_agent_proxy( trailing_uri: str, request: Request, entity_client: NemoEntitiesClient, -) -> StreamingResponse: - """Find the first ``running`` deployment for the named agent and forward the request to it. +) -> StreamingResponse | JSONResponse: + """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 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) + 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 is_external_agent(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) @@ -139,6 +159,7 @@ async def _serve_agent_proxy( methods=_PROXY_READ_METHODS, tags=["Agent Gateway"], include_in_schema=False, + response_model=None, ) @scope.read @path_rule( @@ -151,7 +172,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) @@ -161,6 +182,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( @@ -173,7 +195,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) @@ -416,3 +438,242 @@ 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 _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. + + 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 "" + 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]: + 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", + } + ], + } + + +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}" + 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" + + +@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 + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + if not is_external_agent(agent): + raise HTTPException( + status_code=400, + detail=f"Agent '{name}' is managed; chat through its deployment, not this endpoint.", + ) + + 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. + + 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 = _conversation_prompt(body.get("messages")) + 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(endpoint, text, model), media_type="text/event-stream") + + # Non-streaming: single message/send, returned as one OpenAI completion. + try: + 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)) + + +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_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(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/entities.py b/plugins/nemo-agents/src/nemo_agents_plugin/entities.py index 2cec22d610..933eb426cd 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,37 @@ 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." + ), + ) + + +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"): diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/schema.py b/plugins/nemo-agents/src/nemo_agents_plugin/schema.py index 2f372d6f85..e356ad32ec 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/schema.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/schema.py @@ -21,10 +21,11 @@ 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 -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator # --------------------------------------------------------------------------- # Request bodies — plain BaseModel, named by convention @@ -32,12 +33,46 @@ 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.") + 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 + + +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): 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..e1e205b6e0 --- /dev/null +++ b/plugins/nemo-agents/tests/unit/test_a2a.py @@ -0,0 +1,200 @@ +# 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 json + +import httpx +import pytest +import respx +from nemo_agents_plugin.a2a import ( + A2AMessageError, + AgentCardError, + extract_message_text, + extract_stream_delta, + fetch_agent_card, + probe_agent_reachable, + send_a2a_message, + stream_a2a_message, +) + +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 fetch a valid A2A agent card"): + 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") + + +@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"}]} + 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") + + +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")] + + +@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")] diff --git a/plugins/nemo-agents/tests/unit/test_agents_api.py b/plugins/nemo-agents/tests/unit/test_agents_api.py index cc29f30dd4..89c4bae466 100644 --- a/plugins/nemo-agents/tests/unit/test_agents_api.py +++ b/plugins/nemo-agents/tests/unit/test_agents_api.py @@ -193,6 +193,104 @@ 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 + + @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) # --------------------------------------------------------------------------- @@ -398,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/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/plugins/nemo-agents/tests/unit/test_gateway.py b/plugins/nemo-agents/tests/unit/test_gateway.py index 5ea7ba8e70..501f323b9e 100644 --- a/plugins/nemo-agents/tests/unit/test_gateway.py +++ b/plugins/nemo-agents/tests/unit/test_gateway.py @@ -615,3 +615,211 @@ 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_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()) + + 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, + json={"stream": True, "messages": [{"role": "user", "content": "weather?"}]}, + ) + + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/event-stream") + 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_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={ + "messages": [ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "reply"}, + {"role": "user", "content": "second"}, + ] + }, + ) + + assert send.await_args is not None + 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")) + + 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 + + +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_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={"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/dataViews/AgentsDataView/index.tsx b/web/packages/studio/src/components/dataViews/AgentsDataView/index.tsx index 48e4341be2..5b06b7688e 100644 --- a/web/packages/studio/src/components/dataViews/AgentsDataView/index.tsx +++ b/web/packages/studio/src/components/dataViews/AgentsDataView/index.tsx @@ -25,14 +25,15 @@ 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 { 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'; @@ -73,6 +74,8 @@ export type AgentTableRow = { description?: string; config?: AgentConfig; config_format?: string; + source?: string; + endpoint?: string; created_at?: string; models: string[]; deploymentsStatus: string; @@ -92,6 +95,8 @@ export interface CombinedAgentsTableProps { onCreateDeployment?: (agentName: string) => void; onCloneAgent?: (agent: AgentTableRow) => void; onAgentsLoaded?: (agents: Agent[]) => void; + onRegisterAgent?: () => void; + onCreateExampleAgent?: () => void; canTestModels?: boolean; } @@ -100,6 +105,8 @@ export const AgentsTable: FC = ({ onCreateDeployment, onCloneAgent, onAgentsLoaded, + onRegisterAgent, + onCreateExampleAgent, canTestModels = MODEL_COMPARE_ENABLED, }) => { const workspace = useWorkspaceFromPath(); @@ -169,6 +176,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 +257,12 @@ export const AgentsTable: FC = ({ accessor('name', { header: 'Name', enableSorting: true, + cell: ({ row }) => ( + + {row.original.name} + {isExternalAgent(row.original) && External} + + ), }), accessor('description', { header: 'Description', @@ -283,35 +298,43 @@ export const AgentsTable: FC = ({ rowActionsColumn({ size: ROW_ACTIONS_COLUMN_SIZE, enableResizing: false, - rowActions: (row: AgentTableRow) => [ - { - children: 'Deploy', - onSelect: () => onCreateDeployment?.(row.name), - }, - ...(canTestModels - ? [ + rowActions: (row: AgentTableRow) => { + // External agents run outside NeMo Platform — deploy/test/clone don't apply. + 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: '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..1e6d23dbe7 100644 --- a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx @@ -18,11 +18,13 @@ 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'; 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'; @@ -56,176 +58,205 @@ export const AgentDetailsContent: FC = ({ onDeploy, onSwitchToChat, onDeleteDeployment, -}) => ( - - - - {agentName} - {isDefined(agent?.description) && agent.description && ( - - {agent.description} - - )} - - -
- -
-
-
-
- + + + )} + + {isExternal && agentName && ( - - - {isDefined(agent?.description) && ( - - )} - {(() => { - const models = getAgentModelNames(agent?.config as AgentConfig | undefined); - return models.length > 0 ? ( - - ) : null; - })()} - {isDefined(agent?.config_format) && ( - - )} + + + Runs outside NeMo Platform. Deploy is unavailable; evaluation runs against the + agent's endpoint. + - ), - 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 ? ( + )} +
+ + - 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..cae1d34ea3 --- /dev/null +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentWorkflowContent.tsx @@ -0,0 +1,185 @@ +// 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 { 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'; + +interface AgentWorkflowContentProps { + agent?: Agent; +} + +export const AgentWorkflowContent: FC = ({ agent }) => { + if (agent && isExternalAgent(agent)) { + 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(redactSecrets(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(redactSecrets(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..57bbbe81e6 100644 --- a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ChatPlaygroundContent.tsx +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ChatPlaygroundContent.tsx @@ -15,6 +15,7 @@ interface ChatPlaygroundContentProps { healthyDeployments: AgentDeployment[]; isDeploymentsLoading: boolean; isDeploying: boolean; + isExternal?: boolean; chatAreaRef: RefObject; onSelectDeployment: (name: string) => void; onDeploy: () => void; @@ -27,6 +28,7 @@ export const ChatPlaygroundContent: FC = ({ healthyDeployments, isDeploymentsLoading, isDeploying, + isExternal, chatAreaRef, onSelectDeployment, onDeploy, @@ -43,6 +45,28 @@ export const ChatPlaygroundContent: FC = ({ ); const noHealthyDeployments = !isDeploymentsLoading && healthyDeployments.length === 0; + // 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 ( +
+ + + +
+ ); + } + return (
{!noHealthyDeployments && healthyDeployments.length > 1 && ( @@ -54,7 +78,7 @@ export const ChatPlaygroundContent: FC = ({ /> )} - {noHealthyDeployments && ( + {noHealthyDeployments ? ( = ({ onDeploy={onDeploy} /> + ) : ( + + + )} - - -
); }; 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/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} + + + + ); +}; 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..882495b6ae 100644 --- a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/index.tsx +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/index.tsx @@ -5,8 +5,10 @@ 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 { 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'; @@ -17,6 +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 { isExternalAgent } from '@studio/util/agents'; import { type ComponentProps, type FC, useEffect, useMemo, useRef, useState } from 'react'; export type { AgentPanelTab }; @@ -59,6 +62,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' }, ], @@ -110,7 +114,13 @@ export const AgentPanel: FC = ({ let content: React.ReactNode; if (selectedTab === 'deployment-logs') { - content = ; + content = isExternalAgent(agent) ? ( + + ) : ( + + ); + } else if (selectedTab === 'agent-workflow') { + content = ; } else if (selectedTab === 'chat-playground') { content = ( = ({ healthyDeployments={healthyDeployments} isDeploymentsLoading={isDeploymentsLoading} isDeploying={isDeploying} + isExternal={isExternalAgent(agent)} chatAreaRef={chatAreaRef} onSelectDeployment={(v) => setSelectedDeploymentName(v)} onDeploy={() => setCreateDeploymentOpen(true)} 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/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/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/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/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/index.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/index.tsx new file mode 100644 index 0000000000..9d53106817 --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/index.tsx @@ -0,0 +1,128 @@ +// 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 { 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 { Block, Text } from '@nvidia/foundations-react-core'; +import { getErrorMessage } from '@studio/api/common/utils'; +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 } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; + +const DEFAULT_VALUES: RegisterAgentFormData = { name: '', description: '', url: '' }; + +export const RegisterAgentModal: FC = ({ open, onClose, workspace }) => { + const toast = useToast(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [submitError, setSubmitError] = 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 resetAndClose = () => { + resetMutation(); + setSubmitError(undefined); + resetForm(DEFAULT_VALUES); + onClose(); + }; + + const onSubmit: SubmitHandler = async (formData) => { + setSubmitError(undefined); + try { + await createAgent({ + workspace, + data: { + name: formData.name.trim(), + description: formData.description?.trim() || '', + url: formData.url.trim(), + }, + }); + } catch { + // surfaced via errorText + } + }; + + const errorMessage = + submitError ?? + (createError ? getErrorMessage(createError as Error, 'Failed to register agent') : undefined); + + return ( + + + + + + 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 new file mode 100644 index 0000000000..a728770003 --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/type.ts @@ -0,0 +1,30 @@ +// 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'; + +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') + .refine(isHttpUrl, 'Enter a valid http(s) URL, e.g. http://localhost:10000'), +}); + +export type RegisterAgentFormData = z.infer; + +export interface RegisterAgentModalProps extends Pick { + workspace: string; +} 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; 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)} 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';