diff --git a/web/packages/studio/src/routes/agents/AgentDetailRoute/ConfigValue.test.tsx b/web/packages/studio/src/routes/agents/AgentDetailRoute/ConfigValue.test.tsx new file mode 100644 index 0000000000..b25323e0d5 --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentDetailRoute/ConfigValue.test.tsx @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { humanizeKey } from '@studio/routes/agents/AgentDetailRoute/configFormat'; +import { ConfigValue } from '@studio/routes/agents/AgentDetailRoute/ConfigValue'; +import { render, screen } from '@studio/tests/util/render'; + +describe('humanizeKey', () => { + it.each([ + ['_type', 'Type'], + ['model_name', 'Model Name'], + ['llm_name', 'LLM Name'], + ['base_url', 'Base URL'], + ['parseAgentResponseMaxRetries', 'Parse Agent Response Max Retries'], + ])('humanizes %s -> %s', (input, expected) => { + expect(humanizeKey(input)).toBe(expected); + }); +}); + +describe('ConfigValue', () => { + it('renders a scalar as a single row', () => { + render(); + expect(screen.getByText('Verbose')).toBeInTheDocument(); + expect(screen.getByText('false')).toBeInTheDocument(); + }); + + it('joins a scalar array', () => { + render(); + expect(screen.getByText('Tool Names')).toBeInTheDocument(); + expect(screen.getByText('wiki, clock')).toBeInTheDocument(); + }); + + it('masks sensitive keys', () => { + render(); + expect(screen.queryByText('super-secret')).not.toBeInTheDocument(); + expect(screen.getByText('••••••••')).toBeInTheDocument(); + }); + + it.each(['max_tokens', 'maxTokens'])('does not mask token-count key %s', (label) => { + render(); + expect(screen.getByText('4096')).toBeInTheDocument(); + expect(screen.queryByText('••••••••')).not.toBeInTheDocument(); + }); + + it('recurses into nested objects', () => { + render(); + expect(screen.getByText('Type')).toBeInTheDocument(); + expect(screen.getByText('openai')).toBeInTheDocument(); + expect(screen.getByText('Temperature')).toBeInTheDocument(); + }); +}); diff --git a/web/packages/studio/src/routes/agents/AgentDetailRoute/ConfigValue.tsx b/web/packages/studio/src/routes/agents/AgentDetailRoute/ConfigValue.tsx new file mode 100644 index 0000000000..cf3cc44148 --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentDetailRoute/ConfigValue.tsx @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { KVPair } from '@nemo/common/src/components/KVPair'; +import { Stack, Text } from '@nvidia/foundations-react-core'; +import { humanizeKey } from '@studio/routes/agents/AgentDetailRoute/configFormat'; +import type { FC, ReactNode } from 'react'; + +const SENSITIVE_KEY = /(api[_-]?key|secret|token|password|passwd|credential)/i; +const MASK = '••••••••'; + +/** Lowercase, separator-stripped so snake_case and camelCase keys compare equal. */ +const normalizeKey = (key: string): string => key.toLowerCase().replace(/[_-]/g, ''); + +/** Keys that match SENSITIVE_KEY on substring but are ordinary config, not secrets. */ +const NON_SENSITIVE_KEYS = new Set( + [ + 'max_tokens', + 'max_new_tokens', + 'max_input_tokens', + 'max_output_tokens', + 'max_completion_tokens', + 'max_prompt_tokens', + 'num_tokens', + 'token_limit', + 'context_window_tokens', + ].map(normalizeKey) +); + +const isSensitiveKey = (key: string): boolean => + !NON_SENSITIVE_KEYS.has(normalizeKey(key)) && SENSITIVE_KEY.test(key); + +const isScalar = (value: unknown): boolean => + value === null || ['string', 'number', 'boolean'].includes(typeof value); + +const formatScalar = (value: unknown): string => { + if (value === null || value === undefined) return '—'; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + return String(value); +}; + +interface ConfigValueProps { + label: string; + value: unknown; +} + +/** + * Recursively renders an arbitrary config value as KVPair rows. Scalars (and + * scalar-only arrays) collapse to a single row; nested objects/arrays indent. + * Values under sensitive keys (api_key, token, secret, …) are masked. + */ +export const ConfigValue: FC = ({ label, value }) => { + const heading = humanizeKey(label); + + if (isSensitiveKey(label) && value != null && value !== '') { + return ; + } + + if (Array.isArray(value)) { + if (value.length === 0) return ; + if (value.every(isScalar)) { + return ; + } + return ( + + {value.map((item, index) => ( + + ))} + + ); + } + + if (value !== null && typeof value === 'object') { + const entries = Object.entries(value as Record); + if (entries.length === 0) return ; + return ( + + {entries.map(([childKey, childValue]) => ( + + ))} + + ); + } + + return ; +}; + +const NestedBlock: FC<{ heading: string; children: ReactNode }> = ({ heading, children }) => ( + + + {heading} + + + {children} + + +); diff --git a/web/packages/studio/src/routes/agents/AgentDetailRoute/ConfigurationTab.tsx b/web/packages/studio/src/routes/agents/AgentDetailRoute/ConfigurationTab.tsx deleted file mode 100644 index 10acf6723b..0000000000 --- a/web/packages/studio/src/routes/agents/AgentDetailRoute/ConfigurationTab.tsx +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { KVPair } from '@nemo/common/src/components/KVPair'; -import { isDefined } from '@nemo/common/src/utils/list'; -import type { Agent } from '@nemo/sdk/generated/agents/schema/Agent'; -import { Stack } from '@nvidia/foundations-react-core'; -import type { AgentConfig } from '@studio/components/dataViews/AgentsDataView'; -import { getAgentModelNames } from '@studio/components/dataViews/AgentsDataView/utils'; -import { DetailPanel } from '@studio/routes/agents/AgentDetailRoute/overview/DetailPanel'; -import type { FC } from 'react'; - -interface ConfigurationTabProps { - workspace: string; - agentName?: string; - agent?: Agent; -} - -/** Read-only view of the agent's configuration metadata. */ -export const ConfigurationTab: FC = ({ workspace, agentName, agent }) => { - const models = getAgentModelNames(agent?.config as AgentConfig | undefined); - - return ( - - - - - {isDefined(agent?.description) && ( - - )} - {models.length > 0 && } - {isDefined(agent?.config_format) && ( - - )} - - - ); -}; diff --git a/web/packages/studio/src/routes/agents/AgentDetailRoute/DetailsTab.tsx b/web/packages/studio/src/routes/agents/AgentDetailRoute/DetailsTab.tsx new file mode 100644 index 0000000000..3ede8f5c88 --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentDetailRoute/DetailsTab.tsx @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { KVPair } from '@nemo/common/src/components/KVPair'; +import { RelativeTime } from '@nemo/common/src/components/RelativeTime'; +import { isDefined } from '@nemo/common/src/utils/list'; +import type { Agent } from '@nemo/sdk/generated/agents/schema/Agent'; +import { Stack, Text } from '@nvidia/foundations-react-core'; +import type { AgentConfig } from '@studio/components/dataViews/AgentsDataView'; +import { getAgentModelNames } from '@studio/components/dataViews/AgentsDataView/utils'; +import { ConfigValue } from '@studio/routes/agents/AgentDetailRoute/ConfigValue'; +import { DetailPanel } from '@studio/routes/agents/AgentDetailRoute/overview/DetailPanel'; +import type { FC } from 'react'; + +/** Config keys rendered by dedicated structured panels below. */ +const STRUCTURED_KEYS = ['workflow', 'llms', 'functions'] as const; + +interface DetailsTabProps { + workspace: string; + agentName?: string; + agent?: Agent; +} + +/** + * Read-only view of the exact spec an agent is built with: identity metadata + * plus every field of its config, grouped into structured panels with an + * "Additional configuration" fallback so nothing in the spec is hidden. + */ +export const DetailsTab: FC = ({ workspace, agentName, agent }) => { + const config = (agent?.config ?? {}) as Record; + const models = getAgentModelNames(agent?.config as AgentConfig | undefined); + + const workflow = asRecord(config['workflow']); + const llms = asRecord(config['llms']); + const functions = asRecord(config['functions']); + const extraEntries = Object.entries(config).filter( + ([key]) => !STRUCTURED_KEYS.includes(key as (typeof STRUCTURED_KEYS)[number]) + ); + + return ( + + + + + + {isDefined(agent?.project) && agent.project && ( + + )} + {isDefined(agent?.description) && agent.description && ( + + )} + {models.length > 0 && } + {isDefined(agent?.config_format) && ( + + )} + {isDefined(agent?.id) && } + {isDefined(agent?.created_at) && agent.created_at && ( + } /> + )} + {isDefined(agent?.updated_at) && agent.updated_at && ( + } /> + )} + + + + {workflow && ( + + + + )} + + {llms && ( + + + + )} + + {functions && ( + + + + )} + + {extraEntries.length > 0 && ( + + + {extraEntries.map(([key, value]) => ( + + ))} + + + )} + + {Object.keys(config).length === 0 && ( + + + This agent has no stored configuration. + + + )} + + ); +}; + +const asRecord = (value: unknown): Record | undefined => + value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; + +const ConfigEntries: FC<{ data: Record }> = ({ data }) => ( + + {Object.entries(data).map(([key, value]) => ( + + ))} + +); diff --git a/web/packages/studio/src/routes/agents/AgentDetailRoute/configFormat.ts b/web/packages/studio/src/routes/agents/AgentDetailRoute/configFormat.ts new file mode 100644 index 0000000000..75eaa44812 --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentDetailRoute/configFormat.ts @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const ACRONYMS: Record = { + llm: 'LLM', + llms: 'LLMs', + url: 'URL', + api: 'API', + id: 'ID', + uri: 'URI', +}; + +/** Turns a snake_case / camelCase config key into a readable label. */ +export const humanizeKey = (key: string): string => + key + .replace(/([a-z\d])([A-Z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .trim() + .split(' ') + .filter(Boolean) + .map((word) => ACRONYMS[word.toLowerCase()] ?? word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); diff --git a/web/packages/studio/src/routes/agents/AgentDetailRoute/index.test.tsx b/web/packages/studio/src/routes/agents/AgentDetailRoute/index.test.tsx index 98de2dfafa..77d07534ca 100644 --- a/web/packages/studio/src/routes/agents/AgentDetailRoute/index.test.tsx +++ b/web/packages/studio/src/routes/agents/AgentDetailRoute/index.test.tsx @@ -29,6 +29,7 @@ describe('AgentDetailRoute', () => { expect(screen.getByRole('tab', { name: 'Evaluations' })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: 'Logs' })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: 'Chat' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Details' })).toBeInTheDocument(); expect(screen.queryByRole('tab', { name: 'Overview' })).not.toBeInTheDocument(); expect(screen.queryByRole('tab', { name: 'Configuration' })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Open traces' })).toBeInTheDocument(); @@ -46,4 +47,24 @@ describe('AgentDetailRoute', () => { expect(screen.getByRole('tab', { name: 'Chat' })).toHaveAttribute('aria-selected', 'true'); expect(await screen.findByRole('textbox', { name: /Task prompt/i })).toBeInTheDocument(); }); + + it('shows the agent spec on the details tab and masks secrets', async () => { + const user = userEvent.setup(); + renderDetail(); + + await user.click(await screen.findByRole('tab', { name: 'Details' })); + + expect(screen.getByRole('tab', { name: 'Details' })).toHaveAttribute('aria-selected', 'true'); + // Structured panels + expect(await screen.findByText('Overview')).toBeInTheDocument(); + expect(screen.getByText('Workflow')).toBeInTheDocument(); + expect(screen.getByText('Models')).toBeInTheDocument(); + expect(screen.getByText('Tools')).toBeInTheDocument(); + // Config values surfaced from the spec + expect(screen.getByText('nat-workflow-v1')).toBeInTheDocument(); + expect(screen.getByText('react_agent')).toBeInTheDocument(); + // The llm api_key is masked, never shown raw + expect(screen.queryByText('not-used')).not.toBeInTheDocument(); + expect(screen.getByText('••••••••')).toBeInTheDocument(); + }); }); diff --git a/web/packages/studio/src/routes/agents/AgentDetailRoute/index.tsx b/web/packages/studio/src/routes/agents/AgentDetailRoute/index.tsx index c08eae2235..4db08f784d 100644 --- a/web/packages/studio/src/routes/agents/AgentDetailRoute/index.tsx +++ b/web/packages/studio/src/routes/agents/AgentDetailRoute/index.tsx @@ -25,6 +25,7 @@ import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; import { CreateDeploymentModal } from '@studio/routes/agents/AgentDeploymentsListRoute/CreateDeploymentModal'; import { DeploymentsTab } from '@studio/routes/agents/AgentDetailRoute/DeploymentsTab'; +import { DetailsTab } from '@studio/routes/agents/AgentDetailRoute/DetailsTab'; import { EvaluationsTab } from '@studio/routes/agents/AgentDetailRoute/EvaluationsTab'; import { SubmitEvaluationModal } from '@studio/routes/agents/AgentEvaluationsRoute/components/SubmitEvaluationModal'; import { getAgentMonitorRoute, getAgentsListRoute } from '@studio/routes/utils'; @@ -33,7 +34,7 @@ import { type FC, useRef, useState } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; const TAB_SEARCH_PARAM = 'tab'; -const DETAIL_TABS = ['deployments', 'logs', 'chat', 'evaluations'] as const; +const DETAIL_TABS = ['deployments', 'logs', 'chat', 'evaluations', 'details'] as const; const DEFAULT_TAB = 'deployments'; type AgentDetailTab = (typeof DETAIL_TABS)[number]; @@ -169,6 +170,7 @@ export const AgentDetailRoute: FC = () => { Logs Chat Evaluations + Details @@ -216,6 +218,10 @@ export const AgentDetailRoute: FC = () => { /> + + + +