Skip to content

feat(agents): register, visualize, chat with, and evaluate external NAT agents#745

Closed
marcusds wants to merge 11 commits into
mainfrom
register-existing-nat-agent/mschwab
Closed

feat(agents): register, visualize, chat with, and evaluate external NAT agents#745
marcusds wants to merge 11 commits into
mainfrom
register-existing-nat-agent/mschwab

Conversation

@marcusds

@marcusds marcusds commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Studio had no way to bring in an existing NAT agent or work with one that runs outside NeMo Platform. This adds a first-class external agent (registered by URL, discovered via its A2A card) and makes it work across the Agents surface — register, visualize, chat, evaluate — with managed-only actions correctly gated.

What's included

Register & visualize

  • Register modal: Connect running agent by endpoint URL → fetches the A2A agent card. (Paste-YAML was scoped out; backend create still accepts config XOR url.)
  • Workflow tab on the agent panel: managed → config graph + YAML (credentials redacted); external → A2A card skills + endpoint.
  • External badge in the Agents table; Source/Endpoint in the details panel.

Entity + unified create

  • Agent gains source (managed|external), endpoint, card.
  • POST /agents takes config XOR url; a url fetches the A2A card (timeout + size-capped) and stores an external agent. No NAT deployment involved.

Chat (A2A bridge, streaming, multi-turn)

  • /agents/{name}/chat/completions (Studio) and the agent-name proxy /-/v1/chat/completions (CLI nemo agents invoke / SDK) bridge OpenAI chat ↔ A2A. Streaming uses message/stream; non-streaming message/send.
  • Multi-turn: NAT's A2A executor is message-only (no contextId memory), so the OpenAI history is folded into the message (single turn verbatim; multi-turn as a transcript).

Evaluate (A2A bridge)

  • nat eval drives agents over NAT /generate; the proxy bridges /generate/full → A2A, so external agents evaluate through the existing nat eval pipeline with unchanged eval configs.

Health & lifecycle

  • POST /agents/{name}/refresh re-fetches the card; GET /agents/{name}/reachability is a liveness probe. Studio shows a reachability badge + Refresh card button on the external agent panel.

Gating

  • External agents can't be deployed/eval-via-deployment (API 400 + UI hidden); Logs/Chat tabs show the right external messaging. Deploy stays managed-only; Evaluate works (via the bridge).

How it works

Dependency-free hand-rolled A2A JSON-RPC (a2a.py): card fetch, message/send, message/stream, liveness probe. The gateway translates OpenAI chat/completions and NAT /generate ↔ A2A. External agents flow through the same agent-name proxy as managed ones, so CLI/eval tooling works unchanged. A single is_external_agent / isExternalAgent predicate drives the managed/external branch.

Testing

  • Backend: ~100 unit tests (card fetch, unified create, deploy guard, chat + generate bridges, streaming/size caps, multi-turn transcript, refresh/reachability). respx- + real-socket-verified. ruff + ty clean.
  • Frontend: unit tests for read models + credential redaction; agent suites green; typecheck (our files) + lint clean.
  • Live E2E against a real nat a2a serve agent: register by URL, chat, and a full nat eval run through the bridge (accuracy 0.79).

Review

Went through Codex + a 3-persona roundtable. Fixed: card.url→vetted endpoint (SSRF/exfil), generic card-fetch error (port-scan oracle), effective size caps, A2A failure logging, CLI/SDK invoke parity, credential redaction in the Workflow tab, agent-panel pagination (external past page 1 mis-rendered), malformed-URL 422, and the broken Studio tests.

Known / follow-ups

  • Auth to external agents: intentionally out of scope — connects unauthenticated agents only. Follow-up would mirror IGW ModelProvider (secret ref + auth-header template).
  • SSRF: the platform is operator-trust everywhere (IGW ModelProvider host_url, evaluator BYO URLs all connect unguarded), so no per-feature guard was added; a private-IP policy would be a platform-wide change.
  • Docs: docs/studio/agents.mdx update pending (follow-up).
  • Python SDK (Stainless): not regenerated (release step; TS/orval SDK is regenerated — the chat/generate bridge routes are include_in_schema=False, refresh/reachability are in-schema).
  • CI note: the web-typecheck job fails on pre-existing errors in intake/eval-session files (Trace.input/output, @axe-core/playwright) authored on main (commit 9215278), untouched by this branch. Clears when main's typecheck is fixed.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added support for registering and managing external agents by endpoint URL.
    • Added agent reachability checks and card refresh actions.
    • External agents now support chat and streaming through compatible endpoints.
    • Studio displays external-agent status, metadata, skills, and workflow details.
    • Added registration flows, external-agent badges, and tailored empty states.
  • Bug Fixes
    • Prevented external agents from being deployed as managed agents.
    • Improved validation and error handling for invalid agent registrations.
  • Tests
    • Added coverage for external-agent registration, connectivity, messaging, deployment restrictions, and Studio displays.

