Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion web/oss/src/components/pages/prompts/assets/iconHelpers.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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"

export const getAppTypeIcon = (appType?: string) => {
const normalizedType = appType?.toLowerCase()

if (normalizedType?.includes("agent"))
return <RobotIcon size={16} className="text-zinc-9 dark:text-white" />
if (normalizedType?.includes("chat"))
return <ChatDotsIcon size={16} className="text-zinc-9 dark:text-white" />
if (normalizedType?.includes("completion"))
Expand Down
6 changes: 4 additions & 2 deletions web/packages/agenta-entities/src/workflow/state/appUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export const appTemplatesDataAtom = atom<WorkflowCatalogTemplate[]>((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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null
onChange: (value: Record<string, unknown>) => 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 {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every tool helper keys off this toolName (it reads function.name): the selected-set, remove-by-name, and the visible/added check all depend on it. A tool object that lacks function.name is silently invisible to those paths, so it can neither be deduped nor removed by name. Worth confirming every tool shape the picker emits carries that field.

if (!tool || typeof tool !== "object") return undefined
const fn = (tool as Record<string, unknown>).function
if (!fn || typeof fn !== "object") return undefined
const name = (fn as Record<string, unknown>).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<string, unknown>
const payloadObj = payload as Record<string, unknown>

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<string, unknown>
const props = (schema?.properties ?? {}) as Record<string, SchemaProperty>

// 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],
)
Comment on lines +81 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Stabilize the setField callback to prevent unnecessary re-renders.

The setField callback includes config in its dependency array, causing it to be recreated on every config change. This breaks referential stability and can trigger unnecessary re-renders in child components that receive callbacks derived from setField (like setTools).

🚀 Proposed fix using functional update
 const setField = useCallback(
-    (key: string, fieldValue: unknown) => onChange({...config, [key]: fieldValue}),
-    [config, onChange],
+    (key: string, fieldValue: unknown) => {
+        onChange((prev) => ({...(prev ?? {}), [key]: fieldValue}))
+    },
+    [onChange],
 )

If onChange doesn't support functional updates, wrap it:

+const stableOnChange = useCallback((updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
+    onChange(updater(config))
+}, [onChange, config])
+
 const setField = useCallback(
-    (key: string, fieldValue: unknown) => onChange({...config, [key]: fieldValue}),
-    [config, onChange],
+    (key: string, fieldValue: unknown) => stableOnChange((prev) => ({...prev, [key]: fieldValue})),
+    [stableOnChange],
 )


// 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<string, unknown>),
agenta_metadata: {
...(((tool as Record<string, unknown>).agenta_metadata as
| Record<string, unknown>
| 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<string, unknown>) => {
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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read fallback: the editor populates from `agents_md`, then `instructions` as a legacy key. But every write goes to `agents_md` only (setField below). So a config stored under the old `instructions` key shows in the editor, and the first edit silently migrates it to `agents_md`. Confirm `agents_md` is the field the backend reads, so this migration lands somewhere valid.


return (
<div className={cn("flex flex-col gap-3", className)}>
<TextInputControl
schema={props.agents_md}
label="Instructions"
value={agentsMd}
onChange={(v) => setField("agents_md", v)}
description={props.agents_md?.description as string | undefined}
withTooltip={withTooltip}
disabled={disabled}
multiline
/>

<GroupedChoiceControl
schema={props.model}
label="Model"
value={(config.model as string | null) ?? null}
onChange={(v) => setField("model", v)}
withTooltip={withTooltip}
disabled={disabled}
/>

{/* Tools */}
<div className="flex flex-col gap-2">
{tools.length > 0 && (
<div className="flex flex-col gap-2">
{tools.map((tool, index) => {
const control = (
<ToolItemControl
key={`tool-${index}`}
value={tool}
onChange={(v) => handleToolChange(index, v)}
onDelete={disabled ? undefined : () => handleToolDelete(index)}
disabled={disabled}
/>
)
return EditorProvider ? (
<EditorProvider
key={`tool-editor-${index}`}
codeOnly
language="json"
showToolbar={false}
enableTokens={false}
id={`agent-tool-editor-${index}`}
>
{control}
</EditorProvider>
) : (
control
)
})}
</div>
)}
{!disabled && (
<div>
<ToolSelectorPopover
onAddTool={handleAddTool}
onRemoveTool={handleRemoveToolByName}
onRemoveBuiltinTool={handleRemoveBuiltinTool}
selectedToolNames={selectedToolNames}
selectedTools={tools as ToolObj[]}
existingToolCount={tools.length}
/>
</div>
)}
</div>

{/* MCP servers */}
<div className="flex flex-col gap-2">
<Typography.Text className="text-sm font-medium">MCP servers</Typography.Text>
{mcpServers.length > 0 && (
<div className="flex flex-col gap-2">
{mcpServers.map((server, index) => {
const control = (
<McpServerItemControl
key={`mcp-${index}`}
value={server}
onChange={(v) => handleMcpServerChange(index, v)}
onDelete={
disabled ? undefined : () => handleMcpServerDelete(index)
}
disabled={disabled}
/>
)
return EditorProvider ? (
<EditorProvider
key={`mcp-editor-${index}`}
codeOnly
language="json"
showToolbar={false}
enableTokens={false}
id={`agent-mcp-editor-${index}`}
>
{control}
</EditorProvider>
) : (
control
)
})}
</div>
)}
{!disabled && (
<div>
<Button size="small" icon={<Plus size={14} />} onClick={handleAddMcpServer}>
Add MCP server
</Button>
</div>
)}
</div>

<EnumSelectControl
schema={props.harness}
label="Harness"
value={(config.harness as string | null) ?? null}
onChange={(v) => setField("harness", v)}
withTooltip={withTooltip}
disabled={disabled}
/>

<EnumSelectControl
schema={props.sandbox}
label="Sandbox"
value={(config.sandbox as string | null) ?? null}
onChange={(v) => setField("sandbox", v)}
withTooltip={withTooltip}
disabled={disabled}
/>

<EnumSelectControl
schema={props.permission_policy}
label="Permission policy"
value={(config.permission_policy as string | null) ?? null}
onChange={(v) => setField("permission_policy", v)}
withTooltip={withTooltip}
disabled={disabled}
/>
</div>
)
}
Loading
Loading