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
This commit is contained in:
yuneng-jiang 2026-08-18 14:40:42 -07:00 • committed by GitHub
parent 3c82791968
commit 2502776700
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 2131 additions and 836 deletions

View file

@ -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<string, string[]>
| 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<string, string[]>;
defaultInputModes?: string[];
defaultOutputModes?: string[];
enable_tracing?: boolean;
}
export type AgentFieldName = FieldPath<AgentFormValues>;
export const labelWithHint = (label: React.ReactNode, hint: string): React.ReactNode => (
<>
{label}
<Tooltip>
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
</>
);
export type AgentFieldControlProps = ControllerRenderProps<AgentFormValues, AgentFieldName> & {
id: string;
"aria-invalid": true | undefined;
"aria-describedby": string | undefined;
};
export interface AgentRequestPayload {
[key: string]: unknown;
agent_name?: string;
agent_card_params?: Record<string, unknown>;
litellm_params?: Record<string, unknown>;
object_permission?: Record<string, unknown>;
}
interface AgentFormFieldProps {
name: AgentFieldName;
label?: React.ReactNode;
description?: React.ReactNode;
defaultValue?: AgentFormFieldValue;
rules?: Omit<RegisterOptions<AgentFormValues, AgentFieldName>, "valueAsNumber" | "valueAsDate" | "setValueAs">;
className?: string;
children: (control: AgentFieldControlProps) => React.ReactNode;
}
export const AgentFormField = ({
name,
label,
description,
defaultValue,
rules,
className,
children,
}: AgentFormFieldProps) => {
const { control } = useFormContext<AgentFormValues>();
const reactId = React.useId();
const controlId = `${reactId}-control`;
const descriptionId = `${reactId}-description`;
const errorId = `${reactId}-error`;
const renderField: ControllerProps<AgentFormValues, AgentFieldName>["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 (
<Field data-invalid={invalid || undefined} className={className}>
{label !== undefined && <FieldLabel htmlFor={controlId}>{label}</FieldLabel>}
{children({
...field,
id: controlId,
"aria-invalid": invalid || undefined,
"aria-describedby": describedBy,
})}
{description !== undefined && <FieldDescription id={descriptionId}>{description}</FieldDescription>}
<FieldError id={errorId} errors={[fieldState.error]} />
</Field>
);
};
return <Controller control={control} name={name} defaultValue={defaultValue} rules={rules} render={renderField} />;
};
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<readonly string[]>(initiallyOpen);
const [mountedPanels, setMountedPanels] = React.useState<readonly string[]>(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) => (
<Collapsible
open={panels.openPanels.includes(panelKey)}
onOpenChange={() => panels.toggle(panelKey)}
className="border-b border-border last:border-b-0"
>
<CollapsibleTrigger className="group flex w-full items-center gap-2 py-3 text-left text-sm font-medium text-foreground">
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-data-panel-open:rotate-90" />
{title}
</CollapsibleTrigger>
<CollapsibleContent keepMounted>
{panels.mountedPanels.includes(panelKey) && <FieldGroup className="pt-1 pb-5">{children}</FieldGroup>}
</CollapsibleContent>
</Collapsible>
);
export const omitFieldValues = (values: AgentFormValues, names: readonly string[]): AgentFormValues =>
Object.fromEntries(Object.entries(values).filter(([key]) => !names.includes(key)));
interface AgentNumberInputProps extends Omit<AgentFieldControlProps, "value" | "onChange" | "ref"> {
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) => (
<Input
{...props}
ref={inputRef}
type="number"
step="any"
value={typeof value === "number" ? value : ""}
onWheel={(event) => 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<HTMLInputElement>) => {
if (event.key !== "Enter" || event.currentTarget.getAttribute("aria-activedescendant")) return;
event.preventDefault();
const committed = pendingRef.current;
setPending("");
addTags([committed]);
};
return (
<Combobox
multiple
items={items}
value={selected}
onValueChange={(next: AgentSelectOption[]) => {
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
>
<ComboboxChips render={<div ref={anchor} />} className="min-h-8 py-1 text-sm">
<ComboboxValue>
{(chips: AgentSelectOption[]) => (
<>
{chips.map((option) => (
<ComboboxChip key={option.value} aria-label={option.label}>
{option.label}
</ComboboxChip>
))}
<ComboboxChipsInput
id={id}
placeholder={placeholder}
className="min-w-24"
onKeyDown={handleKeyDown}
{...props}
/>
</>
)}
</ComboboxValue>
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
<ComboboxList>
{(option: AgentSelectOption) => (
<ComboboxItem key={option.value} value={option} title={option.label}>
{option.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
};
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 (
<Combobox
multiple
items={items}
value={selected}
onValueChange={(next: AgentSelectOption[]) => onValueChange(next.map((option) => option.value))}
isItemEqualToValue={(option: AgentSelectOption, other: AgentSelectOption) => option.value === other.value}
itemToStringLabel={(option: AgentSelectOption) => option.label}
filter={matchesQuery}
openOnInputClick
>
<ComboboxChips render={<div ref={anchor} />} className="min-h-8 py-1 text-sm">
<ComboboxValue>
{(chips: AgentSelectOption[]) => (
<>
{chips.map((option) => (
<ComboboxChip key={option.value} aria-label={option.label}>
{option.label}
</ComboboxChip>
))}
<ComboboxChipsInput id={id} placeholder={placeholder} className="min-w-24" {...props} />
</>
)}
</ComboboxValue>
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
<ComboboxList>
{(option: AgentSelectOption) => (
<ComboboxItem key={option.value} value={option} title={option.label}>
{option.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
};

View file

@ -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: () => <div data-testid="agent-card-discovery" /> }));
vi.mock("@/components/mcp_server_management/MCPServerSelector", () => ({ default: () => <div /> }));
vi.mock("@/components/mcp_server_management/MCPToolPermissions", () => ({ default: () => <div /> }));
vi.mock("@/components/guardrails/GuardrailSelector", () => ({ default: () => <div /> }));
vi.mock("@/components/common_components/team_dropdown", () => ({ default: () => <div /> }));
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(<AddAgentForm visible={true} onClose={vi.fn()} accessToken="tok" onSuccess={vi.fn()} />);
const panel = (name: RegExp) => screen.getByRole("button", { name });
const openAgentTypeMenu = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getAllByRole("combobox")[0]);
};
const createdPayload = () => vi.mocked(networking.createAgentCall).mock.calls[0][1] as Record<string, unknown>;
const goToLastStepAndCreate = async (user: ReturnType<typeof userEvent.setup>) => {
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<typeof userEvent.setup>, 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();
});
});

View file

@ -1,266 +1,400 @@
import React from "react";
import { Form, Input, Switch, Collapse, Select, Space, Tooltip } from "antd";
import { Button as AntButton } from "antd";
import { PlusOutlined, MinusCircleOutlined, InfoCircleOutlined } from "@ant-design/icons";
import { useFieldArray, useFormContext } from "react-hook-form";
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Field, FieldGroup, FieldTitle } from "@/components/shared/form/field";
import { AGENT_FORM_CONFIG, SKILL_FIELD_CONFIG } from "./agent_config";
import CostConfigFields, { COST_FIELD_NAMES } from "./cost_config_fields";
import {
AgentFormField,
AgentFormPanel,
AgentFormValues,
AgentTagsInput,
CollapsiblePanelsState,
labelWithHint,
} from "./AgentFormKit";
import CostConfigFields from "./cost_config_fields";
const AUTH_HEADERS_PANEL_KEY = "auth_headers";
const { Panel } = Collapse;
const namesOf = (fields: readonly { name: string }[]): readonly string[] => fields.map((field) => field.name);
export const A2A_PANEL_FIELD_NAMES: Readonly<Record<string, readonly string[]>> = {
[AGENT_FORM_CONFIG.basic.key]: namesOf(AGENT_FORM_CONFIG.basic.fields),
[AGENT_FORM_CONFIG.skills.key]: ["skills"],
[AGENT_FORM_CONFIG.capabilities.key]: namesOf(AGENT_FORM_CONFIG.capabilities.fields),
[AGENT_FORM_CONFIG.optional.key]: namesOf(AGENT_FORM_CONFIG.optional.fields),
[AGENT_FORM_CONFIG.cost.key]: COST_FIELD_NAMES,
[AGENT_FORM_CONFIG.litellm.key]: namesOf(AGENT_FORM_CONFIG.litellm.fields),
[AUTH_HEADERS_PANEL_KEY]: ["static_headers", "extra_headers"],
};
export const unmountedA2AFieldNames = (mountedPanels: readonly string[]): readonly string[] =>
Object.entries(A2A_PANEL_FIELD_NAMES)
.filter(([panelKey]) => !mountedPanels.includes(panelKey))
.flatMap(([, fieldNames]) => fieldNames);
interface AgentFormFieldsProps {
panels: CollapsiblePanelsState;
showAgentName?: boolean;
visiblePanels?: string[];
}
/**
* Reusable form fields component for agent forms
* Uses shared configuration from agent_config.ts
*/
const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ showAgentName = true, visiblePanels }) => {
const SkillsFieldArray = () => {
const { control } = useFormContext<AgentFormValues>();
const { fields, append, remove } = useFieldArray({ control, name: "skills" });
return (
<>
{fields.map((item, index) => (
<div key={item.id} className="rounded-md border border-border p-4">
<FieldGroup>
<AgentFormField
name={`skills.${index}.id`}
label={SKILL_FIELD_CONFIG.id.label}
rules={SKILL_FIELD_CONFIG.id.required ? { required: "Required" } : undefined}
>
{({ value, onChange, ref, ...control }) => (
<Input
{...control}
ref={ref}
placeholder={SKILL_FIELD_CONFIG.id.placeholder}
value={typeof value === "string" ? value : ""}
onChange={onChange}
/>
)}
</AgentFormField>
<AgentFormField
name={`skills.${index}.name`}
label={SKILL_FIELD_CONFIG.name.label}
rules={SKILL_FIELD_CONFIG.name.required ? { required: "Required" } : undefined}
>
{({ value, onChange, ref, ...control }) => (
<Input
{...control}
ref={ref}
placeholder={SKILL_FIELD_CONFIG.name.placeholder}
value={typeof value === "string" ? value : ""}
onChange={onChange}
/>
)}
</AgentFormField>
<AgentFormField
name={`skills.${index}.description`}
label={SKILL_FIELD_CONFIG.description.label}
rules={SKILL_FIELD_CONFIG.description.required ? { required: "Required" } : undefined}
>
{({ value, onChange, ref, ...control }) => (
<Textarea
{...control}
ref={ref}
rows={SKILL_FIELD_CONFIG.description.rows}
placeholder={SKILL_FIELD_CONFIG.description.placeholder}
value={typeof value === "string" ? value : ""}
onChange={onChange}
/>
)}
</AgentFormField>
<AgentFormField
name={`skills.${index}.tags`}
label={SKILL_FIELD_CONFIG.tags.label}
rules={SKILL_FIELD_CONFIG.tags.required ? { required: "Required" } : undefined}
>
{({ id, value, onChange }) => (
<AgentTagsInput
id={id}
value={Array.isArray(value) ? (value as string[]) : []}
onValueChange={onChange}
placeholder={SKILL_FIELD_CONFIG.tags.placeholder}
/>
)}
</AgentFormField>
<AgentFormField name={`skills.${index}.examples`} label={SKILL_FIELD_CONFIG.examples.label}>
{({ id, value, onChange }) => (
<AgentTagsInput
id={id}
value={Array.isArray(value) ? (value as string[]) : []}
onValueChange={onChange}
placeholder={SKILL_FIELD_CONFIG.examples.placeholder}
/>
)}
</AgentFormField>
</FieldGroup>
<Button
type="button"
variant="ghost"
className="mt-4 text-destructive hover:text-destructive"
onClick={() => remove(index)}
>
<Trash2 />
Remove Skill
</Button>
</div>
))}
<Button type="button" variant="outline" className="w-full border-dashed" onClick={() => append({})}>
<Plus />
Add Skill
</Button>
</>
);
};
const StaticHeadersFieldArray = () => {
const { control } = useFormContext<AgentFormValues>();
const { fields, append, remove } = useFieldArray({ control, name: "static_headers" });
return (
<>
{fields.map((item, index) => (
<div key={item.id} className="flex items-start gap-2">
<AgentFormField name={`static_headers.${index}.header`} rules={{ required: "Header name required" }}>
{({ value, onChange, ref, ...control }) => (
<Input
{...control}
ref={ref}
className="w-55"
placeholder="Header name (e.g. Authorization)"
value={typeof value === "string" ? value : ""}
onChange={onChange}
/>
)}
</AgentFormField>
<AgentFormField name={`static_headers.${index}.value`} rules={{ required: "Value required" }}>
{({ value, onChange, ref, ...control }) => (
<Input
{...control}
ref={ref}
className="w-65"
placeholder="Value (e.g. Bearer token123)"
value={typeof value === "string" ? value : ""}
onChange={onChange}
/>
)}
</AgentFormField>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Remove static header"
className="text-destructive hover:text-destructive"
onClick={() => remove(index)}
>
<Trash2 />
</Button>
</div>
))}
<Button type="button" variant="outline" className="w-full border-dashed" onClick={() => append({})}>
<Plus />
Add Static Header
</Button>
</>
);
};
const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName = true, visiblePanels }) => {
const shouldShow = (key: string) => !visiblePanels || visiblePanels.includes(key);
return (
<>
{showAgentName && (
<Form.Item
label="Agent Name"
name="agent_name"
rules={[{ required: true, message: "Please enter a unique agent name" }]}
tooltip="Unique identifier for the agent"
>
<Input placeholder="e.g., customer-support-agent" />
</Form.Item>
<FieldGroup className="mb-4">
<AgentFormField
name="agent_name"
label={labelWithHint("Agent Name", "Unique identifier for the agent")}
rules={{ required: "Please enter a unique agent name" }}
>
{({ value, onChange, ref, ...control }) => (
<Input
{...control}
ref={ref}
placeholder="e.g., customer-support-agent"
value={typeof value === "string" ? value : ""}
onChange={onChange}
/>
)}
</AgentFormField>
</FieldGroup>
)}
<Collapse defaultActiveKey={["basic"]} style={{ marginBottom: 16 }}>
{/* Basic Information */}
<div className="mb-4 rounded-md border border-border px-4">
{shouldShow(AGENT_FORM_CONFIG.basic.key) && (
<Panel header={`${AGENT_FORM_CONFIG.basic.title} (Required)`} key={AGENT_FORM_CONFIG.basic.key}>
<AgentFormPanel
panelKey={AGENT_FORM_CONFIG.basic.key}
title={`${AGENT_FORM_CONFIG.basic.title} (Required)`}
panels={panels}
>
{AGENT_FORM_CONFIG.basic.fields.map((field) => (
<Form.Item
<AgentFormField
key={field.name}
label={field.label}
name={field.name}
rules={
field.required
? [{ required: true, message: `Please enter ${field.label.toLowerCase()}` }]
: undefined
}
tooltip={field.tooltip}
extra={field.helpText}
label={field.tooltip ? labelWithHint(field.label, field.tooltip) : field.label}
description={field.helpText}
rules={field.required ? { required: `Please enter ${field.label.toLowerCase()}` } : undefined}
>
{field.type === "textarea" ? (
<Input.TextArea rows={field.rows} placeholder={field.placeholder} />
) : field.type === "select" ? (
<Select placeholder={field.placeholder}>
{(field.options ?? []).map((opt) => (
<Select.Option key={opt} value={opt}>
{opt}
</Select.Option>
))}
</Select>
) : (
<Input placeholder={field.placeholder} />
)}
</Form.Item>
{({ value, onChange, ref, ...control }) => {
const text = typeof value === "string" ? value : "";
if (field.type === "textarea") {
return (
<Textarea
{...control}
ref={ref}
rows={field.rows}
placeholder={field.placeholder}
value={text}
onChange={onChange}
/>
);
}
if (field.type === "select") {
return (
<Select value={text || null} onValueChange={onChange}>
<SelectTrigger {...control} className="w-full">
<SelectValue placeholder={field.placeholder} />
</SelectTrigger>
<SelectContent>
{(field.options ?? []).map((option) => (
<SelectItem key={option} value={option} title={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
return (
<Input {...control} ref={ref} placeholder={field.placeholder} value={text} onChange={onChange} />
);
}}
</AgentFormField>
))}
</Panel>
</AgentFormPanel>
)}
{/* Skills */}
{shouldShow(AGENT_FORM_CONFIG.skills.key) && (
<Panel header={`${AGENT_FORM_CONFIG.skills.title}`} key={AGENT_FORM_CONFIG.skills.key}>
<Form.List name="skills">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<div
key={field.key}
style={{ marginBottom: 16, padding: 16, border: "1px solid #d9d9d9", borderRadius: 4 }}
>
<Form.Item
{...field}
label={SKILL_FIELD_CONFIG.id.label}
name={[field.name, "id"]}
rules={[{ required: SKILL_FIELD_CONFIG.id.required, message: "Required" }]}
>
<Input placeholder={SKILL_FIELD_CONFIG.id.placeholder} />
</Form.Item>
<Form.Item
{...field}
label={SKILL_FIELD_CONFIG.name.label}
name={[field.name, "name"]}
rules={[{ required: SKILL_FIELD_CONFIG.name.required, message: "Required" }]}
>
<Input placeholder={SKILL_FIELD_CONFIG.name.placeholder} />
</Form.Item>
<Form.Item
{...field}
label={SKILL_FIELD_CONFIG.description.label}
name={[field.name, "description"]}
rules={[{ required: SKILL_FIELD_CONFIG.description.required, message: "Required" }]}
>
<Input.TextArea
rows={SKILL_FIELD_CONFIG.description.rows}
placeholder={SKILL_FIELD_CONFIG.description.placeholder}
/>
</Form.Item>
<Form.Item
{...field}
label={SKILL_FIELD_CONFIG.tags.label}
name={[field.name, "tags"]}
rules={[{ required: SKILL_FIELD_CONFIG.tags.required, message: "Required" }]}
>
<Select
mode="tags"
style={{ width: "100%" }}
tokenSeparators={[","]}
placeholder={SKILL_FIELD_CONFIG.tags.placeholder}
/>
</Form.Item>
<Form.Item {...field} label={SKILL_FIELD_CONFIG.examples.label} name={[field.name, "examples"]}>
<Select
mode="tags"
style={{ width: "100%" }}
tokenSeparators={[","]}
placeholder={SKILL_FIELD_CONFIG.examples.placeholder}
/>
</Form.Item>
<AntButton type="link" danger onClick={() => remove(field.name)} icon={<MinusCircleOutlined />}>
Remove Skill
</AntButton>
</div>
))}
<AntButton type="dashed" onClick={() => add()} icon={<PlusOutlined />} style={{ width: "100%" }}>
Add Skill
</AntButton>
</>
)}
</Form.List>
</Panel>
<AgentFormPanel
panelKey={AGENT_FORM_CONFIG.skills.key}
title={AGENT_FORM_CONFIG.skills.title}
panels={panels}
>
<SkillsFieldArray />
</AgentFormPanel>
)}
{/* Capabilities */}
{shouldShow(AGENT_FORM_CONFIG.capabilities.key) && (
<Panel header={AGENT_FORM_CONFIG.capabilities.title} key={AGENT_FORM_CONFIG.capabilities.key}>
<AgentFormPanel
panelKey={AGENT_FORM_CONFIG.capabilities.key}
title={AGENT_FORM_CONFIG.capabilities.title}
panels={panels}
>
{AGENT_FORM_CONFIG.capabilities.fields.map((field) => (
<Form.Item key={field.name} label={field.label} name={field.name} valuePropName="checked">
<Switch />
</Form.Item>
))}
</Panel>
)}
{/* Optional Settings */}
{shouldShow(AGENT_FORM_CONFIG.optional.key) && (
<Panel header={AGENT_FORM_CONFIG.optional.title} key={AGENT_FORM_CONFIG.optional.key}>
{AGENT_FORM_CONFIG.optional.fields.map((field) => (
<Form.Item
key={field.name}
label={field.label}
name={field.name}
valuePropName={field.type === "switch" ? "checked" : undefined}
>
{field.type === "switch" ? <Switch /> : <Input placeholder={field.placeholder} />}
</Form.Item>
))}
</Panel>
)}
{/* Cost Configuration */}
{shouldShow(AGENT_FORM_CONFIG.cost.key) && (
<Panel header={AGENT_FORM_CONFIG.cost.title} key={AGENT_FORM_CONFIG.cost.key}>
<CostConfigFields />
</Panel>
)}
{/* LiteLLM Parameters */}
{shouldShow(AGENT_FORM_CONFIG.litellm.key) && (
<Panel header={AGENT_FORM_CONFIG.litellm.title} key={AGENT_FORM_CONFIG.litellm.key}>
{AGENT_FORM_CONFIG.litellm.fields.map((field) => (
<Form.Item
key={field.name}
label={field.label}
name={field.name}
valuePropName={field.type === "switch" ? "checked" : undefined}
>
{field.type === "switch" ? <Switch /> : <Input placeholder={field.placeholder} />}
</Form.Item>
))}
</Panel>
)}
{/* Authentication Headers */}
{shouldShow("auth_headers") && (
<Panel header="Authentication Headers" key="auth_headers">
{/* Static Headers */}
<Form.Item
label={
<span>
Static Headers{" "}
<Tooltip title="Headers always sent to the backend agent, regardless of the client request. Admin-configured, static wins on conflict.">
<InfoCircleOutlined style={{ color: "#8c8c8c" }} />
</Tooltip>
</span>
}
>
<Form.List name="static_headers">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Space key={key} style={{ display: "flex", marginBottom: 8 }} align="baseline">
<Form.Item
{...restField}
name={[name, "header"]}
rules={[{ required: true, message: "Header name required" }]}
>
<Input placeholder="Header name (e.g. Authorization)" style={{ width: 220 }} />
</Form.Item>
<Form.Item
{...restField}
name={[name, "value"]}
rules={[{ required: true, message: "Value required" }]}
>
<Input placeholder="Value (e.g. Bearer token123)" style={{ width: 260 }} />
</Form.Item>
<MinusCircleOutlined onClick={() => remove(name)} style={{ color: "#ff4d4f" }} />
</Space>
))}
<AntButton type="dashed" onClick={() => add()} icon={<PlusOutlined />} style={{ width: "100%" }}>
Add Static Header
</AntButton>
</>
<AgentFormField key={field.name} name={field.name} label={field.label}>
{({ value, onChange, ref, ...control }) => (
<Switch {...control} inputRef={ref} checked={value === true} onCheckedChange={onChange} />
)}
</Form.List>
</Form.Item>
{/* Extra Headers (dynamic forwarding) */}
<Form.Item
label={
<span>
Forward Client Headers{" "}
<Tooltip title="Header names to extract from the client's request and forward to the agent. Type a name and press Enter.">
<InfoCircleOutlined style={{ color: "#8c8c8c" }} />
</Tooltip>
</span>
}
name="extra_headers"
>
<Select
mode="tags"
style={{ width: "100%" }}
placeholder="e.g. x-api-key, Authorization"
tokenSeparators={[","]}
/>
</Form.Item>
</Panel>
</AgentFormField>
))}
</AgentFormPanel>
)}
</Collapse>
{shouldShow(AGENT_FORM_CONFIG.optional.key) && (
<AgentFormPanel
panelKey={AGENT_FORM_CONFIG.optional.key}
title={AGENT_FORM_CONFIG.optional.title}
panels={panels}
>
{AGENT_FORM_CONFIG.optional.fields.map((field) => (
<AgentFormField key={field.name} name={field.name} label={field.label}>
{({ value, onChange, ref, ...control }) =>
field.type === "switch" ? (
<Switch {...control} inputRef={ref} checked={value === true} onCheckedChange={onChange} />
) : (
<Input
{...control}
ref={ref}
placeholder={field.placeholder}
value={typeof value === "string" ? value : ""}
onChange={onChange}
/>
)
}
</AgentFormField>
))}
</AgentFormPanel>
)}
{shouldShow(AGENT_FORM_CONFIG.cost.key) && (
<AgentFormPanel panelKey={AGENT_FORM_CONFIG.cost.key} title={AGENT_FORM_CONFIG.cost.title} panels={panels}>
<CostConfigFields />
</AgentFormPanel>
)}
{shouldShow(AGENT_FORM_CONFIG.litellm.key) && (
<AgentFormPanel
panelKey={AGENT_FORM_CONFIG.litellm.key}
title={AGENT_FORM_CONFIG.litellm.title}
panels={panels}
>
{AGENT_FORM_CONFIG.litellm.fields.map((field) => (
<AgentFormField key={field.name} name={field.name} label={field.label}>
{({ value, onChange, ref, ...control }) =>
field.type === "switch" ? (
<Switch {...control} inputRef={ref} checked={value === true} onCheckedChange={onChange} />
) : (
<Input
{...control}
ref={ref}
placeholder={field.placeholder}
value={typeof value === "string" ? value : ""}
onChange={onChange}
/>
)
}
</AgentFormField>
))}
</AgentFormPanel>
)}
{shouldShow(AUTH_HEADERS_PANEL_KEY) && (
<AgentFormPanel panelKey={AUTH_HEADERS_PANEL_KEY} title="Authentication Headers" panels={panels}>
<Field>
<FieldTitle>
{labelWithHint(
"Static Headers",
"Headers always sent to the backend agent, regardless of the client request. Admin-configured, static wins on conflict.",
)}
</FieldTitle>
<div className="flex flex-col gap-2">
<StaticHeadersFieldArray />
</div>
</Field>
<AgentFormField
name="extra_headers"
label={labelWithHint(
"Forward Client Headers",
"Header names to extract from the client's request and forward to the agent. Type a name and press Enter.",
)}
>
{({ id, value, onChange }) => (
<AgentTagsInput
id={id}
value={Array.isArray(value) ? (value as string[]) : []}
onValueChange={onChange}
placeholder="e.g. x-api-key, Authorization"
/>
)}
</AgentFormField>
</AgentFormPanel>
)}
</div>
</>
);
};