marcusds added 4 commits July 16, 2026 14:22
Studio had no way to bring in an existing NAT agent or see its workflow.
Add register + visualize paths on the Agents surface and a first-class
notion of agents that run outside NeMo Platform.

- Register modal: "Connect running agent" (by URL) or "Paste config" (NAT YAML)
- Onboarding empty state with dual CTA + NAT docs link
- Workflow tab on the agent panel: managed -> config graph + YAML;
  external -> A2A card skills + endpoint
- Agent entity gains source/endpoint/card; POST /agents takes config XOR url
  and fetches the A2A agent card for external agents
- External agents cannot be deployed/evaluated (API 400 + UI gating); chat
  tab shows an external-endpoint message instead of deploy prompts

Backend: a2a.py card fetch (timeout/size-capped), unified create validator,
deploy guard; tests for a2a, create, and deploy paths.

Deferred: A2A chat proxy. Flagged for review: SSRF on server-side card fetch,
Python SDK (Stainless) regen.

Signed-off-by: mschwab <mschwab@nvidia.com>
External agents run outside NeMo Platform, so there is no NMP deployment to
read logs from (NAT serve/A2A exposes no logs endpoint). Show an explanatory
message on the Logs tab instead of an empty deployment log viewer, matching
the Deploy/Chat gating.

Signed-off-by: mschwab <mschwab@nvidia.com>
Studio's chat playground can now talk to a registered external agent. The
gateway exposes POST /agents/{name}/chat/completions for external agents: it
accepts an OpenAI chat/completions request, forwards the latest user turn to
the agent over A2A message/send (JSON-RPC, no A2A client dependency), and
returns the reply in OpenAI shape (SSE when the client streams).

- a2a.py: send_a2a_message + extract_message_text (Task/Message parts)
- gateway.py: external-only chat route with OpenAI<->A2A translation; 400 managed
- ChatPlaygroundContent: external agents render ModelChat against the bridge
- tests: extract/send (respx + real socket) and route (stream, non-stream,
  latest-user forwarding, 400/404/502)

MVP: single-turn (latest user message; no server-side context_id) and no auth
to the external agent. SSRF on the outbound call flagged for review.

Signed-off-by: mschwab <mschwab@nvidia.com>
nat eval drives an agent over NAT's /generate protocol; external agents expose
only A2A, so System-A eval couldn't reach them. Bridge /generate/full -> A2A
message/send inside the agent-name gateway proxy, so external agents evaluate
through the existing nat eval pipeline with unchanged eval configs.

- gateway.py: _serve_agent_proxy branches external -> _serve_external_generate,
  translating {"input_message": q} -> A2A message/send and emitting the
  data: {"value": ...} line nat eval reads
- AgentDetailsContent: un-gate Evaluate + Recent Evaluations for external;
  Deploy stays gated
- tests: generate bridge (full path, missing input_message, non-generate path)

Eval config is unchanged (agent-agnostic; agent comes from --endpoint).

Signed-off-by: mschwab <mschwab@nvidia.com>
@github-actions github-actions Bot added the feat label Jul 16, 2026
marcusds added 7 commits July 16, 2026 18:08
External chat used non-streaming message/send + one buffered SSE chunk, so the
UI froze until the agent finished. The streaming path now uses A2A
message/stream and forwards each text delta as an OpenAI chat.completion.chunk:
token-streaming agents show progress, buffering agents (e.g. NAT react_agent)
still send one final chunk. Non-streaming requests keep using message/send.

- a2a.py: stream_a2a_message (SSE JSON-RPC) + extract_stream_delta
- gateway.py: streaming path emits OpenAI chunks from A2A deltas; a mid-stream
  error is surfaced as content since the HTTP status is already 200
