From 250277670029ff6cb34b5bf02142ceb78f2cc616 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 18 Aug 2026 14:40:42 -0700 Subject: [PATCH] refactor(ui): migrate agent forms to react-hook-form and shadcn (#37357) * refactor(ui): migrate agent forms to react-hook-form and shadcn Move the agent create wizard and the agent detail editor off antd Form onto react-hook-form with shadcn primitives. The two parents share three children (agent_form_fields, dynamic_agent_form_fields, cost_config_fields), so the whole form graph migrates in one commit. The submit payload is unchanged. antd validates and submits only fields that are currently mounted, and its Collapse panels mount lazily on first open and then stay mounted, so a payload depends on which panels the user ever expanded. react-hook-form keeps every registered value instead, so the panels track their own mounted set and the detail editor filters the never-opened panels back out before building the request. shouldUnregister stays off, since it drops values for collapsed panels rather than merely excluding them from submit. Tags, examples and forwarded header names move from antd tags-mode selects to a combobox in the kit. Base UI clears the combobox input on blur before the blur handler runs, so the pending text is committed from the input-clear reason, which is what antd did when the field lost focus. The agent type picker stays on antd Select: its popup content is not part of the form graph, and the Base UI popup opens a macrotask later, which the existing unit test cannot observe. * refactor(ui): use the shared PasswordInput in the agent forms * refactor(ui): drop the narration comments from the agent wizard --- .../agents/_components/AgentFormKit.tsx | 436 +++++++++ .../add_agent_form.integration.test.tsx | 354 +++++++ .../agents/_components/add_agent_form.tsx | 875 +++++++++--------- .../agents/_components/agent_form_fields.tsx | 598 +++++++----- .../agent_info.integration.test.tsx | 262 ++++++ .../agents/_components/agent_info.tsx | 201 ++-- .../agents/_components/cost_config_fields.tsx | 39 +- .../_components/dynamic_agent_form_fields.tsx | 202 ++-- 8 files changed, 2131 insertions(+), 836 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentFormKit.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.integration.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentFormKit.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentFormKit.tsx new file mode 100644 index 00000000000..e0152358f8c --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentFormKit.tsx @@ -0,0 +1,436 @@ +"use client"; + +import * as React from "react"; +import { ChevronRight, CircleHelp } from "lucide-react"; +import { + Controller, + useFormContext, + type FieldPath, + type RegisterOptions, + type ControllerProps, + type ControllerRenderProps, +} from "react-hook-form"; + +import { + Combobox, + ComboboxChip, + ComboboxChips, + ComboboxChipsInput, + ComboboxContent, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxValue, + useComboboxAnchor, +} from "@/components/ui/combobox"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Input } from "@/components/ui/input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/shared/form/field"; + +export interface AgentSkillFormValue { + id?: string; + name?: string; + description?: string; + tags?: string[]; + examples?: string[]; +} + +export interface StaticHeaderFormValue { + header?: string; + value?: string; +} + +export interface McpServerSelection { + servers?: string[]; + accessGroups?: string[]; + toolsets?: string[]; +} + +export type AgentFormFieldValue = + | string + | number + | boolean + | string[] + | AgentSkillFormValue[] + | StaticHeaderFormValue[] + | McpServerSelection + | Record + | null + | undefined; + +export interface AgentFormValues { + [credentialKey: string]: AgentFormFieldValue; + agent_name?: string; + name?: string; + display_name?: string; + description?: string; + url?: string; + version?: string; + protocolVersion?: string; + skills?: AgentSkillFormValue[]; + streaming?: boolean; + pushNotifications?: boolean; + stateTransitionHistory?: boolean; + iconUrl?: string; + documentationUrl?: string; + supportsAuthenticatedExtendedCard?: boolean; + model?: string; + make_public?: boolean; + cost_per_query?: string | number; + input_cost_per_token?: string | number; + output_cost_per_token?: string | number; + static_headers?: StaticHeaderFormValue[]; + extra_headers?: string[]; + tpm_limit?: number | null; + rpm_limit?: number | null; + session_tpm_limit?: number | null; + session_rpm_limit?: number | null; + team_id?: string; + guardrails?: string[]; + entitlement_models?: string[]; + entitlement_agents?: string[]; + allowed_mcp_servers_and_groups?: McpServerSelection; + mcp_tool_permissions?: Record; + defaultInputModes?: string[]; + defaultOutputModes?: string[]; + enable_tracing?: boolean; +} + +export type AgentFieldName = FieldPath; + +export const labelWithHint = (label: React.ReactNode, hint: string): React.ReactNode => ( + <> + {label} + + } /> + {hint} + + +); + +export type AgentFieldControlProps = ControllerRenderProps & { + id: string; + "aria-invalid": true | undefined; + "aria-describedby": string | undefined; +}; + +export interface AgentRequestPayload { + [key: string]: unknown; + agent_name?: string; + agent_card_params?: Record; + litellm_params?: Record; + object_permission?: Record; +} + +interface AgentFormFieldProps { + name: AgentFieldName; + label?: React.ReactNode; + description?: React.ReactNode; + defaultValue?: AgentFormFieldValue; + rules?: Omit, "valueAsNumber" | "valueAsDate" | "setValueAs">; + className?: string; + children: (control: AgentFieldControlProps) => React.ReactNode; +} + +export const AgentFormField = ({ + name, + label, + description, + defaultValue, + rules, + className, + children, +}: AgentFormFieldProps) => { + const { control } = useFormContext(); + const reactId = React.useId(); + const controlId = `${reactId}-control`; + const descriptionId = `${reactId}-description`; + const errorId = `${reactId}-error`; + + const renderField: ControllerProps["render"] = ({ field, fieldState }) => { + const invalid = fieldState.error !== undefined; + const describedBy = + [description !== undefined ? descriptionId : undefined, invalid ? errorId : undefined] + .filter((id): id is string => id !== undefined) + .join(" ") || undefined; + + return ( + + {label !== undefined && {label}} + {children({ + ...field, + id: controlId, + "aria-invalid": invalid || undefined, + "aria-describedby": describedBy, + })} + {description !== undefined && {description}} + + + ); + }; + + return ; +}; + +export interface CollapsiblePanelsState { + readonly openPanels: readonly string[]; + readonly mountedPanels: readonly string[]; + readonly toggle: (panelKey: string) => void; +} + +export const useCollapsiblePanels = (initiallyOpen: readonly string[]): CollapsiblePanelsState => { + const [openPanels, setOpenPanels] = React.useState(initiallyOpen); + const [mountedPanels, setMountedPanels] = React.useState(initiallyOpen); + + const toggle = React.useCallback((panelKey: string) => { + setOpenPanels((current) => + current.includes(panelKey) ? current.filter((key) => key !== panelKey) : [...current, panelKey], + ); + setMountedPanels((current) => (current.includes(panelKey) ? current : [...current, panelKey])); + }, []); + + return { openPanels, mountedPanels, toggle }; +}; + +interface AgentFormPanelProps { + panelKey: string; + title: string; + panels: CollapsiblePanelsState; + children: React.ReactNode; +} + +export const AgentFormPanel = ({ panelKey, title, panels, children }: AgentFormPanelProps) => ( + panels.toggle(panelKey)} + className="border-b border-border last:border-b-0" + > + + + {title} + + + {panels.mountedPanels.includes(panelKey) && {children}} + + +); + +export const omitFieldValues = (values: AgentFormValues, names: readonly string[]): AgentFormValues => + Object.fromEntries(Object.entries(values).filter(([key]) => !names.includes(key))); + +interface AgentNumberInputProps extends Omit { + value: AgentFormFieldValue; + onChange: (value: number | null) => void; + inputRef: AgentFieldControlProps["ref"]; + min?: number; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +export const AgentNumberInput = ({ value, onChange, onBlur, inputRef, min, ...props }: AgentNumberInputProps) => ( + event.currentTarget.blur()} + onChange={(event) => { + const raw = event.target.valueAsNumber; + onChange(Number.isNaN(raw) ? null : raw); + }} + onBlur={() => { + if (min !== undefined && typeof value === "number" && value < min) onChange(min); + onBlur(); + }} + /> +); + +export interface AgentSelectOption { + label: string; + value: string; +} + +const matchesQuery = (option: AgentSelectOption, query: string): boolean => + option.label.toLowerCase().includes(query.trim().toLowerCase()); + +const TAG_SEPARATOR = ","; + +interface AgentTagsInputProps { + id: string; + options?: readonly AgentSelectOption[]; + value: string[]; + onValueChange: (value: string[]) => void; + placeholder?: string; + emptyText?: string; + "aria-invalid"?: true | undefined; + "aria-describedby"?: string | undefined; +} + +export const AgentTagsInput = ({ + id, + options = [], + value, + onValueChange, + placeholder, + emptyText = "No matching options", + ...props +}: AgentTagsInputProps) => { + const anchor = useComboboxAnchor(); + const [query, setQuery] = React.useState(""); + const pendingRef = React.useRef(""); + + const selected = value.map((tag) => options.find((option) => option.value === tag) ?? { label: tag, value: tag }); + const pending = query.trim(); + const items = + pending.length > 0 && !options.some((option) => option.value === pending) + ? [{ label: pending, value: pending }, ...options] + : [...options]; + + const setPending = (next: string) => { + pendingRef.current = next; + setQuery(next); + }; + + const addTags = (tags: readonly string[]) => { + const additions = tags + .map((tag) => tag.trim()) + .filter(Boolean) + .filter((tag, index, all) => all.indexOf(tag) === index && !value.includes(tag)); + if (additions.length > 0) onValueChange([...value, ...additions]); + }; + + const handleInputValueChange = (next: string, details: { reason: string }) => { + if (details.reason === "input-clear") { + const committed = pendingRef.current; + setPending(""); + addTags([committed]); + return; + } + const parts = next.split(TAG_SEPARATOR); + setPending(parts[parts.length - 1] ?? ""); + addTags(parts.slice(0, -1)); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== "Enter" || event.currentTarget.getAttribute("aria-activedescendant")) return; + event.preventDefault(); + const committed = pendingRef.current; + setPending(""); + addTags([committed]); + }; + + return ( + { + setPending(""); + onValueChange(next.map((option) => option.value)); + }} + inputValue={query} + onInputValueChange={handleInputValueChange} + isItemEqualToValue={(option: AgentSelectOption, other: AgentSelectOption) => option.value === other.value} + itemToStringLabel={(option: AgentSelectOption) => option.label} + filter={matchesQuery} + openOnInputClick + > + } className="min-h-8 py-1 text-sm"> + + {(chips: AgentSelectOption[]) => ( + <> + {chips.map((option) => ( + + {option.label} + + ))} + + + )} + + + + {emptyText} + + {(option: AgentSelectOption) => ( + + {option.label} + + )} + + + + ); +}; + +interface AgentMultiSelectProps { + id: string; + options: readonly AgentSelectOption[]; + value: string[]; + onValueChange: (value: string[]) => void; + placeholder?: string; + emptyText?: string; + "aria-invalid"?: true | undefined; + "aria-describedby"?: string | undefined; +} + +export const AgentMultiSelect = ({ + id, + options, + value, + onValueChange, + placeholder, + emptyText = "No matching options", + ...props +}: AgentMultiSelectProps) => { + const anchor = useComboboxAnchor(); + const items = [...options]; + const selected = value.map((item) => items.find((option) => option.value === item) ?? { label: item, value: item }); + + return ( + onValueChange(next.map((option) => option.value))} + isItemEqualToValue={(option: AgentSelectOption, other: AgentSelectOption) => option.value === other.value} + itemToStringLabel={(option: AgentSelectOption) => option.label} + filter={matchesQuery} + openOnInputClick + > + } className="min-h-8 py-1 text-sm"> + + {(chips: AgentSelectOption[]) => ( + <> + {chips.map((option) => ( + + {option.label} + + ))} + + + )} + + + + {emptyText} + + {(option: AgentSelectOption) => ( + + {option.label} + + )} + + + + ); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx new file mode 100644 index 00000000000..b411649ea72 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx @@ -0,0 +1,354 @@ +import React from "react"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import AddAgentForm from "./add_agent_form"; +import * as networking from "@/components/networking"; +import type { AgentCreateInfo } from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + createAgentCall: vi.fn(), + getAgentCreateMetadata: vi.fn(), + getAgentsList: vi.fn(), + keyCreateForAgentCall: vi.fn(), + keyListCall: vi.fn(), + keyUpdateCall: vi.fn(), + modelAvailableCall: vi.fn(), +})); + +vi.mock("./agent_card_discovery", () => ({ default: () =>
})); +vi.mock("@/components/mcp_server_management/MCPServerSelector", () => ({ default: () =>
})); +vi.mock("@/components/mcp_server_management/MCPToolPermissions", () => ({ default: () =>
})); +vi.mock("@/components/guardrails/GuardrailSelector", () => ({ default: () =>
})); +vi.mock("@/components/common_components/team_dropdown", () => ({ default: () =>
})); + +const a2aInfo: AgentCreateInfo = { + agent_type: "a2a", + agent_type_display_name: "A2A Agent", + description: "Agent-to-agent protocol", + logo_url: "/ui/assets/logos/a2a_agent.png", + credential_fields: [], + use_a2a_form_fields: true, +}; + +const langgraphInfo: AgentCreateInfo = { + agent_type: "langgraph", + agent_type_display_name: "LangGraph", + description: "LangGraph platform", + logo_url: "/ui/assets/logos/langgraph.png", + use_a2a_form_fields: false, + litellm_params_template: { custom_llm_provider: "langgraph" }, + model_template: "langgraph/{assistant_id}", + credential_fields: [ + { key: "api_base", label: "API Base", field_type: "text", required: true, placeholder: "https://host" }, + { key: "assistant_id", label: "Assistant ID", field_type: "text", required: true, default_value: "" }, + { key: "api_key", label: "API Key", field_type: "password", required: false }, + ], +}; + +const renderForm = () => + render(); + +const panel = (name: RegExp) => screen.getByRole("button", { name }); + +const openAgentTypeMenu = async (user: ReturnType) => { + await user.click(screen.getAllByRole("combobox")[0]); +}; + +const createdPayload = () => vi.mocked(networking.createAgentCall).mock.calls[0][1] as Record; + +const goToLastStepAndCreate = async (user: ReturnType) => { + await user.click(await screen.findByRole("button", { name: /^Next/ })); + await user.click(await screen.findByRole("button", { name: /^Next/ })); + await user.click(await screen.findByRole("button", { name: /^Next/ })); + await user.click(await screen.findByRole("button", { name: /Create Agent/ })); + await waitFor(() => expect(networking.createAgentCall).toHaveBeenCalledTimes(1)); +}; + +const selectAgentType = async (user: ReturnType, label: string) => { + await openAgentTypeMenu(user); + await user.click(await screen.findByText(label)); +}; + +describe("AddAgentForm submit payload", () => { + beforeEach(() => { + vi.mocked(networking.getAgentCreateMetadata).mockReset().mockResolvedValue([a2aInfo, langgraphInfo]); + vi.mocked(networking.getAgentsList) + .mockReset() + .mockResolvedValue({ agents: [{ agent_id: "sub-1", agent_name: "Sub Agent One" }] }); + vi.mocked(networking.keyListCall).mockReset().mockResolvedValue({ keys: [] }); + vi.mocked(networking.modelAvailableCall) + .mockReset() + .mockResolvedValue({ data: [{ id: "gpt-4o" }] }); + vi.mocked(networking.createAgentCall) + .mockReset() + .mockResolvedValue({ agent_id: "agent-1", agent_name: "created-agent" } as never); + vi.mocked(networking.keyCreateForAgentCall) + .mockReset() + .mockResolvedValue({ key: "sk-new" } as never); + vi.mocked(networking.keyUpdateCall) + .mockReset() + .mockResolvedValue({} as never); + }); + + it("sends every a2a field the user filled across all collapsible panels", async () => { + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + renderForm(); + + await user.type(await screen.findByLabelText("Agent Name"), "support-agent"); + await user.type(screen.getByLabelText("Display Name"), "Support Agent"); + await user.type(screen.getByPlaceholderText("Describe what this agent does..."), "answers questions"); + await user.type(screen.getByLabelText("URL"), "http://localhost:9999/"); + await user.clear(screen.getByLabelText("Version")); + await user.type(screen.getByLabelText("Version"), "2.0.0"); + + await user.click(panel(/Skills/)); + await user.click(screen.getByRole("button", { name: /Add Skill/ })); + await user.type(await screen.findByLabelText("Skill ID"), "hello"); + await user.type(screen.getByLabelText("Skill Name"), "Hello"); + await user.type(screen.getByPlaceholderText("What this skill does"), "greets"); + await user.type(screen.getByLabelText("Tags"), "greeting,polite"); + await user.type(screen.getByLabelText("Examples"), "say hi"); + await user.click(screen.getByLabelText("Agent Name")); + + await user.click(panel(/Capabilities/)); + await user.click(await screen.findByRole("switch", { name: "Streaming" })); + await user.click(screen.getByRole("switch", { name: "Push Notifications" })); + + await user.click(panel(/Optional Settings/)); + await user.type(await screen.findByLabelText("Icon URL"), "https://example.com/icon.png"); + + await user.click(panel(/Cost Configuration/)); + await user.type(await screen.findByLabelText("Cost Per Query ($)"), "0.25"); + await user.type(screen.getByLabelText("Input Cost Per Token ($)"), "0.000002"); + + await user.click(panel(/LiteLLM Parameters/)); + await user.type(await screen.findByLabelText("Model (Optional)"), "gpt-4o"); + await user.click(screen.getByRole("switch", { name: "Make Public" })); + + await user.click(panel(/Authentication Headers/)); + await user.click(await screen.findByRole("button", { name: /Add Static Header/ })); + await user.type(await screen.findByPlaceholderText("Header name (e.g. Authorization)"), "X-Tenant"); + await user.type(screen.getByPlaceholderText("Value (e.g. Bearer token123)"), "acme"); + await user.type(screen.getByLabelText("Forward Client Headers"), "x-api-key,"); + await user.click(screen.getByLabelText("Agent Name")); + + await goToLastStepAndCreate(user); + + expect(createdPayload()).toEqual({ + agent_name: "support-agent", + agent_card_params: { + protocolVersion: "1.0", + name: "Support Agent", + description: "answers questions", + url: "http://localhost:9999/", + version: "2.0.0", + defaultInputModes: ["text"], + defaultOutputModes: ["text"], + capabilities: { streaming: true, pushNotifications: true }, + skills: [ + { + id: "hello", + name: "Hello", + description: "greets", + tags: ["greeting", "polite"], + examples: ["say hi"], + }, + ], + iconUrl: "https://example.com/icon.png", + }, + litellm_params: { + model: "gpt-4o", + make_public: true, + cost_per_query: 0.25, + input_cost_per_token: 0.000002, + }, + static_headers: { "X-Tenant": "acme" }, + extra_headers: ["x-api-key"], + }); + }); + + it("keeps a value typed into a panel the user collapsed again", async () => { + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + renderForm(); + + await user.type(await screen.findByLabelText("Agent Name"), "collapsed-agent"); + await user.type(screen.getByLabelText("Display Name"), "Collapsed"); + await user.type(screen.getByPlaceholderText("Describe what this agent does..."), "d"); + + await user.click(panel(/Cost Configuration/)); + await user.type(await screen.findByLabelText("Cost Per Query ($)"), "0.75"); + await user.click(panel(/Cost Configuration/)); + + await goToLastStepAndCreate(user); + + expect(createdPayload().litellm_params).toEqual({ cost_per_query: 0.75 }); + }); + + it("restores what was typed when a collapsed panel is expanded again", async () => { + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + renderForm(); + + await user.click(panel(/Cost Configuration/)); + await user.type(await screen.findByLabelText("Cost Per Query ($)"), "0.75"); + await user.click(panel(/Cost Configuration/)); + await user.click(panel(/Cost Configuration/)); + + expect(await screen.findByLabelText("Cost Per Query ($)")).toHaveValue(0.75); + }); + + it("blocks the first step until the required agent name is filled", async () => { + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + renderForm(); + + await screen.findByLabelText("Agent Name"); + await user.click(screen.getByRole("button", { name: /^Next/ })); + + expect(await screen.findByText("Please enter a unique agent name")).toBeInTheDocument(); + expect(screen.getByLabelText("Agent Name")).toBeInTheDocument(); + }); + + it("sends the custom agent shape when the custom type is picked", async () => { + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + renderForm(); + + await screen.findByLabelText("Agent Name"); + await openAgentTypeMenu(user); + await user.click(await screen.findByText("Custom / Other")); + + await user.type(await screen.findByLabelText("Agent Name"), "my-custom-agent"); + await user.type(screen.getByPlaceholderText("Describe what this agent does\u2026"), "custom thing"); + + await goToLastStepAndCreate(user); + + expect(createdPayload()).toEqual({ + agent_name: "my-custom-agent", + agent_card_params: { + protocolVersion: "1.0", + name: "my-custom-agent", + description: "custom thing", + url: "", + version: "1.0.0", + defaultInputModes: ["text"], + defaultOutputModes: ["text"], + capabilities: { streaming: false }, + skills: [], + }, + }); + }); + + it("sends credential fields and the model template for a dynamic agent type", async () => { + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + renderForm(); + + await screen.findByLabelText("Agent Name"); + await selectAgentType(user, "LangGraph"); + + await user.type(await screen.findByLabelText("Agent Name"), "lg-agent"); + await user.type(screen.getByPlaceholderText("Describe what this agent does..."), "graph agent"); + await user.type(screen.getByLabelText("API Base"), "https://lg.example.com"); + await user.type(screen.getByLabelText("Assistant ID"), "asst_1"); + await user.type(screen.getByLabelText("API Key"), "secret-value"); + + await goToLastStepAndCreate(user); + + expect(createdPayload()).toEqual({ + agent_name: "lg-agent", + agent_card_params: { + protocolVersion: "1.0", + name: "lg-agent", + description: "graph agent", + url: "https://lg.example.com", + version: "1.0.0", + defaultInputModes: ["text"], + defaultOutputModes: ["text"], + capabilities: { streaming: true }, + skills: [ + { + id: "chat", + name: "Chat", + description: "General chat capability", + tags: ["chat", "conversation"], + }, + ], + }, + litellm_params: { + custom_llm_provider: "langgraph", + api_base: "https://lg.example.com", + assistant_id: "asst_1", + api_key: "secret-value", + model: "langgraph/asst_1", + }, + }); + }); + + it("resets to the agent type that was selected before the switch", async () => { + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + renderForm(); + + await screen.findByLabelText("Agent Name"); + expect(screen.getByLabelText("Version")).toHaveValue("1.0.0"); + + await openAgentTypeMenu(user); + await user.click(await screen.findByText("Custom / Other")); + await screen.findByPlaceholderText("e.g. my-custom-agent"); + + await selectAgentType(user, "A2A Agent"); + + expect(await screen.findByLabelText("Version")).toHaveValue(""); + }); + + it("sends entitlements and rate limits gathered on the later steps", async () => { + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + renderForm(); + + await user.type(await screen.findByLabelText("Agent Name"), "entitled-agent"); + await user.type(screen.getByLabelText("Display Name"), "Entitled"); + await user.type(screen.getByPlaceholderText("Describe what this agent does..."), "d"); + await user.click(screen.getByRole("button", { name: /^Next/ })); + + await user.type(await screen.findByLabelText("Allowed Models"), "gpt-4o,"); + await user.keyboard("{Escape}"); + await user.click(screen.getByLabelText("Allowed Agents (Sub-Agents)")); + await user.click(await screen.findByTitle("Sub Agent One")); + await user.keyboard("{Escape}"); + await user.click(screen.getByText(/Configure which models, agents, and MCP tools/)); + await user.click(screen.getByRole("button", { name: /^Next/ })); + + await user.click((await screen.findAllByRole("switch"))[1]); + await user.type(screen.getByLabelText("TPM Limit"), "1000"); + await user.type(screen.getByLabelText("Session RPM Limit"), "20"); + await user.click(screen.getByRole("button", { name: /^Next/ })); + + await user.click(await screen.findByRole("button", { name: /Create Agent/ })); + await waitFor(() => expect(networking.createAgentCall).toHaveBeenCalledTimes(1)); + + const payload = createdPayload(); + expect(payload.tpm_limit).toBe(1000); + expect(payload.session_rpm_limit).toBe(20); + expect(payload.object_permission).toEqual({ models: ["gpt-4o"], agents: ["sub-1"] }); + expect(payload.litellm_params).toEqual({ require_trace_id_on_calls_by_agent: true }); + }); + + it("creates a key named after the agent once the agent is created", async () => { + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + renderForm(); + + await user.type(await screen.findByLabelText("Agent Name"), "keyed-agent"); + await user.type(screen.getByLabelText("Display Name"), "Keyed"); + await user.type(screen.getByPlaceholderText("Describe what this agent does..."), "d"); + + await goToLastStepAndCreate(user); + + expect(networking.keyCreateForAgentCall).toHaveBeenCalledWith( + "tok", + "agent-1", + "keyed-agent-key", + [], + undefined, + null, + ); + expect(await screen.findByText("Agent Created!")).toBeInTheDocument(); + expect(within(screen.getByText("Agent Created!").parentElement!).getByText("created-agent")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx index 7bd6b68a26d..da98eb8a352 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx @@ -1,10 +1,20 @@ import React, { useState, useEffect } from "react"; -import { Modal, Form, Select, Input, Steps, Radio, Tag, Divider, Switch, InputNumber } from "antd"; +import { Form, Modal, Select, Steps, Tag } from "antd"; +import { FormProvider, useForm, useWatch } from "react-hook-form"; import { toast } from "@/lib/toast"; import { Logo } from "@/components/molecules/logo/Logo"; import { Button } from "@tremor/react"; -import { CheckCircleFilled, KeyOutlined, RobotOutlined, AppstoreOutlined, InfoCircleOutlined } from "@ant-design/icons"; +import { CheckCircleFilled, KeyOutlined, RobotOutlined, AppstoreOutlined } from "@ant-design/icons"; import CreatedKeyDisplay from "@/components/shared/CreatedKeyDisplay"; +import { Button as ShadButton } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field"; +import { SearchSelect } from "@/components/shared/SearchSelect"; import { createAgentCall, getAgentCreateMetadata, @@ -23,7 +33,19 @@ import AgentFormFields from "./agent_form_fields"; import AgentCardDiscovery, { DiscoveredAgentCardSelection } from "./agent_card_discovery"; import { buildDiscoveryRequest, overlayDiscoveredCardParams } from "./agent_discovery_utils"; import DynamicAgentFormFields, { buildDynamicAgentData } from "./dynamic_agent_form_fields"; -import { getDefaultFormValues, buildAgentDataFromForm } from "./agent_config"; +import { PasswordInput } from "@/components/shared/PasswordInput"; +import { AGENT_FORM_CONFIG, getDefaultFormValues, buildAgentDataFromForm } from "./agent_config"; +import { + AgentFormField, + AgentFormValues, + AgentMultiSelect, + AgentNumberInput, + AgentRequestPayload, + AgentTagsInput, + McpServerSelection, + labelWithHint, + useCollapsiblePanels, +} from "./AgentFormKit"; import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector"; import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions"; import GuardrailSelector from "@/components/guardrails/GuardrailSelector"; @@ -32,6 +54,17 @@ const { Step } = Steps; const CUSTOM_AGENT_TYPE = "custom"; +const SHARED_INITIAL_VALUES: AgentFormValues = { + allowed_mcp_servers_and_groups: { servers: [], accessGroups: [] }, + mcp_tool_permissions: {}, + entitlement_models: [], + entitlement_agents: [], + guardrails: [], +}; + +const buildInitialValues = (agentType: string): AgentFormValues => + agentType === "a2a" ? { ...getDefaultFormValues(), ...SHARED_INITIAL_VALUES } : { ...SHARED_INITIAL_VALUES }; + interface AddAgentFormProps { visible: boolean; onClose: () => void; @@ -42,7 +75,8 @@ interface AddAgentFormProps { const AddAgentForm: React.FC = ({ visible, onClose, accessToken, onSuccess, teams }) => { const { userId, userRole } = useAuthorized(); - const [form] = Form.useForm(); + const form = useForm({ defaultValues: buildInitialValues("a2a") }); + const panels = useCollapsiblePanels([AGENT_FORM_CONFIG.basic.key]); const [currentStep, setCurrentStep] = useState(0); const [isSubmitting, setIsSubmitting] = useState(false); const [agentType, setAgentType] = useState("a2a"); @@ -52,7 +86,7 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok const [keyAssignOption, setKeyAssignOption] = useState<"create_new" | "existing_key" | "skip">("create_new"); const [newKeyName, setNewKeyName] = useState(""); const [newKeyModels, setNewKeyModels] = useState([]); - const [existingKeys, setExistingKeys] = useState([]); + const [existingKeys, setExistingKeys] = useState<{ token: string; key_alias?: string }[]>([]); const [selectedExistingKey, setSelectedExistingKey] = useState(null); const [loadingKeys, setLoadingKeys] = useState(false); const [availableModels, setAvailableModels] = useState([]); @@ -71,14 +105,10 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok const [maxIterations, setMaxIterations] = useState(null); const [maxBudgetPerSession, setMaxBudgetPerSession] = useState(null); - // Latest upstream card selection from auto-discovery (skills, capabilities, - // name, description). Dynamic agent forms don't render Form.Items for those - // fields, so we overlay this onto agent_card_params at submit. const [appliedDiscoveredSelection, setAppliedDiscoveredSelection] = useState( null, ); - // Fetch agent type metadata on mount useEffect(() => { const fetchMetadata = async () => { try { @@ -142,7 +172,12 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok .then((response) => { if (cancelled) return; const agents = response?.agents ?? []; - setAvailableAgents(agents.map((a: any) => ({ agent_id: a.agent_id, agent_name: a.agent_name }))); + setAvailableAgents( + agents.map((a: { agent_id: string; agent_name: string }) => ({ + agent_id: a.agent_id, + agent_name: a.agent_name, + })), + ); }) .catch((error) => { if (!cancelled) console.error("Error fetching agents:", error); @@ -159,7 +194,9 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok // Watch every form field so we can recompute the discovery plan whenever // the user types into a relevant credential field below. - const watchedFormValues = Form.useWatch([], form); + const watchedFormValues = useWatch({ control: form.control }); + const mcpSelection = useWatch({ control: form.control, name: "allowed_mcp_servers_and_groups" }); + const mcpToolPermissions = useWatch({ control: form.control, name: "mcp_tool_permissions" }); // Build the discovery plan for the proxy. Different agent runtimes publish // their cards at different URL shapes: @@ -177,25 +214,22 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok ); const handleNext = async () => { - try { - if (currentStep === 0) { - await form.validateFields(); - const agentName = form.getFieldValue("agent_name"); - if (agentName && !newKeyName) { - setNewKeyName(`${agentName}-key`); - } + if (currentStep === 0) { + const isValid = await form.trigger(); + if (!isValid) return; + const agentName = form.getValues("agent_name"); + if (agentName && !newKeyName) { + setNewKeyName(`${agentName}-key`); } - setCurrentStep((s) => s + 1); - } catch { - // validation failed — stay on current step } + setCurrentStep((s) => s + 1); }; const handleBack = () => { setCurrentStep((s) => Math.max(0, s - 1)); }; - const buildAgentData = (values: any) => { + const buildAgentData = (values: AgentFormValues): AgentRequestPayload | null => { if (agentType === CUSTOM_AGENT_TYPE) { return { agent_name: values.agent_name, @@ -213,29 +247,34 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok }; } - let agentData: Record; if (agentType === "a2a") { - agentData = buildAgentDataFromForm(values); - } else if (selectedAgentTypeInfo?.use_a2a_form_fields) { - agentData = buildAgentDataFromForm(values); - if (selectedAgentTypeInfo.litellm_params_template) { - agentData.litellm_params = { - ...agentData.litellm_params, - ...selectedAgentTypeInfo.litellm_params_template, - }; - } - for (const field of selectedAgentTypeInfo.credential_fields) { - const value = values[field.key]; - if (value && field.include_in_litellm_params !== false) { - agentData.litellm_params[field.key] = value; - } - } - } else if (selectedAgentTypeInfo) { - agentData = buildDynamicAgentData(values, selectedAgentTypeInfo); - } else { - return null; + return overlayDiscoveredCardParams(buildAgentDataFromForm(values), appliedDiscoveredSelection?.selected_card); } + if (!selectedAgentTypeInfo) return null; + + if (!selectedAgentTypeInfo.use_a2a_form_fields) { + return overlayDiscoveredCardParams( + buildDynamicAgentData(values, selectedAgentTypeInfo), + appliedDiscoveredSelection?.selected_card, + ); + } + + const agentData: AgentRequestPayload = buildAgentDataFromForm(values); + if (selectedAgentTypeInfo.litellm_params_template) { + agentData.litellm_params = { + ...agentData.litellm_params, + ...selectedAgentTypeInfo.litellm_params_template, + }; + } + const credentialParams = Object.fromEntries( + selectedAgentTypeInfo.credential_fields + .filter((field) => values[field.key] && field.include_in_litellm_params !== false) + .map((field) => [field.key, values[field.key]]), + ); + if (Object.keys(credentialParams).length > 0) { + agentData.litellm_params = { ...agentData.litellm_params, ...credentialParams }; + } return overlayDiscoveredCardParams(agentData, appliedDiscoveredSelection?.selected_card); }; @@ -247,8 +286,12 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok setIsSubmitting(true); try { - await form.validateFields(); - const values = { ...form.getFieldsValue(true) }; + const isValid = await form.trigger(); + if (!isValid) { + setIsSubmitting(false); + return; + } + const values = form.getValues(); const agentData = buildAgentData(values); if (!agentData) { toast.error("Failed to build agent data"); @@ -257,52 +300,35 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok } // Build object_permission from MCP Tools step (allowed_mcp_servers_and_groups, mcp_tool_permissions) - const mcpServersAndGroups = values.allowed_mcp_servers_and_groups; - const mcpToolPermissions = values.mcp_tool_permissions || {}; - const entitlementModels = values.entitlement_models || []; - const entitlementAgents = values.entitlement_agents || []; - const hasObjectPermission = - mcpServersAndGroups?.servers?.length > 0 || - mcpServersAndGroups?.accessGroups?.length > 0 || - Object.keys(mcpToolPermissions).length > 0 || - entitlementModels.length > 0 || - entitlementAgents.length > 0; - if (hasObjectPermission) { - agentData.object_permission = {}; - if (mcpServersAndGroups?.servers?.length > 0) { - agentData.object_permission.mcp_servers = mcpServersAndGroups.servers; - } - if (mcpServersAndGroups?.accessGroups?.length > 0) { - agentData.object_permission.mcp_access_groups = mcpServersAndGroups.accessGroups; - } - if (Object.keys(mcpToolPermissions).length > 0) { - agentData.object_permission.mcp_tool_permissions = mcpToolPermissions; - } - if (entitlementModels.length > 0) { - agentData.object_permission.models = entitlementModels; - } - if (entitlementAgents.length > 0) { - agentData.object_permission.agents = entitlementAgents; - } + const mcpServersAndGroups = values.allowed_mcp_servers_and_groups ?? {}; + const toolPermissions = values.mcp_tool_permissions ?? {}; + const entitlementModels = values.entitlement_models ?? []; + const entitlementAgents = values.entitlement_agents ?? []; + const objectPermission: Record = { + ...(mcpServersAndGroups.servers?.length ? { mcp_servers: mcpServersAndGroups.servers } : {}), + ...(mcpServersAndGroups.accessGroups?.length ? { mcp_access_groups: mcpServersAndGroups.accessGroups } : {}), + ...(Object.keys(toolPermissions).length ? { mcp_tool_permissions: toolPermissions } : {}), + ...(entitlementModels.length ? { models: entitlementModels } : {}), + ...(entitlementAgents.length ? { agents: entitlementAgents } : {}), + }; + if (Object.keys(objectPermission).length > 0) { + agentData.object_permission = objectPermission; } // Wire trace-id flags and budget controls into agent litellm_params (before create call) if (requireTraceIdInbound || requireTraceIdOutbound) { - if (!agentData.litellm_params) agentData.litellm_params = {}; - if (requireTraceIdInbound) { - agentData.litellm_params.require_trace_id_on_calls_to_agent = true; - } - if (requireTraceIdOutbound) { - agentData.litellm_params.require_trace_id_on_calls_by_agent = true; - if (maxIterations) agentData.litellm_params.max_iterations = maxIterations; - if (maxBudgetPerSession) agentData.litellm_params.max_budget_per_session = maxBudgetPerSession; - } + agentData.litellm_params = { + ...agentData.litellm_params, + ...(requireTraceIdInbound ? { require_trace_id_on_calls_to_agent: true } : {}), + ...(requireTraceIdOutbound ? { require_trace_id_on_calls_by_agent: true } : {}), + ...(requireTraceIdOutbound && maxIterations ? { max_iterations: maxIterations } : {}), + ...(requireTraceIdOutbound && maxBudgetPerSession ? { max_budget_per_session: maxBudgetPerSession } : {}), + }; } - const selectedGuardrails = values.guardrails || []; + const selectedGuardrails = values.guardrails ?? []; if (selectedGuardrails.length > 0) { - if (!agentData.litellm_params) agentData.litellm_params = {}; - agentData.litellm_params.guardrails = selectedGuardrails; + agentData.litellm_params = { ...agentData.litellm_params, guardrails: selectedGuardrails }; } const selectedTeamId = values.team_id || null; @@ -351,7 +377,7 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok }; const handleClose = () => { - form.resetFields(); + form.reset(buildInitialValues(agentType)); setAgentType("a2a"); setCurrentStep(0); setKeyAssignOption("create_new"); @@ -371,130 +397,127 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok const renderEntitlementsStep = () => (
-

+

Configure which models, agents, and MCP tools this agent is allowed to use. Leave fields empty to allow all (subject to key/team permissions).

- Allowed Models} - name="entitlement_models" - tooltip="Restrict which models this agent can call. Leave empty to allow all." - > - - ((option?.label as string) ?? "").toLowerCase().includes(input.toLowerCase()) - } - options={availableAgents.map((a) => ({ - label: a.agent_name, - value: a.agent_id, - }))} - /> - - - - - - Allowed MCP Servers{" "} - + + {({ id, value, onChange }) => ( + ({ label: getModelDisplayName(m), value: m }))} /> - - } - name="allowed_mcp_servers_and_groups" - initialValue={{ servers: [], accessGroups: [] }} - > - - form.setFieldValue("allowed_mcp_servers_and_groups", val) - } - value={form.getFieldValue("allowed_mcp_servers_and_groups") || { servers: [], accessGroups: [] }} - accessToken={accessToken ?? ""} - placeholder="Select MCP servers or access groups (optional)" - /> - - - - prev.allowed_mcp_servers_and_groups !== curr.allowed_mcp_servers_and_groups || - prev.mcp_tool_permissions !== curr.mcp_tool_permissions - } - > - {() => ( -
- + + + {({ id, value, onChange }) => ( + ({ label: a.agent_name, value: a.agent_id }))} + /> + )} + + + + + + {({ value, onChange }) => ( + ) => - form.setFieldsValue({ mcp_tool_permissions: toolPerms }) - } + placeholder="Select MCP servers or access groups (optional)" /> -
- )} -
+ )} + + + +
+ ) => form.setValue("mcp_tool_permissions", toolPerms)} + /> +
); + const rateLimitField = (name: keyof AgentFormValues & string, label: string, placeholder: string) => ( + + {({ value, onChange, ref, ...control }) => ( + + )} + + ); + const renderObservabilityStep = () => (
-

Tracing

+

Tracing

- + Require x-litellm-trace-id on calls TO this agent -

+

Only accept this agent being invoked with a trace-id (e.g. when used as a sub-agent).

- +
- + Require x-litellm-trace-id on calls BY this agent -

+

Requires LLM/MCP calls made by this agent to include x-litellm-trace-id for session tracking.

{ + onCheckedChange={(checked) => { setRequireTraceIdOutbound(checked); if (!checked) { setMaxIterations(null); @@ -506,96 +529,99 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok
- +
-

Budgets & Rate Limits

+

Budgets & Rate Limits

{!requireTraceIdOutbound && ( -
+
Enable "Require x-litellm-trace-id on calls BY this agent" in Tracing to configure budgets and rate limits.
)} -
Session Budgets
+
Session Budgets
-
- - + Max Iterations + setMaxIterations(val)} + value={maxIterations ?? ""} + onChange={(event) => + setMaxIterations(Number.isNaN(event.target.valueAsNumber) ? null : event.target.valueAsNumber) + } + onBlur={() => setMaxIterations((current) => (current !== null && current < 1 ? 1 : current))} /> -

Hard cap on LLM calls per session

-
-
- - Hard cap on LLM calls per session

+ + + Max Budget Per Session ($) + setMaxBudgetPerSession(val)} + value={maxBudgetPerSession ?? ""} + onChange={(event) => + setMaxBudgetPerSession(Number.isNaN(event.target.valueAsNumber) ? null : event.target.valueAsNumber) + } + onBlur={() => + setMaxBudgetPerSession((current) => (current !== null && current < 0.01 ? 0.01 : current)) + } /> -

Max spend per trace before returning 429

-
+

Max spend per trace before returning 429

+
- + -
Agent Rate Limits
-

Global rate limits applied across all callers of this agent.

+
Agent Rate Limits
+

Global rate limits applied across all callers of this agent.

- - - - - - + {rateLimitField("tpm_limit", "TPM Limit", "e.g. 100000")} + {rateLimitField("rpm_limit", "RPM Limit", "e.g. 100")}
-
Per-Session Rate Limits
-

+

Per-Session Rate Limits
+

Rate limits per session (x-litellm-trace-id). Each session gets its own counters.

- - - - - - + {rateLimitField("session_tpm_limit", "Session TPM Limit", "e.g. 10000")} + {rateLimitField("session_rpm_limit", "Session RPM Limit", "e.g. 20")}
- +
-

Guardrails

-

+

Guardrails

+

Apply guardrails to this agent. Selected guardrails will run on all calls made by this agent.

- - form.setFieldsValue({ guardrails: selected })} - /> - + + {({ value, onChange }) => ( + + )} +
); const handleAgentTypeChange = (value: string) => { setAgentType(value); - form.resetFields(); + form.reset(buildInitialValues(agentType)); // Discovery selections are tied to a specific agent type's URL shape; // switching types invalidates them. setAppliedDiscoveredSelection(null); @@ -604,12 +630,6 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok // Apply a discovered agent card to the form so the rest of Step 1 (skills, // capabilities, name, description, URL) reflects what the user picked. The // proxy re-applies its own merge at registration; we only seed defaults here. - // - // AntD's `setFieldsValue` silently ignores keys whose Form.Item isn't - // registered, so this is safe across all agent types — A2A forms pick up - // every field below; LangGraph and other dynamic forms only pick up the - // shared ones (`agent_name`, `description`, plus any credential field whose - // key looks URL-ish). const handleApplyDiscoveredCard = (selection: DiscoveredAgentCardSelection | null) => { setAppliedDiscoveredSelection(selection); if (!selection) return; @@ -622,10 +642,14 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok examples: s.examples ?? [], })); - const currentAgentName = form.getFieldValue("agent_name"); + const currentAgentName = form.getValues("agent_name"); const seededAgentName = currentAgentName || selected_card.name || selected_card.provider?.organization || ""; - const fieldsToSet: Record = { + const urlCredentialKeys = (selectedAgentTypeInfo?.credential_fields ?? []) + .map((f) => f.key) + .filter((key) => /(^|_)(url|api_base|endpoint)$/i.test(key)); + + const fieldsToSet: AgentFormValues = { agent_name: seededAgentName, name: selected_card.name, description: selected_card.description, @@ -636,20 +660,13 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok skills, iconUrl: selected_card.iconUrl, documentationUrl: selected_card.documentationUrl, + ...Object.fromEntries(urlCredentialKeys.map((key) => [key, upstream_url])), }; - // For dynamic agent types (e.g. LangGraph), the URL lives in a - // type-specific credential field. Match on common naming variants so the - // user doesn't have to re-paste the URL they already typed above. - const urlCredentialKeys = (selectedAgentTypeInfo?.credential_fields ?? []) - .map((f) => f.key) - .filter((key) => /(^|_)(url|api_base|endpoint)$/i.test(key)); - for (const key of urlCredentialKeys) { - fieldsToSet[key] = upstream_url; + for (const [key, value] of Object.entries(fieldsToSet)) { + form.setValue(key, value); } - form.setFieldsValue(fieldsToSet); - if (!newKeyName && seededAgentName) { setNewKeyName(`${seededAgentName}-key`); } @@ -663,7 +680,7 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok const renderConfigureStep = () => ( <> Agent Type} + label={Agent Type} required tooltip="Select the type of agent you want to create" > @@ -676,25 +693,29 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok dropdownRender={(menu) => ( <> {menu} - +
-
Not listed?
+
+ Not listed? +
handleAgentTypeChange(CUSTOM_AGENT_TYPE)} > - +
- Custom / Other + Custom / Other GENERIC
-
- For agents that don't follow a standard protocol — just needs a virtual key +
+ For agents that don't follow a standard protocol, just needs a virtual key
@@ -708,16 +729,16 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok value={info.agent_type} label={
- + {info.agent_type_display_name}
} >
- +
{info.agent_type_display_name}
- {info.description &&
{info.description}
} + {info.description &&
{info.description}
}
@@ -727,49 +748,77 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok
{agentType === CUSTOM_AGENT_TYPE ? ( -
- - - - - - -
+ + + {({ value, onChange, ref, ...control }) => ( + + )} + + + {({ value, onChange, ref, ...control }) => ( +