diff --git a/web/packages/studio/src/api/agents/useCreateAgentFromUpload.test.ts b/web/packages/studio/src/api/agents/useCreateAgentFromUpload.test.ts new file mode 100644 index 0000000000..a2f700f785 --- /dev/null +++ b/web/packages/studio/src/api/agents/useCreateAgentFromUpload.test.ts @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { agentsCreateAgent, agentsGetAgent } from '@nemo/sdk/generated/agents/api'; +import { + filesCreateFileset, + filesDeleteFileset, + filesRetrieveFileset, + filesUploadFile, +} from '@nemo/sdk/generated/platform/api'; +import { + AgentSpecFilesetConflictError, + AgentSpecFilesetOrphanError, + createAgentFromUpload, +} from '@studio/api/agents/useCreateAgentFromUpload'; +import type { UploadAgentEntry } from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/type'; + +vi.mock('@nemo/sdk/generated/agents/api', async (importOriginal) => ({ + ...(await importOriginal()), + agentsCreateAgent: vi.fn(), + agentsGetAgent: vi.fn(), +})); + +vi.mock('@nemo/sdk/generated/platform/api', async (importOriginal) => ({ + ...(await importOriginal()), + filesRetrieveFileset: vi.fn(), + filesCreateFileset: vi.fn(), + filesUploadFile: vi.fn(), + filesDeleteFileset: vi.fn(), +})); + +const FABRIC_YAML = 'config_format: nemo-agents-spec-v1\nname: calc\ndescription: Adds numbers\n'; + +const entryFor = (path: string, contents: string): UploadAgentEntry => ({ + path, + file: new File([contents], path.split('/').pop() ?? path), +}); + +const entries = (): UploadAgentEntry[] => [ + entryFor('agent.yaml', FABRIC_YAML), + entryFor('mcps/calculator.py', 'print(1)\n'), +]; + +const params = () => ({ workspace: 'ws', name: 'calc', entries: entries() }); + +const filesetMissing = () => vi.mocked(filesRetrieveFileset).mockRejectedValue(new Error('404')); +const filesetExists = () => + vi.mocked(filesRetrieveFileset).mockResolvedValue({ name: 'calc-spec' } as never); +const agentMissing = () => vi.mocked(agentsGetAgent).mockRejectedValue(new Error('404')); +const agentExists = () => vi.mocked(agentsGetAgent).mockResolvedValue({ name: 'calc' } as never); + +beforeEach(() => { + filesetMissing(); + agentMissing(); + vi.mocked(filesCreateFileset).mockResolvedValue({ name: 'calc-spec' } as never); + vi.mocked(filesUploadFile).mockResolvedValue({ path: 'agent.yaml' } as never); + vi.mocked(filesDeleteFileset).mockResolvedValue(undefined as never); + vi.mocked(agentsCreateAgent).mockResolvedValue({ name: 'calc' } as never); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('createAgentFromUpload', () => { + it('uploads every file before creating the agent', async () => { + const order: string[] = []; + vi.mocked(filesUploadFile).mockImplementation(async (_ws, _fs, path) => { + order.push(`upload:${path}`); + return { path } as never; + }); + vi.mocked(agentsCreateAgent).mockImplementation(async () => { + order.push('createAgent'); + return { name: 'calc' } as never; + }); + + await createAgentFromUpload(params()); + + // Uploads run concurrently, so only their completion before the create is guaranteed. + expect(order.at(-1)).toBe('createAgent'); + expect(order.slice(0, -1).sort()).toEqual(['upload:agent.yaml', 'upload:mcps/calculator.py']); + expect(agentsCreateAgent).toHaveBeenCalledWith('ws', { + name: 'calc', + description: 'Adds numbers', + config: expect.objectContaining({ config_format: 'nemo-agents-spec-v1' }), + config_format: 'nemo-agents-spec-v1', + }); + }); + + it('refuses a fileset that an existing agent owns', async () => { + filesetExists(); + agentExists(); + + await expect(createAgentFromUpload(params())).rejects.toThrow(AgentSpecFilesetConflictError); + expect(filesCreateFileset).not.toHaveBeenCalled(); + expect(filesDeleteFileset).not.toHaveBeenCalled(); + }); + + it('asks before replacing a fileset that no agent owns', async () => { + filesetExists(); + + await expect(createAgentFromUpload(params())).rejects.toThrow(AgentSpecFilesetOrphanError); + expect(filesDeleteFileset).not.toHaveBeenCalled(); + expect(agentsCreateAgent).not.toHaveBeenCalled(); + }); + + it('replaces an orphaned fileset once confirmed', async () => { + filesetExists(); + + await createAgentFromUpload({ ...params(), replaceOrphanedFileset: true }); + + expect(filesDeleteFileset).toHaveBeenCalledWith('ws', 'calc-spec'); + expect(filesCreateFileset).toHaveBeenCalledWith( + 'ws', + expect.objectContaining({ name: 'calc-spec' }) + ); + expect(agentsCreateAgent).toHaveBeenCalled(); + }); + + it('does not replace an owned fileset even when confirmed', async () => { + filesetExists(); + agentExists(); + + await expect( + createAgentFromUpload({ ...params(), replaceOrphanedFileset: true }) + ).rejects.toThrow(AgentSpecFilesetConflictError); + expect(filesDeleteFileset).not.toHaveBeenCalled(); + }); + + it('deletes the fileset when an upload fails', async () => { + vi.mocked(filesUploadFile) + .mockResolvedValueOnce({ path: 'agent.yaml' } as never) + .mockRejectedValueOnce(new Error('network down')); + + await expect(createAgentFromUpload(params())).rejects.toThrow('network down'); + expect(filesDeleteFileset).toHaveBeenCalledWith('ws', 'calc-spec'); + }); + + it('deletes the fileset when creating the agent fails', async () => { + vi.mocked(agentsCreateAgent).mockRejectedValue(new Error('409 conflict')); + + await expect(createAgentFromUpload(params())).rejects.toThrow('409 conflict'); + expect(filesDeleteFileset).toHaveBeenCalledWith('ws', 'calc-spec'); + }); + + it('rejects a non-Fabric config before touching anything', async () => { + const natEntries = [entryFor('agent.yaml', 'config_format: nat-workflow-v1\n')]; + + await expect( + createAgentFromUpload({ workspace: 'ws', name: 'calc', entries: natEntries }) + ).rejects.toThrow(/config_format/); + expect(filesRetrieveFileset).not.toHaveBeenCalled(); + expect(filesCreateFileset).not.toHaveBeenCalled(); + expect(agentsCreateAgent).not.toHaveBeenCalled(); + }); +}); diff --git a/web/packages/studio/src/api/agents/useCreateAgentFromUpload.ts b/web/packages/studio/src/api/agents/useCreateAgentFromUpload.ts new file mode 100644 index 0000000000..f8467c2600 --- /dev/null +++ b/web/packages/studio/src/api/agents/useCreateAgentFromUpload.ts @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { agentsCreateAgent, agentsGetAgent } from '@nemo/sdk/generated/agents/api'; +import type { Agent } from '@nemo/sdk/generated/agents/schema/Agent'; +import { + filesCreateFileset, + filesDeleteFileset, + filesRetrieveFileset, + filesUploadFile, +} from '@nemo/sdk/generated/platform/api'; +import { + AGENT_CONFIG_FILENAME, + FABRIC_CONFIG_FORMAT, +} from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/const'; +import type { UploadAgentEntry } from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/type'; +import { + agentSpecFilesetName, + parseAgentConfig, +} from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/utils'; +import { UseMutationOptions, useMutation } from '@tanstack/react-query'; + +export interface CreateAgentFromUploadParams { + workspace: string; + name: string; + entries: UploadAgentEntry[]; + replaceOrphanedFileset?: boolean; +} + +/** An agent of this name already exists; its spec fileset is not ours to take. */ +export class AgentSpecFilesetConflictError extends Error { + constructor(public readonly filesetName: string) { + super( + `An agent named "${filesetName.replace(/-spec$/, '')}" already owns the fileset "${filesetName}". Choose a different name.` + ); + } +} + +/** A spec fileset with no agent behind it — an abandoned upload, or an agent since deleted. */ +export class AgentSpecFilesetOrphanError extends Error { + constructor(public readonly filesetName: string) { + super( + `A fileset named "${filesetName}" already exists, but no agent owns it — an upload that did not finish, or an agent that was deleted, since deleting an agent leaves its fileset behind. Replacing it discards its current contents.` + ); + } +} + +// Files first: the fileset reserves the name, and a create-time validation that needs a +// base_dir can only see files that are already uploaded. Deleting the fileset on rollback +// is safe because an existing one is either refused or replaced deliberately above. +export const createAgentFromUpload = async ({ + workspace, + name, + entries, + replaceOrphanedFileset = false, +}: CreateAgentFromUploadParams): Promise => { + const filesetName = agentSpecFilesetName(name); + + const configEntry = entries.find((entry) => entry.path === AGENT_CONFIG_FILENAME); + if (!configEntry) throw new Error(`No ${AGENT_CONFIG_FILENAME} in the selected directory.`); + const config = parseAgentConfig(await configEntry.file.text()); + + await claimFileset(workspace, name, filesetName, replaceOrphanedFileset); + + try { + await filesCreateFileset(workspace, { + name: filesetName, + description: `Agent spec for ${name}`, + }); + await uploadEntries(workspace, filesetName, entries); + + return await agentsCreateAgent(workspace, { + name, + description: typeof config.description === 'string' ? config.description : '', + config, + config_format: FABRIC_CONFIG_FORMAT, + }); + } catch (error) { + await rollback(workspace, filesetName); + throw error; + } +}; + +const claimFileset = async ( + workspace: string, + agentName: string, + filesetName: string, + replaceOrphanedFileset: boolean +): Promise => { + try { + await filesRetrieveFileset(workspace, filesetName); + } catch { + return; + } + + if (await agentExists(workspace, agentName)) { + throw new AgentSpecFilesetConflictError(filesetName); + } + if (!replaceOrphanedFileset) { + throw new AgentSpecFilesetOrphanError(filesetName); + } + + await filesDeleteFileset(workspace, filesetName); +}; + +const agentExists = async (workspace: string, agentName: string): Promise => { + try { + await agentsGetAgent(workspace, agentName); + return true; + } catch { + return false; + } +}; + +// One request per file, so a 500-file agent is 500 round trips. Run a bounded number at +// once: unbounded Promise.all would queue them all against the browser's per-host limit +// and lose the first error behind hundreds of in-flight requests. +const UPLOAD_CONCURRENCY = 6; + +const uploadEntries = async ( + workspace: string, + filesetName: string, + entries: UploadAgentEntry[] +): Promise => { + const queue = [...entries]; + const worker = async (): Promise => { + for (let entry = queue.shift(); entry; entry = queue.shift()) { + const blob = new Blob([await entry.file.arrayBuffer()], { type: 'application/octet-stream' }); + await filesUploadFile(workspace, filesetName, entry.path, blob); + } + }; + + await Promise.all( + Array.from({ length: Math.min(UPLOAD_CONCURRENCY, entries.length) }, () => worker()) + ); +}; + +const rollback = async (workspace: string, filesetName: string): Promise => { + await filesDeleteFileset(workspace, filesetName).catch(() => undefined); +}; + +export type UseCreateAgentFromUploadOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export const useCreateAgentFromUpload = (options?: UseCreateAgentFromUploadOptions) => + useMutation({ ...options, mutationFn: createAgentFromUpload }); diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts new file mode 100644 index 0000000000..b7970af3e6 --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { z } from 'zod'; + +export const AGENT_CONFIG_FILENAME = 'agent.yaml'; + +export const FABRIC_CONFIG_FORMAT = 'nemo-agents-spec-v1'; + +// Container staging skips this file, so its bytes never reach a deployment. +export const AGENT_SPEC_FILENAME = 'AGENT-SPEC.md'; + +// Mirrors MAX_AGENT_SPEC_STAGED_BYTES / _FILES; the platform only enforces them at deploy. +export const MAX_AGENT_SPEC_BYTES = 900_000; +export const MAX_AGENT_SPEC_FILES = 500; + +// A directory picker hands over every descendant, so a mistaken pick can arrive with +// hundreds of thousands of entries. Reject on the raw count before mapping, filtering or +// sorting any of them — the ignore list cannot be applied without touching every entry. +export const MAX_PICKED_FILES = 1_000; + +export const IGNORED_DIRECTORIES = new Set([ + '__pycache__', + '.git', + '.venv', + 'venv', + 'node_modules', + '.mypy_cache', + '.pytest_cache', + '.ruff_cache', + '.idea', + '.vscode', +]); + +export const IGNORED_FILENAMES = new Set(['.DS_Store', 'Thumbs.db']); + +export const IGNORED_EXTENSIONS = ['.pyc', '.pyo', '.pyd', '.so', '.dylib', '.dll']; + +export const uploadAgentFormSchema = z.object({ + name: z + .string() + .trim() + .min(1, 'Name is required') + .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, 'Use lowercase letters, numbers, and hyphens'), +}); diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.test.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.test.tsx new file mode 100644 index 0000000000..ad72701704 --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.test.tsx @@ -0,0 +1,239 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PLATFORM_BASE_URL } from '@studio/constants/environment'; +import { ROUTES } from '@studio/constants/routes'; +import { workspace1 } from '@studio/mocks/entity-store/projects'; +import { server } from '@studio/mocks/node'; +import { UploadAgentModal } from '@studio/routes/agents/AgentsListRoute/UploadAgentModal'; +import { getAgentsListRoute } from '@studio/routes/utils'; +import { renderRoute, screen, waitFor } from '@studio/tests/util/render'; +import { fireEvent, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; + +const workspace = workspace1.workspace; +const FILESETS_URL = `${PLATFORM_BASE_URL}/apis/files/v2/workspaces/:workspace/filesets`; +const FILESET_URL = `${FILESETS_URL}/:name`; +const UPLOAD_URL = `${FILESET_URL}/-/*`; +const AGENTS_URL = `${PLATFORM_BASE_URL}/apis/agents/v2/workspaces/:workspace/agents`; +const AGENT_URL = `${AGENTS_URL}/:name`; + +const FABRIC_YAML = `config_format: nemo-agents-spec-v1 +name: calc +description: Adds numbers +`; + +const makeFile = (relativePath: string, contents: string): File => { + const file = new File([contents], relativePath.split('/').pop() ?? relativePath, { + type: 'text/plain', + }); + Object.defineProperty(file, 'webkitRelativePath', { value: relativePath }); + return file; +}; + +const DEFAULT_FILES = [ + makeFile('calc-agent/agent.yaml', FABRIC_YAML), + makeFile('calc-agent/mcps/calculator.py', 'print(1)\n'), +]; + +interface Scenario { + filesetExists?: boolean; + agentExists?: boolean; +} + +const mockPlatform = ({ filesetExists = false, agentExists = false }: Scenario = {}) => { + const uploaded: string[] = []; + const created: { name?: string }[] = []; + + server.use( + http.get(FILESET_URL, ({ params }) => + filesetExists + ? HttpResponse.json({ name: params['name'], workspace }) + : HttpResponse.json({ detail: 'not found' }, { status: 404 }) + ), + http.get(AGENT_URL, ({ params }) => + agentExists + ? HttpResponse.json({ name: params['name'], workspace }) + : HttpResponse.json({ detail: 'not found' }, { status: 404 }) + ), + http.delete(FILESET_URL, () => HttpResponse.json({ name: 'deleted' })), + http.post(FILESETS_URL, async ({ request }) => HttpResponse.json(await request.json())), + http.put(UPLOAD_URL, ({ request }) => { + uploaded.push(decodeURIComponent(new URL(request.url).pathname.split('/-/')[1] ?? '')); + return HttpResponse.json({ path: 'ok' }); + }), + http.post(AGENTS_URL, async ({ request }) => { + const body = (await request.json()) as { name?: string }; + created.push(body); + return HttpResponse.json({ ...body, workspace }, { status: 201 }); + }) + ); + + return { uploaded, created }; +}; + +const renderModal = () => + renderRoute(undefined, { + history: getAgentsListRoute(workspace), + routes: [ + { + path: ROUTES.workspace.agentsList, + element: , + }, + { path: ROUTES.workspace.agentDetail, element:
Agent detail page
}, + ], + }); + +const pickDirectory = (dialog: HTMLElement, files: File[] = DEFAULT_FILES) => { + fireEvent.change(within(dialog).getByTestId('agent-directory-input'), { target: { files } }); +}; + +const submit = async (dialog: HTMLElement, user: ReturnType) => { + await user.click(within(dialog).getByRole('button', { name: /^(Create|Replace and create)$/ })); +}; + +describe('UploadAgentModal', () => { + it('uploads the picked directory, then creates the agent', async () => { + const user = userEvent.setup(); + const { uploaded, created } = mockPlatform(); + + renderModal(); + const dialog = await screen.findByRole('dialog'); + pickDirectory(dialog); + await waitFor(() => expect(within(dialog).getByDisplayValue('calc')).toBeInTheDocument()); + + await submit(dialog, user); + + await waitFor(() => expect(created).toHaveLength(1)); + expect([...uploaded].sort()).toEqual(['agent.yaml', 'mcps/calculator.py']); + expect(created[0]?.name).toBe('calc'); + }); + + it('shows why an owned name is refused instead of a generic failure', async () => { + const user = userEvent.setup(); + const { created } = mockPlatform({ filesetExists: true, agentExists: true }); + + renderModal(); + const dialog = await screen.findByRole('dialog'); + pickDirectory(dialog); + await waitFor(() => expect(within(dialog).getByDisplayValue('calc')).toBeInTheDocument()); + + await submit(dialog, user); + + expect(await within(dialog).findByText(/already owns the fileset/)).toBeInTheDocument(); + expect(within(dialog).getByRole('button', { name: 'Create' })).toBeInTheDocument(); + expect(created).toHaveLength(0); + }); + + it('offers to replace an orphaned fileset, and replaces it on the next submit', async () => { + const user = userEvent.setup(); + const { created } = mockPlatform({ filesetExists: true }); + + renderModal(); + const dialog = await screen.findByRole('dialog'); + pickDirectory(dialog); + await waitFor(() => expect(within(dialog).getByDisplayValue('calc')).toBeInTheDocument()); + + await submit(dialog, user); + expect(await within(dialog).findByText(/no agent owns it/)).toBeInTheDocument(); + + const replace = await within(dialog).findByRole('button', { name: 'Replace and create' }); + await user.click(replace); + + await waitFor(() => expect(created).toHaveLength(1)); + }); + + it('rejects a directory with no agent.yaml at the top level', async () => { + mockPlatform(); + + renderModal(); + const dialog = await screen.findByRole('dialog'); + pickDirectory(dialog, [makeFile('calc-agent/mcps/calculator.py', 'print(1)\n')]); + + expect(await within(dialog).findByText(/No agent\.yaml/)).toBeInTheDocument(); + expect(within(dialog).getByRole('button', { name: 'Create' })).toBeDisabled(); + }); + + it('rejects a directory holding a file that is not text', async () => { + mockPlatform(); + + renderModal(); + const dialog = await screen.findByRole('dialog'); + pickDirectory(dialog, [ + makeFile('calc-agent/agent.yaml', FABRIC_YAML), + new File([new Uint8Array([0xff, 0xfe, 0x00])], 'logo.bin'), + ]); + + expect(await within(dialog).findByText(/is not a text file/)).toBeInTheDocument(); + }); +}); + +describe('UploadAgentModal oversized pick', () => { + it('rejects a directory far larger than an agent, naming the count', async () => { + mockPlatform(); + renderModal(); + const dialog = await screen.findByRole('dialog'); + + // A real accidental pick was 880k files; only length is read before the guard fires. + fireEvent.change(within(dialog).getByTestId('agent-directory-input'), { + target: { files: { length: 880_000 } }, + }); + + expect(await within(dialog).findByText(/880,000 files/)).toBeInTheDocument(); + expect(within(dialog).getByRole('button', { name: 'Create' })).toBeDisabled(); + }); +}); + +describe('UploadAgentModal folder drop', () => { + const dirEntry = (name: string, fullPath: string, children: FileSystemEntry[]) => + ({ + name, + fullPath, + isFile: false, + isDirectory: true, + createReader: () => { + let drained = false; + return { + readEntries: (resolve: (entries: FileSystemEntry[]) => void) => { + resolve(drained ? [] : children); + drained = true; + }, + }; + }, + }) as unknown as FileSystemEntry; + + const fileEntry = (name: string, fullPath: string, contents: string) => + ({ + name, + fullPath, + isFile: true, + isDirectory: false, + file: (resolve: (file: File) => void) => resolve(new File([contents], name)), + }) as unknown as FileSystemEntry; + + it('accepts a dropped folder, walking it into nested paths', async () => { + const user = userEvent.setup(); + const { uploaded, created } = mockPlatform(); + + renderModal(); + const dialog = await screen.findByRole('dialog'); + + const root = dirEntry('calc-agent', '/calc-agent', [ + fileEntry('agent.yaml', '/calc-agent/agent.yaml', FABRIC_YAML), + dirEntry('mcps', '/calc-agent/mcps', [ + fileEntry('calculator.py', '/calc-agent/mcps/calculator.py', 'print(1)\n'), + ]), + ]); + + fireEvent.drop(within(dialog).getByTestId('agent-directory-dropzone'), { + dataTransfer: { items: [{ kind: 'file', webkitGetAsEntry: () => root }], files: [] }, + }); + + await waitFor(() => expect(within(dialog).getByDisplayValue('calc')).toBeInTheDocument()); + await submit(dialog, user); + + await waitFor(() => expect(created).toHaveLength(1)); + expect([...uploaded].sort()).toEqual(['agent.yaml', 'mcps/calculator.py']); + }); +}); diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx new file mode 100644 index 0000000000..ab4d5a8b8b --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx @@ -0,0 +1,258 @@ +// 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 { getErrorMessage } from '@nemo/common/src/api/common/utils'; +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 } from '@nemo/sdk/generated/agents/api'; +import { + Stack, + Text, + UploadInputElement, + UploadRoot, + UploadTrigger, +} from '@nvidia/foundations-react-core'; +import { + AgentSpecFilesetOrphanError, + useCreateAgentFromUpload, +} from '@studio/api/agents/useCreateAgentFromUpload'; +import { + AGENT_CONFIG_FILENAME, + uploadAgentFormSchema, +} from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/const'; +import type { + PickedFile, + UploadAgentEntry, + UploadAgentFormData, + UploadAgentModalProps, +} from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/type'; +import { + agentNameFromConfig, + collectAgentEntries, + findNonUtf8Path, + parseAgentConfig, + pickedFromDataTransfer, + pickedFromFileList, + tooManyPickedFiles, + totalEntryBytes, + validateAgentEntries, +} from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/utils'; +import { getAgentDetailRoute } from '@studio/routes/utils'; +import { useQueryClient } from '@tanstack/react-query'; +import { + type ChangeEventHandler, + type DragEventHandler, + type FC, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; +import { type SubmitHandler, useForm, useWatch } from 'react-hook-form'; +import { useNavigate } from 'react-router'; + +export const UploadAgentModal: FC = ({ open, onClose, workspace }) => { + const toast = useToast(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const inputRef = useRef(null); + const setDirectoryInput = useCallback((node: HTMLInputElement | null) => { + inputRef.current = node; + // webkitdirectory is absent from React's input attribute types. + node?.setAttribute('webkitdirectory', ''); + }, []); + const [entries, setEntries] = useState([]); + const [directoryName, setDirectoryName] = useState(''); + const [selectionError, setSelectionError] = useState(undefined); + const [replaceArmedFor, setReplaceArmedFor] = useState(null); + + const { + mutateAsync: createAgent, + error: createError, + isPending, + reset: resetMutation, + } = useCreateAgentFromUpload({ + onSuccess: (agent) => { + toast.success(`Agent "${agent.name}" created`); + void queryClient.invalidateQueries({ queryKey: getAgentsListAgentsQueryKey(workspace) }); + resetAndClose(); + if (agent.name) navigate(getAgentDetailRoute(workspace, agent.name)); + }, + }); + + const { + control, + setValue, + handleSubmit, + reset: resetForm, + formState: { errors }, + } = useForm({ + resolver: zodResolver(uploadAgentFormSchema), + defaultValues: { name: '' }, + disabled: isPending, + mode: 'onChange', + }); + + // useWatch re-renders this modal on every keystroke; the summary depends only on entries. + const entriesSummary = useMemo( + () => + entries.length === 0 + ? undefined + : `${directoryName} — ${entries.length} files, ${Math.max(1, Math.round(totalEntryBytes(entries) / 1000))} KB`, + [directoryName, entries] + ); + + const watchedName = useWatch({ control, name: 'name' }); + // Derived, not stored: an armed replace targets one fileset, so editing the name + // disarms it in the same render rather than one render later. + const replaceOrphan = replaceArmedFor !== null && replaceArmedFor === watchedName?.trim(); + + const resetAndClose = () => { + resetMutation(); + resetForm({ name: '' }); + setEntries([]); + setDirectoryName(''); + setSelectionError(undefined); + setReplaceArmedFor(null); + onClose(); + }; + + // Both entry points land here so a drop is validated exactly like a pick. + const acceptPicked = async (picked: PickedFile[]) => { + setDirectoryName(picked[0]?.relativePath.split('/')[0] ?? ''); + const collected = collectAgentEntries(picked); + + const problem = validateAgentEntries(collected); + if (problem) { + setEntries([]); + setSelectionError(problem); + return; + } + + const binaryPath = await findNonUtf8Path(collected); + if (binaryPath) { + setEntries([]); + setSelectionError( + `${binaryPath} is not a text file. Agent files are delivered to container deployments as text, so the agent would fail to deploy. Remove it and try again.` + ); + return; + } + + const configEntry = collected.find((item) => item.path === AGENT_CONFIG_FILENAME); + try { + const config = parseAgentConfig((await configEntry?.file.text()) ?? ''); + setValue('name', agentNameFromConfig(config) ?? '', { shouldValidate: true }); + } catch (error) { + setEntries([]); + setSelectionError( + getErrorMessage(error as Error) || `Could not read ${AGENT_CONFIG_FILENAME}` + ); + return; + } + + setEntries(collected); + setSelectionError(undefined); + }; + + const rejectOversized = (count: number): boolean => { + const oversized = tooManyPickedFiles(count); + if (!oversized) return false; + setEntries([]); + setDirectoryName(''); + setSelectionError(oversized); + return true; + }; + + const onDirectoryPicked: ChangeEventHandler = async (event) => { + const fileList = event.target.files; + const pickedCount = fileList?.length ?? 0; + if (pickedCount === 0) return; + + if (rejectOversized(pickedCount)) { + event.target.value = ''; + return; + } + + const picked = pickedFromFileList(Array.from(fileList ?? [])); + event.target.value = ''; + await acceptPicked(picked); + }; + + const onDirectoryDropped: DragEventHandler = async (event) => { + event.preventDefault(); + event.stopPropagation(); + if (isPending) return; + + const items = Array.from(event.dataTransfer.items); + if (items.length === 0) return; + + const picked = await pickedFromDataTransfer(items); + if (picked.length === 0) { + setSelectionError('That drop contained no readable files.'); + return; + } + if (rejectOversized(picked.length)) return; + + await acceptPicked(picked); + }; + + const onSubmit: SubmitHandler = async (formData) => { + const name = formData.name.trim(); + try { + await createAgent({ workspace, name, entries, replaceOrphanedFileset: replaceOrphan }); + } catch (error) { + // An orphaned fileset is recoverable, so the next submit replaces it. + setReplaceArmedFor(error instanceof AgentSpecFilesetOrphanError ? name : null); + } + }; + + // No fallback argument: getErrorMessage prefers one over a plain Error's own message. + const errorMessage = + selectionError ?? + (createError ? getErrorMessage(createError as Error) || 'Failed to create agent' : undefined); + + return ( + + + Select agent config files + + + + + + {entriesSummary ? {entriesSummary} : null} + + + + ); +}; diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/type.ts b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/type.ts new file mode 100644 index 0000000000..feb67e4360 --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/type.ts @@ -0,0 +1,24 @@ +// 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 type { uploadAgentFormSchema } from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/const'; +import type { z } from 'zod'; + +export type UploadAgentFormData = z.infer; + +/** A file plus the path it was picked or dropped under, still including the root directory. */ +export interface PickedFile { + file: File; + relativePath: string; +} + +/** A picked file paired with its path inside the agent spec fileset. */ +export interface UploadAgentEntry { + path: string; + file: File; +} + +export interface UploadAgentModalProps extends Pick { + workspace: string; +} diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/utils.test.ts b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/utils.test.ts new file mode 100644 index 0000000000..70e41d840e --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/utils.test.ts @@ -0,0 +1,286 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + MAX_AGENT_SPEC_FILES, + MAX_PICKED_FILES, +} from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/const'; +import type { + PickedFile, + UploadAgentEntry, +} from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/type'; +import { + AgentConfigParseError, + agentNameFromConfig, + agentSpecFilesetName, + collectAgentEntries, + findNonUtf8Path, + isIgnoredPath, + parseAgentConfig, + pickedFromDataTransfer, + tooManyPickedFiles, + validateAgentEntries, +} from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/utils'; + +const makeFile = (relativePath: string, contents = 'x'): PickedFile => ({ + file: new File([contents], relativePath.split('/').pop() ?? relativePath), + relativePath, +}); + +const entry = (path: string, size = 1): UploadAgentEntry => ({ + path, + file: { size } as File, +}); + +describe('collectAgentEntries', () => { + it('strips the picked directory from each path', () => { + const entries = collectAgentEntries([ + makeFile('calculator-agent/agent.yaml'), + makeFile('calculator-agent/mcps/calculator.py'), + ]); + + expect(entries.map((item) => item.path)).toEqual(['agent.yaml', 'mcps/calculator.py']); + }); + + it('drops build artifacts that the container path cannot stage', () => { + const entries = collectAgentEntries([ + makeFile('agent/agent.yaml'), + makeFile('agent/mcps/__pycache__/calculator.cpython-312.pyc'), + makeFile('agent/.DS_Store'), + makeFile('agent/.git/config'), + makeFile('agent/node_modules/left-pad/index.js'), + ]); + + expect(entries.map((item) => item.path)).toEqual(['agent.yaml']); + }); + + it('falls back to the file name when the picker reports no relative path', () => { + const entries = collectAgentEntries([ + { file: new File(['x'], 'agent.yaml'), relativePath: 'agent.yaml' }, + ]); + + expect(entries.map((item) => item.path)).toEqual(['agent.yaml']); + }); +}); + +describe('isIgnoredPath', () => { + it.each([ + ['mcps/__pycache__/calculator.pyc', true], + ['.venv/lib/python3.12/site-packages/x.py', true], + ['build/libthing.so', true], + ['skills/review/SKILL.md', false], + ['mcps/calculator.py', false], + ])('%s -> %s', (path, expected) => { + expect(isIgnoredPath(path)).toBe(expected); + }); +}); + +describe('validateAgentEntries', () => { + it('requires agent.yaml at the top level', () => { + expect(validateAgentEntries([entry('mcps/calculator.py')])).toMatch(/No agent\.yaml/); + expect(validateAgentEntries([entry('nested/agent.yaml')])).toMatch(/No agent\.yaml/); + }); + + it('rejects an empty directory', () => { + expect(validateAgentEntries([])).toMatch(/no uploadable files/); + }); + + it('rejects a directory over the file-count limit', () => { + const entries = [ + entry('agent.yaml'), + ...Array.from({ length: MAX_AGENT_SPEC_FILES }, (_unused, index) => entry(`f${index}.md`)), + ]; + + expect(validateAgentEntries(entries)).toMatch(/the limit is 500/); + }); + + it('rejects a directory over the byte limit', () => { + expect(validateAgentEntries([entry('agent.yaml', 900_001)])).toMatch(/the limit is 900 KB/); + }); + + it('accepts a directory within both limits', () => { + expect( + validateAgentEntries([entry('agent.yaml'), entry('mcps/calculator.py')]) + ).toBeUndefined(); + }); +}); + +describe('parseAgentConfig', () => { + it('returns the parsed config for the Fabric contract', () => { + const config = parseAgentConfig('config_format: nemo-agents-spec-v1\nname: calc\n'); + + expect(config.name).toBe('calc'); + }); + + it('rejects a NAT workflow config', () => { + expect(() => parseAgentConfig('config_format: nat-workflow-v1\n')).toThrow( + AgentConfigParseError + ); + }); + + it('rejects a config with no config_format', () => { + expect(() => parseAgentConfig('name: calc\n')).toThrow(AgentConfigParseError); + }); + + it('rejects YAML that is not a mapping', () => { + expect(() => parseAgentConfig('- one\n- two\n')).toThrow(/must contain a YAML mapping/); + }); + + it('rejects malformed YAML', () => { + expect(() => parseAgentConfig('a:\n - b\n c: broken\n')).toThrow(/not valid YAML/); + }); +}); + +describe('agentNameFromConfig', () => { + it('reads a non-empty name', () => { + expect(agentNameFromConfig({ name: ' calc ' })).toBe('calc'); + }); + + it('ignores a missing or blank name', () => { + expect(agentNameFromConfig({})).toBeUndefined(); + expect(agentNameFromConfig({ name: ' ' })).toBeUndefined(); + expect(agentNameFromConfig({ name: 7 })).toBeUndefined(); + }); +}); + +describe('agentSpecFilesetName', () => { + it('matches the platform convention', () => { + expect(agentSpecFilesetName('calc')).toBe('calc-spec'); + }); +}); + +describe('findNonUtf8Path', () => { + const binaryEntry = (path: string, bytes: number[]): UploadAgentEntry => ({ + path, + file: new File([new Uint8Array(bytes)], path.split('/').pop() ?? path), + }); + + const textEntry = (path: string, contents: string): UploadAgentEntry => ({ + path, + file: new File([contents], path.split('/').pop() ?? path), + }); + + it('names the first file that is not valid UTF-8', async () => { + const entries = [ + textEntry('agent.yaml', 'name: calc\n'), + binaryEntry('logo.bin', [0xff, 0xfe, 0x00, 0x62]), + ]; + + await expect(findNonUtf8Path(entries)).resolves.toBe('logo.bin'); + }); + + it('accepts multi-byte UTF-8, an empty file, and a BOM', async () => { + const entries = [ + textEntry('agent.yaml', 'description: café ☕ — 名前\n'), + textEntry('empty.md', ''), + binaryEntry('bom.md', [0xef, 0xbb, 0xbf, 0x68, 0x69]), + ]; + + await expect(findNonUtf8Path(entries)).resolves.toBeUndefined(); + }); + + it('ignores AGENT-SPEC.md, which container staging never reads', async () => { + const entries = [binaryEntry('AGENT-SPEC.md', [0xff, 0xfe, 0x00])]; + + await expect(findNonUtf8Path(entries)).resolves.toBeUndefined(); + }); + + it('rejects a lone UTF-16 surrogate sequence', async () => { + const entries = [binaryEntry('utf16.md', [0xed, 0xa0, 0x80])]; + + await expect(findNonUtf8Path(entries)).resolves.toBe('utf16.md'); + }); +}); + +describe('tooManyPickedFiles', () => { + it('rejects a pick far larger than any agent directory', () => { + expect(tooManyPickedFiles(880_000)).toMatch(/880,000 files/); + }); + + it('allows a pick within the inspectable ceiling', () => { + expect(tooManyPickedFiles(MAX_PICKED_FILES)).toBeUndefined(); + expect(tooManyPickedFiles(12)).toBeUndefined(); + }); +}); + +describe('pickedFromDataTransfer', () => { + interface FakeTree { + [name: string]: FakeTree | string; + } + + const makeEntry = (name: string, fullPath: string, node: FakeTree | string): FileSystemEntry => { + if (typeof node === 'string') { + return { + name, + fullPath, + isFile: true, + isDirectory: false, + file: (resolve: (file: File) => void) => resolve(new File([node], name)), + } as unknown as FileSystemEntry; + } + + const children = Object.entries(node).map(([child, value]) => + makeEntry(child, `${fullPath}/${child}`, value) + ); + let drained = false; + return { + name, + fullPath, + isFile: false, + isDirectory: true, + createReader: () => ({ + readEntries: (resolve: (entries: FileSystemEntry[]) => void) => { + resolve(drained ? [] : children); + drained = true; + }, + }), + } as unknown as FileSystemEntry; + }; + + const drop = (tree: FakeTree, root = 'calc-agent'): DataTransferItem[] => [ + { + kind: 'file', + webkitGetAsEntry: () => makeEntry(root, `/${root}`, tree), + } as unknown as DataTransferItem, + ]; + + it('walks a dropped directory into files with their paths', async () => { + const picked = await pickedFromDataTransfer( + drop({ 'agent.yaml': 'name: calc', mcps: { 'calculator.py': 'print(1)' } }) + ); + + expect(picked.map((item) => item.relativePath).sort()).toEqual([ + 'calc-agent/agent.yaml', + 'calc-agent/mcps/calculator.py', + ]); + }); + + it('feeds collectAgentEntries the same shape a picked directory does', async () => { + const picked = await pickedFromDataTransfer( + drop({ 'agent.yaml': 'name: calc', mcps: { 'calculator.py': 'print(1)' } }) + ); + + expect(collectAgentEntries(picked).map((entry) => entry.path)).toEqual([ + 'agent.yaml', + 'mcps/calculator.py', + ]); + }); + + it('does not descend into ignored directories', async () => { + const picked = await pickedFromDataTransfer( + drop({ + 'agent.yaml': 'name: calc', + __pycache__: { 'calculator.cpython-312.pyc': 'binary-ish' }, + node_modules: { 'left-pad': { 'index.js': 'x' } }, + }) + ); + + expect(picked.map((item) => item.relativePath)).toEqual(['calc-agent/agent.yaml']); + }); + + it('ignores non-file drag items', async () => { + const items = [{ kind: 'string', webkitGetAsEntry: () => null } as unknown as DataTransferItem]; + + await expect(pickedFromDataTransfer(items)).resolves.toEqual([]); + }); +}); diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/utils.ts b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/utils.ts new file mode 100644 index 0000000000..fe0be32b8a --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/utils.ts @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AGENT_CONFIG_FILENAME, + AGENT_SPEC_FILENAME, + FABRIC_CONFIG_FORMAT, + IGNORED_DIRECTORIES, + IGNORED_EXTENSIONS, + IGNORED_FILENAMES, + MAX_AGENT_SPEC_BYTES, + MAX_AGENT_SPEC_FILES, + MAX_PICKED_FILES, +} from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/const'; +import type { + PickedFile, + UploadAgentEntry, +} from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/type'; +import YAML from 'yaml'; + +/** Convention only — the Agent entity stores no reference to it. */ +export const agentSpecFilesetName = (agentName: string): string => `${agentName}-spec`; + +export const tooManyPickedFiles = (pickedCount: number): string | undefined => + pickedCount > MAX_PICKED_FILES + ? `That directory holds ${pickedCount.toLocaleString()} files, far more than an agent directory should. Point at the agent's own directory.` + : undefined; + +export const isIgnoredPath = (path: string): boolean => { + const segments = path.split('/'); + if (segments.some((segment) => IGNORED_DIRECTORIES.has(segment))) return true; + + const filename = segments[segments.length - 1] ?? ''; + if (IGNORED_FILENAMES.has(filename)) return true; + + return IGNORED_EXTENSIONS.some((extension) => filename.endsWith(extension)); +}; + +const pathCollator = new Intl.Collator(); + +/** The fileset holds the directory's contents, so the picked root is stripped from each path. */ +export const collectAgentEntries = (picked: PickedFile[]): UploadAgentEntry[] => { + const entries: UploadAgentEntry[] = []; + + for (const { file, relativePath } of picked) { + const path = relativePath.split('/').slice(1).join('/') || file.name; + if (!path || isIgnoredPath(path)) continue; + entries.push({ path, file }); + } + + return entries.sort((left, right) => pathCollator.compare(left.path, right.path)); +}; + +/** A directory picker reports the path on the File itself; a drop does not. */ +export const pickedFromFileList = (files: File[]): PickedFile[] => + files.map((file) => ({ file, relativePath: file.webkitRelativePath || file.name })); + +/** + * Walk dropped directories into files. + * + * `dataTransfer.files` flattens a dropped folder to a useless zero-byte entry, so the + * directory has to be traversed through `webkitGetAsEntry`. Paths are built during the + * walk because a File produced this way has an empty `webkitRelativePath`. + * + * Traversal stops once the ceiling is passed: a mistaken drop of a large tree is the + * same hazard as the equivalent pick, and here the reading is ours to abandon. + */ +export const pickedFromDataTransfer = async (items: DataTransferItem[]): Promise => { + const roots = items + .map((item) => (item.kind === 'file' ? item.webkitGetAsEntry() : null)) + .filter((entry): entry is FileSystemEntry => entry !== null); + + const picked: PickedFile[] = []; + const pending: FileSystemEntry[] = [...roots]; + + while (pending.length > 0 && picked.length <= MAX_PICKED_FILES) { + const entry = pending.shift(); + if (!entry) break; + + if (entry.isFile) { + const file = await readEntryFile(entry as FileSystemFileEntry); + if (file) picked.push({ file, relativePath: entry.fullPath.replace(/^\//, '') }); + continue; + } + + if (entry.isDirectory) { + if (isIgnoredPath(entry.name)) continue; + pending.push(...(await readDirectoryEntries(entry as FileSystemDirectoryEntry))); + } + } + + return picked; +}; + +const readEntryFile = (entry: FileSystemFileEntry): Promise => + new Promise((resolve) => entry.file(resolve, () => resolve(undefined))); + +const readDirectoryEntries = async ( + directory: FileSystemDirectoryEntry +): Promise => { + const reader = directory.createReader(); + const all: FileSystemEntry[] = []; + + // readEntries yields a batch at a time and signals completion with an empty batch. + for (;;) { + const batch = await new Promise((resolve) => + reader.readEntries(resolve, () => resolve([])) + ); + if (batch.length === 0) return all; + all.push(...batch); + if (all.length > MAX_PICKED_FILES) return all; + } +}; + +export const totalEntryBytes = (entries: UploadAgentEntry[]): number => + entries.reduce((total, entry) => total + entry.file.size, 0); + +export const validateAgentEntries = (entries: UploadAgentEntry[]): string | undefined => { + if (entries.length === 0) return 'That directory has no uploadable files.'; + + if (!entries.some((entry) => entry.path === AGENT_CONFIG_FILENAME)) { + return `No ${AGENT_CONFIG_FILENAME} at the top level of that directory.`; + } + + if (entries.length > MAX_AGENT_SPEC_FILES) { + return `That directory holds ${entries.length} files; the limit is ${MAX_AGENT_SPEC_FILES}. Point at a directory containing only the agent's own files.`; + } + + const bytes = totalEntryBytes(entries); + if (bytes > MAX_AGENT_SPEC_BYTES) { + return `That directory is ${Math.round(bytes / 1000)} KB; the limit is ${Math.round(MAX_AGENT_SPEC_BYTES / 1000)} KB. Point at a directory containing only the agent's own files.`; + } + + return undefined; +}; + +// Container deployments read every staged file as text and fail on a decode error. +export const findNonUtf8Path = async (entries: UploadAgentEntry[]): Promise => { + const decoder = new TextDecoder('utf-8', { fatal: true }); + + const offenders = await Promise.all( + entries.map(async (entry) => { + if (entry.path.split('/').pop() === AGENT_SPEC_FILENAME) return undefined; + try { + decoder.decode(await entry.file.arrayBuffer()); + return undefined; + } catch { + return entry.path; + } + }) + ); + + return offenders.find((path) => path !== undefined); +}; + +export class AgentConfigParseError extends Error {} + +export const parseAgentConfig = (text: string): Record => { + let parsed: unknown; + try { + parsed = YAML.parse(text); + } catch (error) { + throw new AgentConfigParseError( + `${AGENT_CONFIG_FILENAME} is not valid YAML: ${(error as Error).message}` + ); + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new AgentConfigParseError(`${AGENT_CONFIG_FILENAME} must contain a YAML mapping.`); + } + + const config = parsed as Record; + const configFormat = config.config_format; + if (configFormat !== FABRIC_CONFIG_FORMAT) { + throw new AgentConfigParseError( + `${AGENT_CONFIG_FILENAME} must set config_format: ${FABRIC_CONFIG_FORMAT}${ + typeof configFormat === 'string' ? ` (found ${configFormat})` : '' + }.` + ); + } + + return config; +}; + +export const agentNameFromConfig = (config: Record): string | undefined => + typeof config.name === 'string' && config.name.trim() ? config.name.trim() : undefined; diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx index 30d1bf07fa..d64c4012ff 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx @@ -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 { UploadAgentModal } from '@studio/routes/agents/AgentsListRoute/UploadAgentModal'; import { getAgentDetailRoute } from '@studio/routes/utils'; import { CircleAlert } from 'lucide-react'; import { type FC, useState } from 'react'; @@ -28,6 +29,7 @@ export const AgentsListRoute: FC = () => { const navigate = useNavigate(); const [createDeploymentAgent, setCreateDeploymentAgent] = useState(null); const [isCreateExampleOpen, setCreateExampleOpen] = useState(false); + const [isUploadOpen, setUploadOpen] = useState(false); const [cloneSource, setCloneSource] = useState(null); const [loadedAgents, setLoadedAgents] = useState([]); @@ -64,9 +66,14 @@ export const AgentsListRoute: FC = () => { slotHeading="Agents" slotDescription="View and manage AI agents and their deployments." slotActions={ - + + + + } /> { workspace={workspace} existingAgents={loadedAgents} /> + setUploadOpen(false)} + workspace={workspace} + /> setCloneSource(null)}