- tests: stream delta extraction, delta forwarding, jsonrpc + mid-stream errors

Signed-off-by: mschwab <mschwab@nvidia.com>
Simplify the Register Existing Agent modal to endpoint-URL only for now: remove
the "Paste config" YAML tab, the mode toggle, and the now-unused
parseAgentConfig helper + test. Update the empty-state copy to match. Backend
create still accepts config XOR url; this is a UI scope reduction only.

Signed-off-by: mschwab <mschwab@nvidia.com>
Codex + roundtable review of the external-agent feature. Fixes:

Security / robustness (backend, a2a.py + gateway.py):
- Invoke external agents at the vetted registered endpoint, never the card's
  self-reported url (remote-controlled → SSRF/exfil target)
- Collapse card-fetch errors to one generic message (distinct errors were an
  internal port-scan oracle); log the specific reason server-side
- Cap card + message + stream response bytes with an incremental read (the old
  cap ran after the body was fully buffered); log A2A failures
- Chat route catches Exception -> 500 like its siblings
- Reject structurally invalid endpoint URLs in the create schema (http://[ 500)

CLI/SDK parity:
- Bridge /agents/{name}/-/v1/chat/completions (nemo agents invoke / SDK) to A2A,
  not just /generate — external agents were unreachable via the standard path

Frontend:
- Redact api_key/token/secret in the Workflow tab config/card view
- Resolve the panel agent via GET /agents/{name} not a page-1 list scan
  (external agents past page 1 were mis-rendered as managed)
- Validate the endpoint URL in the register form
- Repair Studio tests broken by the dual-CTA empty state + copy change; add a
  GET-by-name mock handler

Tests: a2a stream/size, chat-via-proxy bridge, malformed-URL 422, redactSecrets.
Signed-off-by: mschwab <mschwab@nvidia.com>
stream_a2a_message capped bytes via aiter_lines(), which buffers a line
internally until a newline — a newline-less body would grow unbounded before
the per-line check ran. Parse SSE lines from a byte-capped buffer over
aiter_bytes() instead, so total bytes are bounded regardless of newlines.
Add a test that a newline-less body trips the cap.

Signed-off-by: mschwab <mschwab@nvidia.com>
External agent cards are captured once at registration, and an external
endpoint can go down invisibly (looks live until you try to chat). Add:

- POST /agents/{name}/refresh — re-fetch the A2A card and update the stored copy
- GET /agents/{name}/reachability — lightweight liveness probe (well-known path,
  short timeout, never raises)
- Studio: a reachability badge (polled) + "Refresh card" button on the external
  agent's detail panel (panel-only — a per-row table probe would hammer the
  backend with N outbound calls)

Backend: probe_agent_reachable util + AgentReachability model + route/probe
tests. Reuses CREATE/READ perms (no new perm → no OPA policy change).

Signed-off-by: mschwab <mschwab@nvidia.com>
#3: add is_external_agent(agent) (backend, mirrors is_container_deployment_mode)
and isExternalAgent() (frontend helper); replace the 4 backend + 6 frontend
inline `source == "external"` / `source === 'external'` checks so the next
AgentSource value only touches one predicate.

#6: extract the external-agent logs notice into a reusable ExternalAgentNotice
component + a shared EXTERNAL_AGENT_HEADING constant, so the "runs outside NeMo
Platform" phrasing lives in one place.

Signed-off-by: mschwab <mschwab@nvidia.com>
Chat was single-turn — only the latest user message reached the agent, so it
had no memory across turns. NAT's A2A executor is message-only (no server-side
contextId memory), so threading contextId wouldn't help; instead fold the
OpenAI conversation history (which the client sends each turn) into the A2A
message. A single turn is sent verbatim; multi-turn becomes a transcript of
prior user/assistant turns followed by the latest user message.

Signed-off-by: mschwab <mschwab@nvidia.com>
@marcusds
marcusds marked this pull request as ready for review July 17, 2026 17:29
@marcusds
marcusds requested review from a team as code owners July 17, 2026 17:29
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

External-agent support adds A2A discovery and messaging, dual-mode agent creation, refresh and reachability APIs, gateway bridging for OpenAI/NAT requests, deployment restrictions, and Studio registration, status, workflow, and chat interfaces.

Changes

External agent platform flow

