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..53ecddd00e --- /dev/null +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentWorkflowContent.tsx @@ -0,0 +1,121 @@ +// 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 { summarizeAgentWorkflow } from '@studio/components/sidePanels/AgentPanels/AgentPanel/summarizeAgentWorkflow'; +import { Workflow, Wrench } from 'lucide-react'; +import type { FC } from 'react'; +import YAML from 'yaml'; + +interface AgentWorkflowContentProps { + agent?: Agent; +} + +export const AgentWorkflowContent: FC = ({ agent }) => { + 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} + + )} + + +); 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 a5932b9ddd..617dc97b62 100644 --- a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/index.tsx +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/index.tsx @@ -7,6 +7,7 @@ import type { AgentConfig } from '@studio/components/dataViews/AgentsDataView'; import { getAgentModelNames } from '@studio/components/dataViews/AgentsDataView/utils'; import { DeleteConfirmationModal } from '@studio/components/DeleteConfirmationModal'; import { AgentDetailsContent } from '@studio/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent'; +import { AgentWorkflowContent } from '@studio/components/sidePanels/AgentPanels/AgentPanel/AgentWorkflowContent'; import { ChatPlaygroundContent } from '@studio/components/sidePanels/AgentPanels/AgentPanel/ChatPlaygroundContent'; import { DeploymentLogsView } from '@studio/components/sidePanels/AgentPanels/AgentPanel/DeploymentLogsView'; import type { AgentPanelTab } from '@studio/components/sidePanels/AgentPanels/AgentPanel/types'; @@ -61,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' }, ], @@ -114,6 +116,8 @@ export const AgentPanel: FC = ({ if (selectedTab === 'deployment-logs') { content = ; + } else if (selectedTab === 'agent-workflow') { + content = ; } else if (selectedTab === 'chat-playground') { content = ( { + 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/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';