View file

@ -0,0 +1,262 @@
import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import AgentInfoView from "./agent_info";
import * as networking from "@/components/networking";
import type { AgentCreateInfo } from "@/components/networking";
vi.mock("@/components/networking", () => ({
getAgentInfo: vi.fn(),
patchAgentCall: vi.fn(),
getAgentCreateMetadata: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
useKeys: () => ({ data: { keys: [] }, isLoading: false, refetch: vi.fn() }),
}));
vi.mock("./agent_card_discovery", () => ({ default: () => <div data-testid="agent-card-discovery" /> }));
const A2A_AGENT = {
agent_id: "agent-1",
agent_name: "my-agent",
agent_card_params: {
protocolVersion: "1.0",
name: "My Agent",
description: "does things",
url: "http://localhost:9999/",
version: "2.3.4",
defaultInputModes: ["text"],
defaultOutputModes: ["text"],
capabilities: { streaming: true, pushNotifications: true },
skills: [{ id: "chat", name: "Chat", description: "chat skill", tags: ["a"], examples: ["ex1"] }],
iconUrl: "https://example.com/icon.png",
documentationUrl: "https://example.com/docs",
},
litellm_params: { model: "gpt-4o", make_public: true, cost_per_query: 0.5 },
tpm_limit: 111,
rpm_limit: 222,
session_tpm_limit: 333,
session_rpm_limit: 444,
static_headers: { Authorization: "Bearer x" },
extra_headers: ["x-api-key"],
};
const LANGGRAPH_AGENT = {
agent_id: "agent-2",
agent_name: "lg-agent",
agent_card_params: { name: "LG", description: "graph agent", url: "", version: "1.0.0", skills: [] },
litellm_params: {
custom_llm_provider: "langgraph",
model: "langgraph/asst_1",
api_base: "https://lg.example.com",
cost_per_query: 0.25,
},
};
const langgraphInfo: AgentCreateInfo = {
agent_type: "langgraph",
agent_type_display_name: "LangGraph",
description: "LangGraph platform",
logo_url: "/l.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 },
{
key: "assistant_id",
label: "Assistant ID",
field_type: "text",
required: false,
include_in_litellm_params: false,
},
],
};
const setup = () => userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
const renderView = () => render(<AgentInfoView agentId="agent-1" onClose={vi.fn()} accessToken="tok" isAdmin={true} />);
const openEditor = async (user: ReturnType<typeof setup>) => {
await user.click(await screen.findByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: "Edit Settings" }));
await screen.findByLabelText("Agent Name");
};
const patchedPayload = () => vi.mocked(networking.patchAgentCall).mock.calls[0][2] as Record<string, unknown>;
const save = async (user: ReturnType<typeof setup>) => {
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => expect(networking.patchAgentCall).toHaveBeenCalledTimes(1));
};
describe("AgentInfoView update payload", () => {
beforeEach(() => {
vi.mocked(networking.getAgentCreateMetadata).mockReset().mockResolvedValue([langgraphInfo]);
vi.mocked(networking.getAgentInfo)
.mockReset()
.mockResolvedValue(A2A_AGENT as never);
vi.mocked(networking.patchAgentCall)
.mockReset()
.mockResolvedValue({} as never);
});
it("sends only the fields whose panel has been opened, dropping the rest", async () => {
const user = setup();
renderView();
await openEditor(user);
await save(user);
expect(patchedPayload()).toEqual({
agent_name: "my-agent",
agent_card_params: {
protocolVersion: "1.0",
name: "My Agent",
description: "does things",
url: "http://localhost:9999/",
version: "2.3.4",
defaultInputModes: ["text"],
defaultOutputModes: ["text"],
capabilities: { streaming: false },
skills: [],
},
tpm_limit: 111,
rpm_limit: 222,
session_tpm_limit: 333,
session_rpm_limit: 444,
});
});
it("sends the loaded values of every panel the user opens", async () => {
const user = setup();
renderView();
await openEditor(user);
await user.click(screen.getByRole("button", { name: /Skills/ }));
await user.click(screen.getByRole("button", { name: /Capabilities/ }));
await user.click(screen.getByRole("button", { name: /Optional Settings/ }));
await user.click(screen.getByRole("button", { name: /Cost Configuration/ }));
await user.click(screen.getByRole("button", { name: /LiteLLM Parameters/ }));
await user.click(screen.getByRole("button", { name: /Authentication Headers/ }));
await screen.findByLabelText("Forward Client Headers");
await save(user);
expect(patchedPayload()).toEqual({
agent_name: "my-agent",
agent_card_params: {
protocolVersion: "1.0",
name: "My Agent",
description: "does things",
url: "http://localhost:9999/",
version: "2.3.4",
defaultInputModes: ["text"],
defaultOutputModes: ["text"],
capabilities: { streaming: true, pushNotifications: true, stateTransitionHistory: undefined },
skills: [{ id: "chat", name: "Chat", description: "chat skill", tags: ["a"], examples: ["ex1"] }],
iconUrl: "https://example.com/icon.png",
documentationUrl: "https://example.com/docs",
},
litellm_params: { model: "gpt-4o", make_public: true, cost_per_query: 0.5 },
static_headers: { Authorization: "Bearer x" },
extra_headers: ["x-api-key"],
tpm_limit: 111,
rpm_limit: 222,
session_tpm_limit: 333,
session_rpm_limit: 444,
});
});
it("clamps a rate limit typed below its minimum up to that minimum", async () => {
const user = setup();
renderView();
await openEditor(user);
await user.clear(screen.getByLabelText("TPM Limit"));
await user.type(screen.getByLabelText("TPM Limit"), "-5");
await save(user);
expect(patchedPayload().tpm_limit).toBe(0);
});
it("drops a rate limit the user cleared", async () => {
const user = setup();
renderView();
await openEditor(user);
await user.clear(screen.getByLabelText("RPM Limit"));
await save(user);
expect(patchedPayload()).not.toHaveProperty("rpm_limit");
});
it("submits when Enter is pressed in a text field", async () => {
const user = setup();
renderView();
await openEditor(user);
await user.type(screen.getByLabelText("Agent Name"), "{Enter}");
await waitFor(() => expect(networking.patchAgentCall).toHaveBeenCalledTimes(1));
});
it("blocks the save while a required field is empty", async () => {
const user = setup();
renderView();
await openEditor(user);
await user.clear(screen.getByLabelText("Agent Name"));
await user.click(screen.getByRole("button", { name: "Save Changes" }));
expect(await screen.findByText("Please enter a unique agent name")).toBeInTheDocument();
expect(networking.patchAgentCall).not.toHaveBeenCalled();
});
it("sends credential fields and the rebuilt model for a dynamic agent type", async () => {
vi.mocked(networking.getAgentInfo).mockResolvedValue(LANGGRAPH_AGENT as never);
const user = setup();
renderView();
await openEditor(user);
await user.clear(screen.getByLabelText("API Base"));
await user.type(screen.getByLabelText("API Base"), "https://other.example.com");
await save(user);
expect(patchedPayload()).toEqual({
agent_name: "lg-agent",
agent_card_params: {
protocolVersion: "1.0",
name: "lg-agent",
description: "graph agent",
url: "https://other.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://other.example.com",
model: "langgraph/asst_1",
},
});
});
it("reloads the agent and leaves edit mode when the edit is cancelled", async () => {
const user = setup();
renderView();
await openEditor(user);
expect(networking.getAgentInfo).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(await screen.findByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
expect(networking.getAgentInfo).toHaveBeenCalledTimes(2);
expect(networking.patchAgentCall).not.toHaveBeenCalled();
});
});

View file

@ -1,6 +1,13 @@
import React, { useState, useEffect, useMemo } from "react";
import { Card, Title, Text, Button as TremorButton, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
import { Form, Input, InputNumber, Button as AntButton, Spin, Descriptions, Divider } from "antd";
import { Spin, Descriptions } from "antd";
import { FormProvider, useForm, useWatch } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { TooltipProvider } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field";
import { toast } from "@/lib/toast";
import { ArrowLeftIcon } from "@heroicons/react/outline";
import { getAgentInfo, patchAgentCall, getAgentCreateMetadata, AgentCreateInfo } from "@/components/networking";
@ -9,9 +16,17 @@ import { KeyResponse } from "@/components/key_team_helpers/key_list";
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import KeyInfoView from "@/components/templates/key_info_view";
import AgentVirtualKeys from "./agent_virtual_keys";
import AgentFormFields from "./agent_form_fields";
import DynamicAgentFormFields, { buildDynamicAgentData } from "./dynamic_agent_form_fields";
import { buildAgentDataFromForm, parseAgentForForm } from "./agent_config";
import AgentFormFields, { unmountedA2AFieldNames } from "./agent_form_fields";
import DynamicAgentFormFields, { buildDynamicAgentData, unmountedDynamicFieldNames } from "./dynamic_agent_form_fields";
import { AGENT_FORM_CONFIG, buildAgentDataFromForm, parseAgentForForm } from "./agent_config";
import {
AgentFormField,
AgentFormValues,
AgentNumberInput,
AgentRequestPayload,
omitFieldValues,
useCollapsiblePanels,
} from "./AgentFormKit";
import AgentCostView from "./agent_cost_view";
import { detectAgentType, parseDynamicAgentForForm } from "./agent_type_utils";
import AgentCardDiscovery, { DiscoveredAgentCardSelection } from "./agent_card_discovery";
@ -32,7 +47,8 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
const [isLoading, setIsLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [form] = Form.useForm();
const form = useForm<AgentFormValues>({ defaultValues: {} });
const panels = useCollapsiblePanels([AGENT_FORM_CONFIG.basic.key]);
const [agentTypeMetadata, setAgentTypeMetadata] = useState<AgentCreateInfo[]>([]);
const [detectedAgentType, setDetectedAgentType] = useState<string>("a2a");
const [appliedDiscoveredSelection, setAppliedDiscoveredSelection] = useState<DiscoveredAgentCardSelection | null>(
@ -69,13 +85,13 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
// Parse form values based on agent type
if (agentType === "a2a") {
form.setFieldsValue(parseAgentForForm(data));
form.reset(parseAgentForForm(data));
} else {
const typeInfo = agentTypeMetadata.find((t) => t.agent_type === agentType);
if (typeInfo) {
form.setFieldsValue(parseDynamicAgentForForm(data, typeInfo));
form.reset(parseDynamicAgentForForm(data, typeInfo));
} else {
form.setFieldsValue(parseAgentForForm(data));
form.reset(parseAgentForForm(data));
}
}
} catch (error) {
@ -93,14 +109,14 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
if (agentType !== "a2a") {
const typeInfo = agentTypeMetadata.find((t) => t.agent_type === agentType);
if (typeInfo) {
form.setFieldsValue(parseDynamicAgentForForm(agent, typeInfo));
form.reset(parseDynamicAgentForForm(agent, typeInfo));
}
}
}
}, [agentTypeMetadata, agent]);
const selectedAgentTypeInfo = agentTypeMetadata.find((t) => t.agent_type === detectedAgentType);
const watchedFormValues = Form.useWatch([], form);
const watchedFormValues = useWatch({ control: form.control });
const discoveryRequest = useMemo(
() => buildDiscoveryRequest(detectedAgentType, watchedFormValues || {}, selectedAgentTypeInfo),
@ -119,7 +135,11 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
examples: s.examples ?? [],
}));
const fieldsToSet: Record<string, any> = {
const urlCredentialKeys = (selectedAgentTypeInfo?.credential_fields ?? [])
.map((f) => f.key)
.filter((key) => /(^|_)(url|api_base|endpoint)$/i.test(key));
const fieldsToSet: AgentFormValues = {
name: selected_card.name,
description: selected_card.description,
url: selection.upstream_url,
@ -127,37 +147,35 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
skills,
iconUrl: selected_card.iconUrl,
documentationUrl: selected_card.documentationUrl,
...Object.fromEntries(urlCredentialKeys.map((key) => [key, selection.upstream_url])),
};
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] = selection.upstream_url;
for (const [key, value] of Object.entries(fieldsToSet)) {
form.setValue(key, value);
}
form.setFieldsValue(fieldsToSet);
};
const handleUpdate = async (values: any) => {
const usesDynamicFields = detectedAgentType !== "a2a" && selectedAgentTypeInfo !== undefined;
const handleUpdate = async (submitted: AgentFormValues) => {
if (!accessToken || !agent) return;
setIsSaving(true);
try {
let updateData: any;
const values = omitFieldValues(
submitted,
usesDynamicFields
? unmountedDynamicFieldNames(panels.mountedPanels)
: unmountedA2AFieldNames(panels.mountedPanels),
);
if (detectedAgentType === "a2a") {
updateData = buildAgentDataFromForm(values, agent);
} else if (selectedAgentTypeInfo) {
updateData = buildDynamicAgentData(values, selectedAgentTypeInfo);
updateData.agent_name = values.agent_name;
} else {
updateData = buildAgentDataFromForm(values, agent);
}
const built: AgentRequestPayload = usesDynamicFields
? { ...buildDynamicAgentData(values, selectedAgentTypeInfo), agent_name: values.agent_name }
: buildAgentDataFromForm(values, agent);
if (appliedDiscoveredSelection) {
updateData = overlayDiscoveredCardParams(updateData, appliedDiscoveredSelection.selected_card);
}
const updateData = appliedDiscoveredSelection
? overlayDiscoveredCardParams(built, appliedDiscoveredSelection.selected_card)
: built;
await patchAgentCall(accessToken, agentId, updateData);
toast.success("Agent updated successfully");
@ -199,6 +217,21 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
return date.toLocaleString();
};
const rateLimitField = (name: keyof AgentFormValues & string, label: string) => (
<AgentFormField name={name} label={label}>
{({ value, onChange, ref, ...control }) => (
<AgentNumberInput
{...control}
value={value}
onChange={onChange}
inputRef={ref}
min={0}
placeholder="Unlimited"
/>
)}
</AgentFormField>
);
if (selectedKey) {
return (
<KeyInfoView
@ -369,62 +402,64 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
</div>
{isEditing ? (
<Form form={form} layout="vertical" onFinish={handleUpdate}>
<Form.Item label="Agent ID">
<Input value={agent.agent_id} disabled />
</Form.Item>
<TooltipProvider>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(handleUpdate)}>
<FieldGroup className="mb-4">
<Field>
<FieldLabel htmlFor="agent-id">Agent ID</FieldLabel>
<Input id="agent-id" value={agent.agent_id} disabled readOnly />
</Field>
</FieldGroup>
{detectedAgentType === "a2a" ? (
<AgentFormFields showAgentName={true} />
) : selectedAgentTypeInfo ? (
<DynamicAgentFormFields agentTypeInfo={selectedAgentTypeInfo} />
) : (
<AgentFormFields showAgentName={true} />
)}
{usesDynamicFields && selectedAgentTypeInfo ? (
<DynamicAgentFormFields agentTypeInfo={selectedAgentTypeInfo} panels={panels} />
) : (
<AgentFormFields showAgentName={true} panels={panels} />
)}
{discoveryRequest && (
<div className="mt-4">
<AgentCardDiscovery
accessToken={accessToken}
onApply={handleApplyDiscoveredCard}
discoveryRequest={discoveryRequest}
savedAgentCard={agent.agent_card_params ?? null}
/>
</div>
)}
{discoveryRequest && (
<div className="mt-4">
<AgentCardDiscovery
accessToken={accessToken}
onApply={handleApplyDiscoveredCard}
discoveryRequest={discoveryRequest}
savedAgentCard={agent.agent_card_params ?? null}
/>
</div>
)}
<Divider />
<Title className="mb-4">Rate Limits</Title>
<div className="grid grid-cols-2 gap-4">
<Form.Item label="TPM Limit" name="tpm_limit">
<InputNumber className="w-full" min={0} placeholder="Unlimited" />
</Form.Item>
<Form.Item label="RPM Limit" name="rpm_limit">
<InputNumber className="w-full" min={0} placeholder="Unlimited" />
</Form.Item>
</div>
<div className="grid grid-cols-2 gap-4">
<Form.Item label="Session TPM Limit" name="session_tpm_limit">
<InputNumber className="w-full" min={0} placeholder="Unlimited" />
</Form.Item>
<Form.Item label="Session RPM Limit" name="session_rpm_limit">
<InputNumber className="w-full" min={0} placeholder="Unlimited" />
</Form.Item>
</div>
<Separator className="my-6" />
<Title className="mb-4">Rate Limits</Title>
<div className="grid grid-cols-2 gap-4">
{rateLimitField("tpm_limit", "TPM Limit")}
{rateLimitField("rpm_limit", "RPM Limit")}
</div>
<div className="mt-4 grid grid-cols-2 gap-4">
{rateLimitField("session_tpm_limit", "Session TPM Limit")}
{rateLimitField("session_rpm_limit", "Session RPM Limit")}
</div>
<div className="flex justify-end gap-2 mt-6">
<AntButton
onClick={() => {
setAppliedDiscoveredSelection(null);
setIsEditing(false);
fetchAgentInfo();
}}
>
Cancel
</AntButton>
<TremorButton loading={isSaving}>Save Changes</TremorButton>
</div>
</Form>
<div className="mt-6 flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
setAppliedDiscoveredSelection(null);
setIsEditing(false);
fetchAgentInfo();
}}
>
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving && <UiLoadingSpinner />}
Save Changes
</Button>
</div>
</form>
</FormProvider>
</TooltipProvider>
) : (
<Text>Click &quot;Edit Settings&quot; to modify agent configuration.</Text>
)}

View file

@ -1,17 +1,32 @@
import React from "react";
import { Form, Input } from "antd";
import { Input } from "@/components/ui/input";
import { AGENT_FORM_CONFIG } from "./agent_config";
import { AgentFormField, labelWithHint } from "./AgentFormKit";
const CostConfigFields: React.FC = () => {
return (
<>
{AGENT_FORM_CONFIG.cost.fields.map((field) => (
<Form.Item key={field.name} label={field.label} name={field.name} tooltip={field.tooltip}>
<Input placeholder={field.placeholder} type="number" step="0.000001" />
</Form.Item>
))}
</>
);
};
export const COST_FIELD_NAMES: readonly string[] = AGENT_FORM_CONFIG.cost.fields.map((field) => field.name);
const CostConfigFields: React.FC = () => (
<>
{AGENT_FORM_CONFIG.cost.fields.map((field) => (
<AgentFormField
key={field.name}
name={field.name}
label={field.tooltip ? labelWithHint(field.label, field.tooltip) : field.label}
>
{({ value, onChange, ref, ...control }) => (
<Input
{...control}
ref={ref}
type="number"
step="0.000001"
placeholder={field.placeholder}
value={typeof value === "string" || typeof value === "number" ? value : ""}
onChange={onChange}
/>
)}
</AgentFormField>
))}
</>
);
export default CostConfigFields;

View file

@ -1,83 +1,135 @@
import React from "react";
import { Form, Input, Select, Collapse } from "antd";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { FieldGroup } from "@/components/shared/form/field";
import { AgentCreateInfo, AgentCredentialFieldMetadata } from "@/components/networking";
import { PasswordInput } from "@/components/shared/PasswordInput";
import { AGENT_FORM_CONFIG } from "./agent_config";
import CostConfigFields from "./cost_config_fields";
const { Panel } = Collapse;
import CostConfigFields, { COST_FIELD_NAMES } from "./cost_config_fields";
import {
AgentFormField,
AgentFormPanel,
AgentRequestPayload,
AgentFormValues,
CollapsiblePanelsState,
labelWithHint,
} from "./AgentFormKit";
interface DynamicAgentFormFieldsProps {
agentTypeInfo: AgentCreateInfo;
panels: CollapsiblePanelsState;
}
/**
* Form fields for dynamic agent types (e.g., LangGraph).
* Renders common fields (agent name, display name, description) plus
* credential fields defined by the agent type metadata.
*/
const DynamicAgentFormFields: React.FC<DynamicAgentFormFieldsProps> = ({ agentTypeInfo }) => {
return (
<>
<Form.Item
label="Agent Name"
name="agent_name"
rules={[{ required: true, message: "Please enter a unique agent name" }]}
tooltip="Unique identifier for the agent"
>
<Input placeholder="e.g., my-langgraph-agent" />
</Form.Item>
export const unmountedDynamicFieldNames = (mountedPanels: readonly string[]): readonly string[] =>
mountedPanels.includes(AGENT_FORM_CONFIG.cost.key) ? [] : COST_FIELD_NAMES;
<Form.Item label="Description" name="description" tooltip="Brief description of what this agent does">
<Input.TextArea rows={2} placeholder="Describe what this agent does..." />
</Form.Item>
{agentTypeInfo.credential_fields.map((field: AgentCredentialFieldMetadata) => (
<Form.Item
key={field.key}
label={field.label}
name={field.key}
rules={field.required ? [{ required: true, message: `Please enter ${field.label}` }] : undefined}
tooltip={field.tooltip}
initialValue={field.default_value}
>
{field.field_type === "password" ? (
<Input.Password placeholder={field.placeholder || ""} />
) : field.field_type === "textarea" ? (
<Input.TextArea rows={3} placeholder={field.placeholder || ""} />
) : field.field_type === "select" && field.options ? (
<Select placeholder={field.placeholder || ""}>
{field.options.map((opt) => (
<Select.Option key={opt} value={opt}>
{opt}
</Select.Option>
const CredentialField = ({ field }: { field: AgentCredentialFieldMetadata }) => (
<AgentFormField
name={field.key}
label={field.tooltip ? labelWithHint(field.label, field.tooltip) : field.label}
defaultValue={field.default_value ?? undefined}
rules={field.required ? { required: `Please enter ${field.label}` } : undefined}
>
{({ value, onChange, ref, ...control }) => {
const text = typeof value === "string" ? value : "";
if (field.field_type === "password") {
return (
<PasswordInput
{...control}
value={typeof value === "string" ? value : ""}
onChange={onChange}
ref={ref}
placeholder={field.placeholder || ""}
/>
);
}
if (field.field_type === "textarea") {
return (
<Textarea
{...control}
ref={ref}
rows={3}
placeholder={field.placeholder || ""}
value={text}
onChange={onChange}
/>
);
}
if (field.field_type === "select" && field.options) {
return (
<Select value={text || null} onValueChange={onChange}>
<SelectTrigger {...control} className="w-full">
<SelectValue placeholder={field.placeholder || ""} />
</SelectTrigger>
<SelectContent>
{field.options.map((option) => (
<SelectItem key={option} value={option} title={option}>
{option}
</SelectItem>
))}
</Select>
) : (
<Input placeholder={field.placeholder || ""} />
)}
</Form.Item>
</SelectContent>
</Select>
);
}
return <Input {...control} ref={ref} placeholder={field.placeholder || ""} value={text} onChange={onChange} />;
}}
</AgentFormField>
);
const DynamicAgentFormFields: React.FC<DynamicAgentFormFieldsProps> = ({ agentTypeInfo, panels }) => (
<>
<FieldGroup className="mb-4">
<AgentFormField
name="agent_name"
label={labelWithHint("Agent Name", "Unique identifier for the agent")}
rules={{ required: "Please enter a unique agent name" }}
>
{({ value, onChange, ref, ...control }) => (
<Input
{...control}
ref={ref}
placeholder="e.g., my-langgraph-agent"
value={typeof value === "string" ? value : ""}
onChange={onChange}
/>
)}
</AgentFormField>
<AgentFormField
name="description"
label={labelWithHint("Description", "Brief description of what this agent does")}
>
{({ value, onChange, ref, ...control }) => (
<Textarea
{...control}
ref={ref}
rows={2}
placeholder="Describe what this agent does..."
value={typeof value === "string" ? value : ""}
onChange={onChange}
/>
)}
</AgentFormField>
{agentTypeInfo.credential_fields.map((field) => (
<CredentialField key={field.key} field={field} />
))}
</FieldGroup>
<Collapse style={{ marginBottom: 16 }}>
<Panel header={AGENT_FORM_CONFIG.cost.title} key={AGENT_FORM_CONFIG.cost.key}>
<CostConfigFields />
</Panel>
</Collapse>
</>
);
};
<div className="mb-4 rounded-md border border-border px-4">
<AgentFormPanel panelKey={AGENT_FORM_CONFIG.cost.key} title={AGENT_FORM_CONFIG.cost.title} panels={panels}>
<CostConfigFields />
</AgentFormPanel>
</div>
</>
);
/**
* Builds agent data from form values for dynamic agent types.
* Uses configuration from agentTypeInfo to determine which fields to include.
*/
export const buildDynamicAgentData = (values: any, agentTypeInfo: AgentCreateInfo) => {
// Build litellm_params from template
const litellmParams: Record<string, any> = {
export const buildDynamicAgentData = (values: AgentFormValues, agentTypeInfo: AgentCreateInfo): AgentRequestPayload => {
const litellmParams: Record<string, unknown> = {
...(agentTypeInfo.litellm_params_template || {}),
};
// Add credential fields marked with include_in_litellm_params
for (const field of agentTypeInfo.credential_fields) {
const value = values[field.key];
if (value && field.include_in_litellm_params !== false) {
@ -85,31 +137,25 @@ export const buildDynamicAgentData = (values: any, agentTypeInfo: AgentCreateInf
}
}
// Add cost configuration
if (values.cost_per_query) {
litellmParams.cost_per_query = parseFloat(values.cost_per_query);
litellmParams.cost_per_query = parseFloat(String(values.cost_per_query));
}
if (values.input_cost_per_token) {
litellmParams.input_cost_per_token = parseFloat(values.input_cost_per_token);
litellmParams.input_cost_per_token = parseFloat(String(values.input_cost_per_token));
}
if (values.output_cost_per_token) {
litellmParams.output_cost_per_token = parseFloat(values.output_cost_per_token);
litellmParams.output_cost_per_token = parseFloat(String(values.output_cost_per_token));
}
// Apply model_template if defined (e.g., "bedrock/agentcore/{agent_runtime_arn}")
if (agentTypeInfo.model_template) {
let model = agentTypeInfo.model_template;
// Replace {field_key} placeholders with actual values
for (const field of agentTypeInfo.credential_fields) {
litellmParams.model = agentTypeInfo.credential_fields.reduce((model, field) => {
const placeholder = `{${field.key}}`;
if (model.includes(placeholder) && values[field.key]) {
model = model.replace(placeholder, values[field.key]);
}
}
litellmParams.model = model;
const value = values[field.key];
return model.includes(placeholder) && value ? model.replace(placeholder, String(value)) : model;
}, agentTypeInfo.model_template);
}
const agentData: Record<string, any> = {
const agentData: AgentRequestPayload = {
agent_name: values.agent_name,
agent_card_params: {
protocolVersion: "1.0",