From 7120276dd9aff0b43e0b849b68c317d66b80205a Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 19 Jun 2026 18:27:53 +0200 Subject: [PATCH 1/2] feat(frontend): agent config playground controls --- .../components/CreateAppDropdown/index.tsx | 6 + .../modals/CreateAppTypeModal/index.tsx | 6 + .../pages/prompts/assets/iconHelpers.tsx | 4 +- .../src/workflow/state/appUtils.ts | 6 +- .../SchemaControls/AgentConfigControl.tsx | 324 ++++++++++++++++++ .../SchemaControls/McpServerItemControl.tsx | 137 ++++++++ .../SchemaControls/SchemaPropertyRenderer.tsx | 21 ++ .../src/DrillInView/SchemaControls/index.ts | 6 + 8 files changed, 507 insertions(+), 3 deletions(-) create mode 100644 web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentConfigControl.tsx create mode 100644 web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/McpServerItemControl.tsx diff --git a/web/oss/src/components/pages/app-management/components/CreateAppDropdown/index.tsx b/web/oss/src/components/pages/app-management/components/CreateAppDropdown/index.tsx index fdc6300665..93a0d0f6e0 100644 --- a/web/oss/src/components/pages/app-management/components/CreateAppDropdown/index.tsx +++ b/web/oss/src/components/pages/app-management/components/CreateAppDropdown/index.tsx @@ -34,6 +34,12 @@ const ITEMS: CreateAppDropdownItem[] = [ description: "Single-shot prompt completion.", testId: "create-app-dropdown-completion", }, + { + type: "agent", + label: "Agent", + description: "Agent that uses tools over multiple turns.", + testId: "create-app-dropdown-agent", + }, ] interface CreateAppDropdownProps { diff --git a/web/oss/src/components/pages/app-management/modals/CreateAppTypeModal/index.tsx b/web/oss/src/components/pages/app-management/modals/CreateAppTypeModal/index.tsx index 9f07b6b354..beeda459ff 100644 --- a/web/oss/src/components/pages/app-management/modals/CreateAppTypeModal/index.tsx +++ b/web/oss/src/components/pages/app-management/modals/CreateAppTypeModal/index.tsx @@ -51,6 +51,12 @@ const OPTIONS: CreateAppTypeOption[] = [ description: "Single-shot prompt completion.", testId: "create-app-type-modal-completion", }, + { + type: "agent", + label: "Agent", + description: "Agent that uses tools over multiple turns.", + testId: "create-app-type-modal-agent", + }, ] interface CreateAppTypeModalProps { diff --git a/web/oss/src/components/pages/prompts/assets/iconHelpers.tsx b/web/oss/src/components/pages/prompts/assets/iconHelpers.tsx index 1902e21c55..2864a5c011 100644 --- a/web/oss/src/components/pages/prompts/assets/iconHelpers.tsx +++ b/web/oss/src/components/pages/prompts/assets/iconHelpers.tsx @@ -1,6 +1,6 @@ import React from "react" -import {ChatDotsIcon, NoteIcon} from "@phosphor-icons/react" +import {ChatDotsIcon, NoteIcon, RobotIcon} from "@phosphor-icons/react" import CompletionAppIcon from "../components/CompletionAppIcon" import SetupWorkflowIcon from "../components/SetupWorkflowIcon" @@ -8,6 +8,8 @@ import SetupWorkflowIcon from "../components/SetupWorkflowIcon" export const getAppTypeIcon = (appType?: string) => { const normalizedType = appType?.toLowerCase() + if (normalizedType?.includes("agent")) + return if (normalizedType?.includes("chat")) return if (normalizedType?.includes("completion")) diff --git a/web/packages/agenta-entities/src/workflow/state/appUtils.ts b/web/packages/agenta-entities/src/workflow/state/appUtils.ts index de72d61b38..6216e2acf6 100644 --- a/web/packages/agenta-entities/src/workflow/state/appUtils.ts +++ b/web/packages/agenta-entities/src/workflow/state/appUtils.ts @@ -64,7 +64,7 @@ export const appTemplatesDataAtom = atom((get) => { * App types supported by the drawer flow. "custom" routes through the * existing CustomWorkflowModal and does NOT use this factory. */ -export type AppType = "chat" | "completion" +export type AppType = "chat" | "completion" | "agent" export interface CreateEphemeralAppFromTemplateParams { type: AppType @@ -206,7 +206,9 @@ export async function createEphemeralAppFromTemplate({ is_code: false, is_match: false, is_feedback: false, - is_chat: type === "chat", + // Agent takes messages-in / returns a final message, so it runs in + // chat mode like `chat` (backend infers is_chat from messages-in too). + is_chat: type === "chat" || type === "agent", has_url: false, has_script: false, has_handler: false, diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentConfigControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentConfigControl.tsx new file mode 100644 index 0000000000..dbecf44312 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentConfigControl.tsx @@ -0,0 +1,324 @@ +/** + * AgentConfigControl + * + * One composite control for the whole agent config, dispatched from + * `x-ag-type: "agent_config"` / `x-ag-type-ref: "agent_config"` (see SchemaPropertyRenderer). + * It reuses the existing controls rather than inventing new ones: the model selector + * (GroupedChoiceControl), the tool picker (ToolSelectorPopover + ToolItemControl), the MCP + * server editor (McpServerItemControl), enum selects (harness, sandbox, permission policy), + * and a textarea (agents_md). The field shape is the `agent_config` catalog type generated + * from the SDK model (AgentConfigSchema in agenta.sdk.utils.types); the agent service ships a + * thin `x-ag-type-ref` the playground resolves and reads back (services/oss/src/agent). + */ +import {useCallback, useMemo} from "react" + +import type {SchemaProperty} from "@agenta/entities/shared" +import {useDrillInUI} from "@agenta/ui/drill-in" +import {cn} from "@agenta/ui/styles" +import {Plus} from "@phosphor-icons/react" +import {Button, Typography} from "antd" + +import {EnumSelectControl} from "./EnumSelectControl" +import {GroupedChoiceControl} from "./GroupedChoiceControl" +import {McpServerItemControl} from "./McpServerItemControl" +import {TextInputControl} from "./TextInputControl" +import {ToolItemControl} from "./ToolItemControl" +import {ToolSelectorPopover, type ToolSelectionMeta} from "./ToolSelectorPopover" +import {type ToolObj} from "./toolUtils" + +export interface AgentConfigControlProps { + schema?: SchemaProperty | null + label?: string + value?: Record | null + onChange: (value: Record) => void + description?: string + withTooltip?: boolean + disabled?: boolean + className?: string +} + +/** Read the function name of a tool object (the gateway slug for Composio tools). */ +function toolName(tool: unknown): string | undefined { + if (!tool || typeof tool !== "object") return undefined + const fn = (tool as Record).function + if (!fn || typeof fn !== "object") return undefined + const name = (fn as Record).name + return typeof name === "string" ? name : undefined +} + +function isBuiltinPayloadMatch(tool: unknown, payload: ToolObj): boolean { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false + + const toolObj = tool as Record + const payloadObj = payload as Record + + if (typeof payloadObj.type === "string" && toolObj.type === payloadObj.type) return true + if (typeof payloadObj.name === "string" && toolObj.name === payloadObj.name) return true + + const payloadKeys = Object.keys(payloadObj) + return ( + payloadKeys.length === 1 && + payloadKeys[0] !== "type" && + payloadKeys[0] !== "name" && + payloadKeys[0] in toolObj + ) +} + +export function AgentConfigControl({ + schema, + value, + onChange, + withTooltip, + disabled, + className, +}: AgentConfigControlProps) { + const {EditorProvider} = useDrillInUI() + const config = (value ?? {}) as Record + const props = (schema?.properties ?? {}) as Record + + // Update a single field of the agent config, leaving the rest intact. + const setField = useCallback( + (key: string, fieldValue: unknown) => onChange({...config, [key]: fieldValue}), + [config, onChange], + ) + + // Tools live as a flat array on the agent config (the same tool-object shape the + // prompt control uses, so the backend resolver parses them identically). + const tools = useMemo( + () => (Array.isArray(config.tools) ? (config.tools as unknown[]) : []), + [config.tools], + ) + const setTools = useCallback((next: unknown[]) => setField("tools", next), [setField]) + + const handleAddTool = useCallback( + (tool: ToolObj, meta?: ToolSelectionMeta) => { + const next = + meta && tool && typeof tool === "object" && !Array.isArray(tool) + ? { + ...(tool as Record), + agenta_metadata: { + ...(((tool as Record).agenta_metadata as + | Record + | undefined) ?? {}), + ...meta, + }, + } + : tool + setTools([...tools, next]) + }, + [tools, setTools], + ) + + const handleToolChange = useCallback( + (index: number, next: ToolObj) => { + const updated = [...tools] + updated[index] = next + setTools(updated) + }, + [tools, setTools], + ) + + const handleToolDelete = useCallback( + (index: number) => setTools(tools.filter((_, i) => i !== index)), + [tools, setTools], + ) + + const handleRemoveToolByName = useCallback( + (name: string) => setTools(tools.filter((tool) => toolName(tool) !== name)), + [tools, setTools], + ) + + const handleRemoveBuiltinTool = useCallback( + (toolToRemove: ToolObj) => { + let removed = false + const updated = tools.filter((tool) => { + if (removed) return true + if (!isBuiltinPayloadMatch(tool, toolToRemove)) return true + removed = true + return false + }) + if (removed) setTools(updated) + }, + [tools, setTools], + ) + + const selectedToolNames = useMemo( + () => new Set(tools.map(toolName).filter((n): n is string => Boolean(n))), + [tools], + ) + + // MCP servers are a sibling of tools: a flat array on the agent config. Each entry is the + // open McpServer shape (name + stdio command/args/env or remote url, secret names), edited + // as JSON the backend resolver parses identically to `tools`. + const mcpServers = useMemo( + () => (Array.isArray(config.mcp_servers) ? (config.mcp_servers as unknown[]) : []), + [config.mcp_servers], + ) + const setMcpServers = useCallback( + (next: unknown[]) => setField("mcp_servers", next), + [setField], + ) + const handleAddMcpServer = useCallback( + () => setMcpServers([...mcpServers, {name: "", transport: "stdio", command: "", args: []}]), + [mcpServers, setMcpServers], + ) + const handleMcpServerChange = useCallback( + (index: number, next: Record) => { + const updated = [...mcpServers] + updated[index] = next + setMcpServers(updated) + }, + [mcpServers, setMcpServers], + ) + const handleMcpServerDelete = useCallback( + (index: number) => setMcpServers(mcpServers.filter((_, i) => i !== index)), + [mcpServers, setMcpServers], + ) + + // ``agents_md`` is the catalog-schema field; ``instructions`` is read as a fallback so an + // already-stored agent config (the legacy key) still populates the editor. + const agentsMd = + (config.agents_md as string | null | undefined) ?? + (config.instructions as string | null | undefined) ?? + null + + return ( +
+ setField("agents_md", v)} + description={props.agents_md?.description as string | undefined} + withTooltip={withTooltip} + disabled={disabled} + multiline + /> + + setField("model", v)} + withTooltip={withTooltip} + disabled={disabled} + /> + + {/* Tools */} +
+ {tools.length > 0 && ( +
+ {tools.map((tool, index) => { + const control = ( + handleToolChange(index, v)} + onDelete={disabled ? undefined : () => handleToolDelete(index)} + disabled={disabled} + /> + ) + return EditorProvider ? ( + + {control} + + ) : ( + control + ) + })} +
+ )} + {!disabled && ( +
+ +
+ )} +
+ + {/* MCP servers */} +
+ MCP servers + {mcpServers.length > 0 && ( +
+ {mcpServers.map((server, index) => { + const control = ( + handleMcpServerChange(index, v)} + onDelete={ + disabled ? undefined : () => handleMcpServerDelete(index) + } + disabled={disabled} + /> + ) + return EditorProvider ? ( + + {control} + + ) : ( + control + ) + })} +
+ )} + {!disabled && ( +
+ +
+ )} +
+ + setField("harness", v)} + withTooltip={withTooltip} + disabled={disabled} + /> + + setField("sandbox", v)} + withTooltip={withTooltip} + disabled={disabled} + /> + + setField("permission_policy", v)} + withTooltip={withTooltip} + disabled={disabled} + /> +
+ ) +} diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/McpServerItemControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/McpServerItemControl.tsx new file mode 100644 index 0000000000..9a545833fb --- /dev/null +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/McpServerItemControl.tsx @@ -0,0 +1,137 @@ +/** + * McpServerItemControl + * + * Schema-driven control for one declared MCP server on the agent config. MCP servers are a + * sibling of `tools` (not a tool variant): a server names a transport (stdio command/args/env + * or a remote url), an optional tool allowlist, and the vault secret names the backend resolves + * into its env at run time. The shape is open enough that a JSON editor is the pragmatic v1 — + * the same approach ToolItemControl takes for tool definitions — with a name header and a delete + * control. The typed shape lives in the `agent_config` catalog type (AgentConfigSchema / + * McpServer in the SDK); this control just edits one entry of the `mcp_servers` array. + */ +import {memo, useCallback, useEffect, useRef, useState} from "react" + +import {safeStringify} from "@agenta/shared/utils" +import {useDrillInUI} from "@agenta/ui/drill-in" +import {MinusCircle} from "@phosphor-icons/react" +import {Button, Tooltip, Typography} from "antd" +import clsx from "clsx" + +export interface McpServerItemControlProps { + /** MCP server value (object or JSON string) */ + value: unknown + /** Called when the server value changes (only on valid JSON) */ + onChange?: (value: Record) => void + /** Called when the server should be removed */ + onDelete?: () => void + /** Whether the control is read-only */ + disabled?: boolean + /** Additional CSS classes */ + className?: string +} + +function toServerObj(value: unknown): Record { + try { + if (typeof value === "string") + return value ? (JSON.parse(value) as Record) : {} + if (value && typeof value === "object" && !Array.isArray(value)) + return value as Record + } catch { + // fall through to empty object + } + return {} +} + +export const McpServerItemControl = memo(function McpServerItemControl({ + value, + onChange, + onDelete, + disabled = false, + className, +}: McpServerItemControlProps) { + const {SharedEditor} = useDrillInUI() + const serverObj = toServerObj(value) + const name = + typeof serverObj.name === "string" && serverObj.name ? serverObj.name : "MCP server" + + const [editorText, setEditorText] = useState(() => safeStringify(serverObj ?? {})) + + // Reset the editor text when the value changes from outside (add/remove/reorder). + const lastExternalRef = useRef(safeStringify(serverObj ?? {})) + useEffect(() => { + const next = safeStringify(toServerObj(value) ?? {}) + if (next !== lastExternalRef.current) { + lastExternalRef.current = next + setEditorText(next) + } + }, [value]) + + const handleEditorChange = useCallback( + (text: string) => { + if (disabled) return + setEditorText(text) + try { + const parsed = text ? (JSON.parse(text) as Record) : {} + lastExternalRef.current = safeStringify(parsed) + onChange?.(parsed) + } catch { + // Keep the invalid text in the editor; don't propagate until it parses. + } + }, + [disabled, onChange], + ) + + const header = ( +
+ + {name} + + {!disabled && onDelete && ( + +
+ ) + + if (!SharedEditor) { + return ( +
+ {header} +