Layer / File(s) Summary
Agent contracts and models
plugins/nemo-agents/openapi/openapi.yaml, plugins/nemo-agents/src/nemo_agents_plugin/entities.py, plugins/nemo-agents/src/nemo_agents_plugin/schema.py
Agent schemas now distinguish managed and external sources, store endpoint/card metadata, validate config/url exclusivity, and expose reachability responses.
A2A discovery and messaging
plugins/nemo-agents/src/nemo_agents_plugin/a2a.py, plugins/nemo-agents/tests/unit/test_a2a.py
A2A card discovery, reachability probing, JSON-RPC messaging, SSE streaming, text extraction, size limits, and associated tests are added.
External agent lifecycle API
plugins/nemo-agents/src/nemo_agents_plugin/api/v2/agents.py, plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py, plugins/nemo-agents/tests/unit/test_agents_api.py, plugins/nemo-agents/tests/unit/test_deployments_api.py
External agents can be created, refreshed, and probed; deployment creation rejects external agents, with API error-path coverage.
Gateway A2A bridging
plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py, plugins/nemo-agents/tests/unit/test_gateway.py
External requests are translated between OpenAI/NAT formats and A2A responses, including streaming and error handling.

Studio external-agent experience

Layer / File(s) Summary
Studio registration and list views
web/packages/studio/src/components/dataViews/AgentsDataView/*, web/packages/studio/src/routes/agents/AgentsListRoute/*, web/packages/studio/src/util/agents.ts
The agent list marks external agents, limits their actions, updates empty-state actions, and adds a validated registration modal.
Studio external-agent details
web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/*, web/packages/studio/src/constants/links.ts, web/packages/studio/src/mocks/handlers.ts
Agent panels show reachability and refresh controls, external metadata and cards, workflow summaries, external chat routing, and managed-only panel behavior.

Sequence Diagram(s)

sequenceDiagram
  participant Studio
  participant AgentsAPI
  participant ExternalAgent
  Studio->>AgentsAPI: Register agent with endpoint URL
  AgentsAPI->>ExternalAgent: Fetch A2A card
  ExternalAgent-->>AgentsAPI: Card metadata
  AgentsAPI-->>Studio: External Agent
  Studio->>AgentsAPI: Poll reachability or refresh card
  AgentsAPI->>ExternalAgent: Probe or fetch card
  ExternalAgent-->>AgentsAPI: Reachability/card response
  AgentsAPI-->>Studio: Status or updated card
Loading

Possibly related PRs

Suggested labels: feat

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.42% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: external NAT agent registration, visualization, chat, and evaluation support.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch register-existing-nat-agent/mschwab
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch register-existing-nat-agent/mschwab

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

🧹 Nitpick comments (1)
web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/index.tsx (1)

65-78: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add registration-flow coverage.

Cover valid submission, URL validation, API failure rendering, and navigation after registration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/index.tsx`
around lines 65 - 78, Add registration-flow tests around onSubmit in
RegisterAgentModal covering successful valid submission, invalid URL validation
preventing submission, createAgent API failure rendering through errorText, and
navigation after successful registration. Reuse the existing form and
routing/test utilities, and verify the expected createAgent arguments and
navigation behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@plugins/nemo-agents/openapi/openapi.yaml`:
- Around line 2785-2793: Update the CreateAgentRequest schema to encode the
documented XOR between config and url using oneOf. Add branches that require
config and url respectively, while preserving the existing name requirement and
allowing each valid managed or external agent shape; both fields must fail
validation.

In `@plugins/nemo-agents/src/nemo_agents_plugin/a2a.py`:
- Around line 213-214: The HTTPError handling in A2AMessageError must not expose
the external endpoint; update plugins/nemo-agents/src/nemo_agents_plugin/a2a.py
lines 213-214 to omit endpoint while retaining the exception type context. In
plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py lines 633-637,
return a generic chat 502 and log only sanitized metadata; apply the same
generic generate 502 response and sanitized logging at lines 651-655.
- Around line 276-284: Update the SSE processing loop in the response stream
around resp.aiter_bytes() to buffer raw bytes and split complete lines on b"\n"
before UTF-8 decoding, preserving partial code points across chunks. Decode each
complete line only after splitting, while retaining the existing size-limit
accounting and JSON payload handling.
- Around line 142-149: Update the probe loop in the liveness-check function
around AsyncClient.get so requests use httpx.AsyncClient.stream instead of
buffering the full response body, while preserving HTTPError handling and
returning immediately when resp.status_code equals 200.

In `@plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py`:
- Around line 143-148: Update the exception handling in the proxy lookup and
direct chat route in gateway.py (lines 143-148 and 563-568): log the underlying
entity-client failure server-side, then raise HTTPException with status 500 and
a generic detail instead of exposing str(exc). Preserve the existing 404
handling for NemoEntityNotFoundError.
- Around line 537-542: Update the A2A streaming flow in gateway.py around the
A2A iterator handling to prime the iterator before returning the streaming
response, mapping failures that occur before the first delta to HTTP 502 while
preserving committed-stream error behavior. In
plugins/nemo-agents/tests/unit/test_gateway.py lines 674-694, add coverage
asserting an immediate failure returns 502 and separately verify errors
occurring after streaming has begun retain the existing behavior.
- Around line 668-672: Update the dispatcher around _external_a2a_endpoint so
only POST requests can invoke _external_openai_chat or
_serve_external_generate_full; return an HTTP 405 response for all other methods
before reading the request body or calling either external-agent handler.
- Around line 477-490: Update the conversation-turn handling around
_message_text so the final turn is accepted only when its role is user,
preserving each prior turn’s original User or Assistant label rather than
forcing the latest turn to User. Do not silently discard system messages; handle
them according to the existing conversation contract, while retaining the
current empty-input behavior.

In `@plugins/nemo-agents/src/nemo_agents_plugin/schema.py`:
- Around line 59-66: Implement one shared outbound URL validation policy and
reuse it from _require_config_xor_url and all request paths in
plugins/nemo-agents/src/nemo_agents_plugin/a2a.py at lines 92-96, 142-145,
208-209, and 268-269. Reject userinfo, localhost, private, loopback, link-local,
and other reserved destinations (or enforce the established public/allowlisted
policy), and validate every redirect target before following it. Ensure card
fetches, probes, message requests, and stream requests all apply the same check.

In
`@web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx`:
- Around line 61-77: Update the managed-agent check in AgentDetailsContent so an
undefined agent is treated as loading rather than managed: require agent to be
defined before deriving or using isExternal for managed-only controls such as
Deploy. Preserve the existing external-agent behavior once agent data is loaded.

In
`@web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ExternalAgentStatus.tsx`:
- Around line 33-37: Update ExternalAgentStatus reachability handling to consume
the query's isError state alongside reachability and isLoading. Treat failed
queries as an unknown/check-failed state rather than “Unreachable,” including
the corresponding status rendering and any duplicated logic around the
reachability display.

In
`@web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/redactSecrets.ts`:
- Around line 17-21: Update the secret-key branch in redactSecrets so any value
whose key matches SECRET_KEY is replaced with MASK regardless of its type or
nesting shape; only recurse with redactSecrets for non-sensitive keys.

In
`@web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow.ts`:
- Around line 27-32: Update the model collection in summarizeAgentWorkflow to
include only the configured LLM whose key matches workflow.llm_name, rather than
all entries in config.llms. Preserve the existing filtering of missing
model_name values and ensure workflows without a matching selection produce no
model entry.
- Around line 27-44: Update summarizeAgentWorkflow in
web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow.ts:
resolve workflow.llm_name against config.llms and return only the selected LLM’s
model in models, rather than collecting every configured LLM; preserve the
existing empty/missing-value behavior. Update the deduplication test in
web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow.test.ts
lines 43-51 to configure multiple LLMs and assert that only the model referenced
by workflow.llm_name is returned.

In
`@web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/index.tsx`:
- Around line 65-74: Update the name schema in the RegisterAgent form validation
to trim whitespace before enforcing the minimum length, using
z.string().trim().min(1, 'Name is required'). Keep the existing onSubmit
trimming in place so validated names cannot become empty after submission.

---

Nitpick comments:
In
`@web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/index.tsx`:
- Around line 65-78: Add registration-flow tests around onSubmit in
RegisterAgentModal covering successful valid submission, invalid URL validation
preventing submission, createAgent API failure rendering through errorText, and
navigation after successful registration. Reuse the existing form and
routing/test utilities, and verify the expected createAgent arguments and
navigation behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 71b168c2-5426-419e-b0e2-ee1017eb0a09

📥 Commits

Reviewing files that changed from the base of the PR and between 2412ab7 and dc40c4d.

📒 Files selected for processing (34)
  • plugins/nemo-agents/openapi/openapi.yaml
  • plugins/nemo-agents/src/nemo_agents_plugin/a2a.py
  • plugins/nemo-agents/src/nemo_agents_plugin/api/v2/agents.py
  • plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py
  • plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py
  • plugins/nemo-agents/src/nemo_agents_plugin/entities.py
  • plugins/nemo-agents/src/nemo_agents_plugin/schema.py
  • plugins/nemo-agents/tests/unit/test_a2a.py
  • plugins/nemo-agents/tests/unit/test_agents_api.py
  • plugins/nemo-agents/tests/unit/test_deployments_api.py
  • plugins/nemo-agents/tests/unit/test_gateway.py
  • web/packages/studio/src/components/dataViews/AgentsDataView/index.test.tsx
  • web/packages/studio/src/components/dataViews/AgentsDataView/index.tsx
  • web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx
  • web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentWorkflowContent.tsx
  • web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ChatPlaygroundContent.tsx
  • web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ExternalAgentNotice.tsx
  • web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ExternalAgentStatus.tsx
  • web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/index.tsx
  • web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/redactSecrets.test.ts
  • web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/redactSecrets.ts
  • web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentCard.test.ts
  • web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentCard.ts
  • web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow.test.ts
  • web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow.ts
  • web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/types.ts
  • web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/useAgentPanel.ts
  • web/packages/studio/src/constants/links.ts
  • web/packages/studio/src/mocks/handlers.ts
  • web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/index.tsx
  • web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/type.ts
  • web/packages/studio/src/routes/agents/AgentsListRoute/index.test.tsx
  • web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx
  • web/packages/studio/src/util/agents.ts

Comment on lines 2785 to +2793
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``."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Encode the config/url XOR in OpenAPI.

The constraint is documentation-only, so generated clients accept both invalid shapes. Add oneOf branches requiring config or url; providing both will then fail both-branch exclusivity.

Proposed schema change
       required:
       - name
+      oneOf:
+      - required:
+        - config
+      - required:
+        - url
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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``."
type: object
required:
- name
oneOf:
- required:
- config
- required:
- url
title: CreateAgentRequest
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``."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/nemo-agents/openapi/openapi.yaml` around lines 2785 - 2793, Update
the CreateAgentRequest schema to encode the documented XOR between config and
url using oneOf. Add branches that require config and url respectively, while
preserving the existing name requirement and allowing each valid managed or
external agent shape; both fields must fail validation.

Comment on lines +142 to +149
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file and nearby context.
git ls-files plugins/nemo-agents/src/nemo_agents_plugin/a2a.py
wc -l plugins/nemo-agents/src/nemo_agents_plugin/a2a.py
sed -n '110,180p' plugins/nemo-agents/src/nemo_agents_plugin/a2a.py

# Find any response-size cap logic or related probe helpers.
rg -n "stream\(|response-size|size cap|card|probe|AsyncClient|get\(" plugins/nemo-agents/src/nemo_agents_plugin -S

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 45225


plugins/nemo-agents/src/nemo_agents_plugin/a2a.py:142-149: Stream the probe request. client.get() buffers the full body for a liveness check; use client.stream() and return on resp.status_code == 200.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/nemo-agents/src/nemo_agents_plugin/a2a.py` around lines 142 - 149,
Update the probe loop in the liveness-check function around AsyncClient.get so
requests use httpx.AsyncClient.stream instead of buffering the full response
body, while preserving HTTPError handling and returning immediately when
resp.status_code equals 200.

Comment on lines +213 to +214
except httpx.HTTPError as exc:
raise A2AMessageError(f"could not reach agent at {endpoint} ({exc.__class__.__name__})") from exc

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Prevent external endpoint secrets from reaching clients or logs.

  • plugins/nemo-agents/src/nemo_agents_plugin/a2a.py#L213-L214: remove the raw endpoint from A2AMessageError.
  • plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py#L633-L637: return a generic chat 502 and log only sanitized metadata.
  • plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py#L651-L655: return a generic generate 502 and log only sanitized metadata.
📍 Affects 2 files
  • plugins/nemo-agents/src/nemo_agents_plugin/a2a.py#L213-L214 (this comment)
  • plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py#L633-L637
  • plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py#L651-L655
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/nemo-agents/src/nemo_agents_plugin/a2a.py` around lines 213 - 214,
The HTTPError handling in A2AMessageError must not expose the external endpoint;
update plugins/nemo-agents/src/nemo_agents_plugin/a2a.py lines 213-214 to omit
endpoint while retaining the exception type context. In
plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py lines 633-637,
return a generic chat 502 and log only sanitized metadata; apply the same
generic generate 502 response and sanitized logging at lines 651-655.

Comment on lines +276 to +284
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

sed -n '240,330p' plugins/nemo-agents/src/nemo_agents_plugin/a2a.py
printf '\n--- SEARCH ---\n'
rg -n "aiter_bytes|decode\\(|SSE|event|stream" plugins/nemo-agents/src/nemo_agents_plugin/a2a.py

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 5019


Decode SSE lines after buffering bytes.
aiter_bytes() can split UTF-8 code points across chunks; decoding each chunk with errors="replace" can corrupt data: payloads before json.loads(). Buffer bytes and split on b"\n" first, or use an incremental decoder.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/nemo-agents/src/nemo_agents_plugin/a2a.py` around lines 276 - 284,
Update the SSE processing loop in the response stream around resp.aiter_bytes()
to buffer raw bytes and split complete lines on b"\n" before UTF-8 decoding,
preserving partial code points across chunks. Decode each complete line only
after splitting, while retaining the existing size-limit accounting and JSON
payload handling.

Comment on lines +143 to +148
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Stop exposing entity-client exception details.

  • plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py#L143-L148: log the proxy lookup failure and return a generic 500.
  • plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py#L563-L568: apply the same handling to the direct chat route.
📍 Affects 1 file
  • plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py#L143-L148 (this comment)
  • plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py#L563-L568
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/nemo-agents/src/nemo_agents_plugin/api/v2/gateway.py` around lines
143 - 148, Update the exception handling in the proxy lookup and direct chat
route in gateway.py (lines 143-148 and 563-568): log the underlying
entity-client failure server-side, then raise HTTPException with status 500 and
a generic detail instead of exposing str(exc). Preserve the existing 404
handling for NemoEntityNotFoundError.

Comment on lines +33 to +37
const { data: reachability, isLoading } = useAgentsExternalAgentReachability(
workspace,
agentName,
{ query: { enabled: !!agentName, refetchInterval: REACHABILITY_POLL_MS } }
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not report reachability API failures as “Unreachable.”

After a query error, isLoading is false and reachability is undefined, so the endpoint is incorrectly blamed. Handle isError as an unknown/check-failed state.

Proposed fix
-  const { data: reachability, isLoading } = useAgentsExternalAgentReachability(
+  const { data: reachability, isLoading, isError } = useAgentsExternalAgentReachability(
...
-  const status = isLoading ? 'checking' : reachability?.reachable ? 'reachable' : 'unreachable';
+  const status = isLoading
+    ? 'checking'
+    : isError
+      ? 'unknown'
+      : reachability?.reachable
+        ? 'reachable'
+        : 'unreachable';

Also applies to: 55-58

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ExternalAgentStatus.tsx`
around lines 33 - 37, Update ExternalAgentStatus reachability handling to
consume the query's isError state alongside reachability and isLoading. Treat
failed queries as an unknown/check-failed state rather than “Unreachable,”
including the corresponding status rendering and any duplicated logic around the
reachability display.

Comment on lines +17 to +21
Object.entries(value as Record<string, unknown>).map(([key, val]) => {
if (SECRET_KEY.test(key) && (typeof val === 'string' || typeof val === 'number')) {
return [key, MASK];
}
return [key, redactSecrets(val)];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Mask the entire value under secret-looking keys.

Nested secret containers leak values—for example, { authorization: { value: 'Bearer …' } } renders unchanged. Once the key is sensitive, mask its value regardless of shape.

Proposed fix
-        if (SECRET_KEY.test(key) && (typeof val === 'string' || typeof val === 'number')) {
+        if (SECRET_KEY.test(key)) {
           return [key, MASK];
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Object.entries(value as Record<string, unknown>).map(([key, val]) => {
if (SECRET_KEY.test(key) && (typeof val === 'string' || typeof val === 'number')) {
return [key, MASK];
}
return [key, redactSecrets(val)];
Object.entries(value as Record<string, unknown>).map(([key, val]) => {
if (SECRET_KEY.test(key)) {
return [key, MASK];
}
return [key, redactSecrets(val)];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/redactSecrets.ts`
around lines 17 - 21, Update the secret-key branch in redactSecrets so any value
whose key matches SECRET_KEY is replaced with MASK regardless of its type or
nesting shape; only recurse with redactSecrets for non-sensitive keys.

Comment on lines +27 to +32
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Show only the workflow-selected LLM model.

models currently includes every configured LLM, even though workflow.llm_name selects one. This misrepresents workflows containing auxiliary LLM configurations.

Proposed fix
-  const models = Object.values(config?.llms ?? {})
-    .map((llm) => llm?.model_name)
-    .filter((m): m is string => !!m);
+  const selectedModel = workflow?.llm_name
+    ? config?.llms?.[workflow.llm_name]?.model_name
+    : undefined;
...
-    models: [...new Set(models)],
+    models: selectedModel ? [selectedModel] : [],

Also applies to: 40-44

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow.ts`
around lines 27 - 32, Update the model collection in summarizeAgentWorkflow to
include only the configured LLM whose key matches workflow.llm_name, rather than
all entries in config.llms. Preserve the existing filtering of missing
model_name values and ensure workflows without a matching selection produce no
model entry.

Comment on lines +27 to +44
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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Derive models from workflow.llm_name, not every configured LLM.

  • web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow.ts#L27-L44: resolve the selected LLM and return only its model.
  • web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow.test.ts#L43-L51: replace the deduplication case with multiple LLMs and assert that only the referenced LLM appears.
📍 Affects 2 files
  • web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow.ts#L27-L44 (this comment)
  • web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow.test.ts#L43-L51
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow.ts`
around lines 27 - 44, Update summarizeAgentWorkflow in
web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow.ts:
resolve workflow.llm_name against config.llms and return only the selected LLM’s
model in models, rather than collecting every configured LLM; preserve the
existing empty/missing-value behavior. Update the deduplication test in
web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow.test.ts
lines 43-51 to configure multiple LLMs and assert that only the model referenced
by workflow.llm_name is returned.

Comment on lines +65 to +74
const onSubmit: SubmitHandler<RegisterAgentFormData> = async (formData) => {
setSubmitError(undefined);
try {
await createAgent({
workspace,
data: {
name: formData.name.trim(),
description: formData.description?.trim() || '',
url: formData.url.trim(),
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the modal file and related schema definitions
git ls-files 'web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/*' \
  'web/packages/studio/src/routes/agents/AgentsListRoute/**' \
  | sed 's#^`#FILE` #'

echo '--- index.tsx ---'
cat -n web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/index.tsx | sed -n '1,220p'

echo '--- search for RegisterAgentFormData / zod schema ---'
rg -n "RegisterAgentFormData|z\.string\(\)\.min\(1|trim\(\)\.min\(1|Name is required|description" web/packages/studio/src/routes/agents/AgentsListRoute -S

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 8778


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read any directly related file(s) if the schema lives nearby
fd -a '.*(schema|validation|form).*' web/packages/studio/src/routes/agents/AgentsListRoute -t f

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 163


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cat -n web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/index.tsx | sed -n '1,220p'

echo '---'
rg -n "RegisterAgentFormData|z\.string\(\)\.min\(1|trim\(\)\.min\(1|Name is required|description" web/packages/studio/src/routes/agents/AgentsListRoute -S

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 7778


Trim name before min(1). z.string().min(1) still accepts spaces, so submit trims them to '' and sends an invalid request. Use z.string().trim().min(1, 'Name is required') in web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/type.ts:17.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@web/packages/studio/src/routes/agents/AgentsListRoute/RegisterAgentModal/index.tsx`
around lines 65 - 74, Update the name schema in the RegisterAgent form
validation to trim whitespace before enforcing the minimum length, using
z.string().trim().min(1, 'Name is required'). Keep the existing onSubmit
trimming in place so validated names cannot become empty after submission.

@marcusds

Copy link
Copy Markdown
Contributor Author

Split into two stacked PRs: backend #763, and Studio UI #764 (stacked on #763, merges after it). Closing this combined PR in favor of those.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant