import { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Agent, AdapterEnvironmentTestResult, CompanySecret, EnvBinding, Environment, } from "@paperclipai/shared"; import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, supportedEnvironmentDriversForAdapter } from "@paperclipai/shared"; import type { AdapterModel } from "../api/agents"; import { agentsApi } from "../api/agents"; import { environmentsApi } from "../api/environments"; import { instanceSettingsApi } from "../api/instanceSettings"; import { secretsApi } from "../api/secrets"; import { assetsApi } from "../api/assets"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, DEFAULT_CODEX_LOCAL_MODEL, } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { FolderOpen, Heart, ChevronDown, X } from "lucide-react"; import { asBoolean, asFiniteNumber, asObject, cn } from "../lib/utils"; import { extractModelName, extractProviderId } from "../lib/model-utils"; import { queryKeys } from "../lib/queryKeys"; import { useCompany } from "../context/CompanyContext"; import { Field, ToggleField, ToggleWithNumber, CollapsibleSection, DraftInput, DraftNumberInput, help, adapterLabels, } from "./agent-config-primitives"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { defaultCreateValues } from "./agent-config-defaults"; import { getUIAdapter } from "../adapters"; import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields"; import { MarkdownEditor } from "./MarkdownEditor"; import { ChoosePathButton } from "./PathInstructionsModal"; import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; import { ReportsToPicker } from "./ReportsToPicker"; import { EnvVarEditor } from "./EnvVarEditor"; import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config"; import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata"; import { getAdapterDisplay, getAdapterLabel } from "../adapters/adapter-display-registry"; import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; import { buildAgentUpdatePatch, type AgentConfigOverlay } from "../lib/agent-config-patch"; import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; /* ---- Create mode values ---- */ // Canonical type lives in @paperclipai/adapter-utils; re-exported here // so existing imports from this file keep working. export type { CreateConfigValues } from "@paperclipai/adapter-utils"; import type { CreateConfigValues } from "@paperclipai/adapter-utils"; /* ---- Props ---- */ type AgentConfigFormProps = { adapterModels?: AdapterModel[]; onDirtyChange?: (dirty: boolean) => void; onSaveActionChange?: (save: (() => void) | null) => void; onCancelActionChange?: (cancel: (() => void) | null) => void; onTestActionChange?: (test: (() => void) | null) => void; onTestActionStateChange?: (state: { disabled: boolean; pending: boolean }) => void; onTestFeedbackChange?: (feedback: { errorMessage: string | null; result: AdapterEnvironmentTestResult | null; }) => void; hideInlineSave?: boolean; showAdapterTypeField?: boolean; showAdapterTestEnvironmentButton?: boolean; showCreateRunPolicySection?: boolean; hideInstructionsFile?: boolean; /** Hide the prompt template field from the Identity section (used when it's shown in a separate Prompts tab). */ hidePromptTemplate?: boolean; /** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */ sectionLayout?: "inline" | "cards"; } & ( | { mode: "create"; values: CreateConfigValues; onChange: (patch: Partial) => void; } | { mode: "edit"; agent: Agent; onSave: (patch: Record) => void; isSaving?: boolean; } ); /* ---- Edit mode overlay (dirty tracking) ---- */ const emptyOverlay: AgentConfigOverlay = { identity: {}, adapterConfig: {}, heartbeat: {}, runtime: {}, }; /** Stable empty object used as fallback for missing env config to avoid new-object-per-render. */ const EMPTY_ENV: Record = {}; function isOverlayDirty(o: AgentConfigOverlay): boolean { return ( Object.keys(o.identity).length > 0 || o.adapterType !== undefined || Object.keys(o.adapterConfig).length > 0 || Object.keys(o.heartbeat).length > 0 || Object.keys(o.runtime).length > 0 || o.modelProfiles?.cheap !== undefined ); } /* ---- Shared input class ---- */ const inputClass = "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; function parseCommaArgs(value: string): string[] { return value .split(",") .map((item) => item.trim()) .filter(Boolean); } function formatArgList(value: unknown): string { if (Array.isArray(value)) { return value .filter((item): item is string => typeof item === "string") .join(", "); } return typeof value === "string" ? value : ""; } const codexThinkingEffortOptions = [ { id: "", label: "Auto" }, { id: "minimal", label: "Minimal" }, { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, { id: "xhigh", label: "X-High" }, ] as const; const openCodeThinkingEffortOptions = [ { id: "", label: "Auto" }, { id: "minimal", label: "Minimal" }, { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, { id: "xhigh", label: "X-High" }, { id: "max", label: "Max" }, ] as const; const cursorModeOptions = [ { id: "", label: "Auto" }, { id: "plan", label: "Plan" }, { id: "ask", label: "Ask" }, ] as const; const claudeThinkingEffortOptions = [ { id: "", label: "Auto" }, { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, ] as const; const MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS = 2; const MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP = 10; const MAX_TURN_CONTINUATION_DEFAULT_DELAY_SEC = 1; const MAX_TURN_CONTINUATION_MAX_DELAY_SEC = 300; function clampInteger(value: number, min: number, max: number) { return Math.max(min, Math.min(max, Math.floor(value))); } function clampDelayMsFromSeconds(value: number) { return clampInteger(value, 0, MAX_TURN_CONTINUATION_MAX_DELAY_SEC) * 1000; } /* ---- Form ---- */ export function AgentConfigForm(props: AgentConfigFormProps) { const { mode, adapterModels: externalModels } = props; const isCreate = mode === "create"; const cards = props.sectionLayout === "cards"; const showAdapterTypeField = props.showAdapterTypeField ?? true; const showAdapterTestEnvironmentButton = props.showAdapterTestEnvironmentButton ?? true; const showInlineAdapterTestEnvironmentButton = showAdapterTestEnvironmentButton && !props.onTestActionChange; const showInlineAdapterTestEnvironmentFeedback = !props.onTestFeedbackChange; const showCreateRunPolicySection = props.showCreateRunPolicySection ?? true; const hideInstructionsFile = props.hideInstructionsFile ?? false; const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); // Sync disabled adapter types from server so dropdown filters them out const disabledTypes = useDisabledAdaptersSync(); const { data: availableSecrets = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"], queryFn: () => secretsApi.list(selectedCompanyId!), enabled: Boolean(selectedCompanyId), }); const { data: experimentalSettings } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), retry: false, }); const environmentsEnabled = experimentalSettings?.enableEnvironments === true; const { data: environments = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.environments.list(selectedCompanyId) : ["environments", "none"], queryFn: () => environmentsApi.list(selectedCompanyId!), enabled: Boolean(selectedCompanyId) && environmentsEnabled, }); const createSecret = useMutation({ mutationFn: (input: { name: string; value: string }) => { if (!selectedCompanyId) throw new Error("Select a company to create secrets"); return secretsApi.create(selectedCompanyId, input); }, onSuccess: () => { if (!selectedCompanyId) return; queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) }); }, }); const uploadMarkdownImage = useMutation({ mutationFn: async ({ file, namespace }: { file: File; namespace: string }) => { if (!selectedCompanyId) throw new Error("Select a company to upload images"); return assetsApi.uploadImage(selectedCompanyId, file, namespace); }, }); // ---- Edit mode: overlay for dirty tracking ---- const [overlay, setOverlay] = useState(emptyOverlay); const agentRef = useRef(null); // Clear overlay when agent data refreshes (after save) useEffect(() => { if (!isCreate) { if (agentRef.current !== null && props.agent !== agentRef.current) { setOverlay({ ...emptyOverlay }); } agentRef.current = props.agent; } }, [isCreate, !isCreate ? props.agent : undefined]); // eslint-disable-line react-hooks/exhaustive-deps const isDirty = !isCreate && isOverlayDirty(overlay); type RecordOverlayGroup = "identity" | "adapterConfig" | "heartbeat" | "runtime"; /** Read effective value: overlay if dirty, else original */ function eff(group: RecordOverlayGroup, field: string, original: T): T { const o = overlay[group]; if (field in o) return o[field] as T; return original; } /** Mark field dirty in overlay */ function mark(group: RecordOverlayGroup, field: string, value: unknown) { setOverlay((prev) => ({ ...prev, [group]: { ...prev[group], [field]: value }, })); } /** Build accumulated patch and send to parent */ const handleCancel = useCallback(() => { setOverlay({ ...emptyOverlay }); }, []); const handleSave = useCallback(() => { if (isCreate || !isDirty) return; props.onSave(buildAgentUpdatePatch(props.agent, overlay)); }, [isCreate, isDirty, overlay, props]); useEffect(() => { if (!isCreate) { props.onDirtyChange?.(isDirty); props.onSaveActionChange?.(handleSave); props.onCancelActionChange?.(handleCancel); } }, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel]); useEffect(() => { if (isCreate) return; return () => { props.onSaveActionChange?.(null); props.onCancelActionChange?.(null); props.onDirtyChange?.(false); }; }, [isCreate, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange]); // ---- Resolve values ---- const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record) : {}; const runtimeConfig = !isCreate ? ((props.agent.runtimeConfig ?? {}) as Record) : {}; const heartbeat = !isCreate ? ((runtimeConfig.heartbeat ?? {}) as Record) : {}; const adapterType = isCreate ? props.values.adapterType : overlay.adapterType ?? props.agent.adapterType; const getCapabilities = useAdapterCapabilities(); const adapterCaps = getCapabilities(adapterType); const isLocal = adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt; const showLegacyWorkingDirectoryField = isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config }); const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); const supportedEnvironmentDrivers = useMemo( () => new Set(supportedEnvironmentDriversForAdapter(adapterType)), [adapterType], ); const val = isCreate ? props.values : null; const set = isCreate ? (patch: Partial) => props.onChange(patch) : null; const currentDefaultEnvironmentId = isCreate ? val!.defaultEnvironmentId ?? "" : eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? ""); const currentDefaultEnvironment = useMemo( () => environments.find((environment) => environment.id === currentDefaultEnvironmentId) ?? null, [currentDefaultEnvironmentId, environments], ); const runnableEnvironments = useMemo( () => environments.filter((environment) => { if (!supportedEnvironmentDrivers.has(environment.driver)) return false; if (environment.driver !== "sandbox") return true; const provider = typeof environment.config?.provider === "string" ? environment.config.provider : null; return provider !== null && provider !== "fake"; }), [environments, supportedEnvironmentDrivers], ); // Fetch adapter models for the effective adapter type const modelQueryKey = selectedCompanyId ? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, currentDefaultEnvironmentId || null) : ["agents", "none", "adapter-models", adapterType]; const { data: fetchedModels, error: fetchedModelsError, } = useQuery({ queryKey: modelQueryKey, queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType, { environmentId: currentDefaultEnvironmentId || null, }), enabled: Boolean(selectedCompanyId), }); const [refreshModelsError, setRefreshModelsError] = useState(null); const [refreshingModels, setRefreshingModels] = useState(false); const models = fetchedModels ?? externalModels ?? []; const adapterCommandField = adapterType === "hermes_local" ? "hermesCommand" : "command"; const { data: detectedModelData, refetch: refetchDetectedModel, } = useQuery({ queryKey: selectedCompanyId ? queryKeys.agents.detectModel(selectedCompanyId, adapterType) : ["agents", "none", "detect-model", adapterType], queryFn: () => { if (!selectedCompanyId) { throw new Error("Select a company to detect the model"); } return agentsApi.detectModel(selectedCompanyId, adapterType); }, enabled: Boolean(selectedCompanyId && isLocal && adapterType !== "opencode_local"), }); const detectedModel = detectedModelData?.model ?? null; const detectedModelCandidates = detectedModelData?.candidates ?? []; const { data: companyAgents = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"], queryFn: () => agentsApi.list(selectedCompanyId!), enabled: Boolean(!isCreate && selectedCompanyId), }); /** Props passed to adapter-specific config field components */ const adapterFieldProps = { mode, isCreate, adapterType, values: isCreate ? props.values : null, set: isCreate ? (patch: Partial) => props.onChange(patch) : null, config, eff: eff as (group: "adapterConfig", field: string, original: T) => T, mark: mark as (group: "adapterConfig", field: string, value: unknown) => void, models, hideInstructionsFile, }; // Section toggle state — advanced always starts collapsed const [runPolicyAdvancedOpen, setRunPolicyAdvancedOpen] = useState(false); // Popover states const [modelOpen, setModelOpen] = useState(false); const [cheapModelOpen, setCheapModelOpen] = useState(false); const [thinkingEffortOpen, setThinkingEffortOpen] = useState(false); // Cheap model profile state — only relevant when the adapter advertises // `supportsModelProfiles`. Defaults are sourced from the adapter's // /model-profiles endpoint so the UI does not encode adapter-specific // cheap defaults. const supportsModelProfiles = adapterCaps.supportsModelProfiles; const { data: adapterCheapProfileDefinitions } = useQuery({ queryKey: selectedCompanyId ? queryKeys.agents.adapterModelProfiles(selectedCompanyId, adapterType) : ["agents", "none", "adapter-model-profiles", adapterType], queryFn: () => agentsApi.adapterModelProfiles(selectedCompanyId!, adapterType), enabled: Boolean(selectedCompanyId) && supportsModelProfiles, }); const adapterCheapDefault = useMemo(() => { return (adapterCheapProfileDefinitions ?? []).find((profile) => profile.key === "cheap") ?? null; }, [adapterCheapProfileDefinitions]); const adapterCheapDefaultModel = useMemo(() => { const adapterConfig = adapterCheapDefault?.adapterConfig ?? {}; const value = (adapterConfig as Record).model; return typeof value === "string" ? value : ""; }, [adapterCheapDefault]); function buildAdapterConfigForTest(): Record { if (isCreate) { return uiAdapter.buildAdapterConfig(val!); } const base = config as Record; const next = { ...base, ...overlay.adapterConfig }; if (adapterType === "hermes_local") { const hermesCommand = typeof next.hermesCommand === "string" && next.hermesCommand.length > 0 ? next.hermesCommand : typeof next.command === "string" && next.command.length > 0 ? next.command : undefined; if (hermesCommand) { next.hermesCommand = hermesCommand; } } return next; } const testEnvironment = useMutation({ mutationFn: async () => { if (!selectedCompanyId) { throw new Error("Select a company to test adapter environment"); } return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig: buildAdapterConfigForTest(), environmentId: currentDefaultEnvironmentId || null, }); }, }); const testEnvironmentDisabled = testEnvironment.isPending || !selectedCompanyId; const triggerTestEnvironment = useCallback(() => { if (testEnvironmentDisabled) return; testEnvironment.mutate(); }, [testEnvironment.mutate, testEnvironmentDisabled]); useEffect(() => { if (!showAdapterTestEnvironmentButton || !props.onTestActionChange) return; props.onTestActionChange(triggerTestEnvironment); return () => { props.onTestActionChange?.(null); }; }, [showAdapterTestEnvironmentButton, props.onTestActionChange, triggerTestEnvironment]); useEffect(() => { if (!showAdapterTestEnvironmentButton || !props.onTestActionStateChange) return; props.onTestActionStateChange({ disabled: testEnvironmentDisabled, pending: testEnvironment.isPending, }); return () => { props.onTestActionStateChange?.({ disabled: true, pending: false }); }; }, [ showAdapterTestEnvironmentButton, props.onTestActionStateChange, testEnvironmentDisabled, testEnvironment.isPending, ]); useEffect(() => { if (!props.onTestFeedbackChange) return; props.onTestFeedbackChange({ errorMessage: testEnvironment.error instanceof Error ? testEnvironment.error.message : testEnvironment.error ? "Environment test failed" : null, result: testEnvironment.data ?? null, }); return () => { props.onTestFeedbackChange?.({ errorMessage: null, result: null }); }; }, [props.onTestFeedbackChange, testEnvironment.data, testEnvironment.error]); // Current model for display const currentModelId = isCreate ? val!.model : eff("adapterConfig", "model", String(config.model ?? "")); async function handleRefreshModels() { if (!selectedCompanyId) return; setRefreshingModels(true); setRefreshModelsError(null); try { const refreshed = await agentsApi.adapterModels(selectedCompanyId, adapterType, { refresh: true }); queryClient.setQueryData(modelQueryKey, refreshed); } catch (error) { setRefreshModelsError(error instanceof Error ? error.message : "Failed to refresh adapter models."); } finally { setRefreshingModels(false); } } const thinkingEffortKey = adapterType === "codex_local" ? "modelReasoningEffort" : adapterType === "cursor" ? "mode" : adapterType === "opencode_local" ? "variant" : "effort"; const thinkingEffortOptions = adapterType === "codex_local" ? codexThinkingEffortOptions : adapterType === "cursor" ? cursorModeOptions : adapterType === "opencode_local" ? openCodeThinkingEffortOptions : claudeThinkingEffortOptions; const currentThinkingEffort = isCreate ? val!.thinkingEffort : adapterType === "codex_local" ? eff( "adapterConfig", "modelReasoningEffort", String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""), ) : adapterType === "cursor" ? eff("adapterConfig", "mode", String(config.mode ?? "")) : adapterType === "opencode_local" ? eff("adapterConfig", "variant", String(config.variant ?? "")) : eff("adapterConfig", "effort", String(config.effort ?? "")); const showThinkingEffort = adapterType !== "gemini_local"; const codexSearchEnabled = adapterType === "codex_local" ? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search))) : false; // Cheap profile read/write helpers. Edit-mode values come from // runtimeConfig.modelProfiles.cheap with overlay overrides on top; create-mode // values come straight from CreateConfigValues (cheapModel + cheapModelEnabled). const cheapProfileFromAgent = useMemo(() => { const profiles = (runtimeConfig.modelProfiles ?? {}) as Record; const cheap = (profiles.cheap ?? {}) as Record; const cheapAdapterConfig = (cheap.adapterConfig ?? {}) as Record; return { enabled: cheap.enabled !== false, model: typeof cheapAdapterConfig.model === "string" ? cheapAdapterConfig.model : "", }; }, [runtimeConfig]); const cheapOverlay = !isCreate ? overlay.modelProfiles?.cheap : undefined; const currentCheapEnabled = isCreate ? val!.cheapModelEnabled ?? false : cheapOverlay?.enabled ?? cheapProfileFromAgent.enabled; const currentCheapModel = isCreate ? val!.cheapModel ?? "" : (() => { const overlayModel = (cheapOverlay?.adapterConfig as Record | undefined)?.model; if (typeof overlayModel === "string") return overlayModel; return cheapProfileFromAgent.model; })(); function setCheapEnabled(next: boolean) { if (isCreate) { set!({ cheapModelEnabled: next }); return; } setOverlay((prev) => ({ ...prev, modelProfiles: { cheap: { ...(prev.modelProfiles?.cheap ?? {}), enabled: next, }, }, })); } function setCheapModel(next: string) { if (isCreate) { set!({ cheapModel: next }); return; } setOverlay((prev) => { const existing = prev.modelProfiles?.cheap ?? {}; const nextAdapterConfig = { ...((existing.adapterConfig ?? {}) as Record), model: next || undefined, }; return { ...prev, modelProfiles: { cheap: { ...existing, adapterConfig: nextAdapterConfig, }, }, }; }); } const effectiveRuntimeConfig = useMemo(() => { if (isCreate) { return { heartbeat: { enabled: val!.heartbeatEnabled, intervalSec: val!.intervalSec, }, }; } const mergedHeartbeat = { ...(runtimeConfig.heartbeat && typeof runtimeConfig.heartbeat === "object" ? runtimeConfig.heartbeat as Record : {}), ...overlay.heartbeat, }; return { ...runtimeConfig, heartbeat: mergedHeartbeat, }; }, [isCreate, overlay.heartbeat, runtimeConfig, val]); const effectiveHeartbeat = asObject(effectiveRuntimeConfig.heartbeat); const maxTurnContinuation = asObject(effectiveHeartbeat.maxTurnContinuation); const maxTurnContinuationEnabled = asBoolean(maxTurnContinuation.enabled, true); const maxTurnContinuationMaxAttempts = clampInteger( asFiniteNumber(maxTurnContinuation.maxAttempts, MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS), 0, MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP, ); const maxTurnContinuationDelaySec = clampInteger( asFiniteNumber(maxTurnContinuation.delayMs, MAX_TURN_CONTINUATION_DEFAULT_DELAY_SEC * 1000) / 1000, 0, MAX_TURN_CONTINUATION_MAX_DELAY_SEC, ); function updateMaxTurnContinuation(patch: Record) { mark("heartbeat", "maxTurnContinuation", { ...maxTurnContinuation, ...patch, }); } return (
{/* ---- Floating Save button (edit mode, when dirty) ---- */} {isDirty && !props.hideInlineSave && (
Unsaved changes
)} {/* ---- Identity (edit only) ---- */} {!isCreate && (
{cards ?

Identity

:
Identity
}
mark("identity", "name", v)} immediate className={inputClass} placeholder="Agent name" /> mark("identity", "title", v || null)} immediate className={inputClass} placeholder="e.g. VP of Engineering" /> mark("identity", "reportsTo", id)} excludeAgentIds={[props.agent.id]} chooseLabel="Choose manager…" /> mark("identity", "capabilities", v || null)} placeholder="Describe what this agent can do..." contentClassName="min-h-[44px] text-sm font-mono" imageUploadHandler={async (file) => { const asset = await uploadMarkdownImage.mutateAsync({ file, namespace: `agents/${props.agent.id}/capabilities`, }); return asset.contentPath; }} /> {isLocal && !props.hidePromptTemplate && ( <> mark("adapterConfig", "promptTemplate", v ?? "")} placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..." contentClassName="min-h-[88px] text-sm font-mono" imageUploadHandler={async (file) => { const namespace = `agents/${props.agent.id}/prompt-template`; const asset = await uploadMarkdownImage.mutateAsync({ file, namespace }); return asset.contentPath; }} />
Prompt template is replayed on every heartbeat. Keep it compact and dynamic to avoid recurring token cost and cache churn.
)}
)} {/* ---- Execution ---- */} {environmentsEnabled ? (
{cards ?

Execution

:
Execution
}
) : null} {/* ---- Adapter ---- */}
{cards ?

Adapter

: Adapter } {showInlineAdapterTestEnvironmentButton && ( )}
{showAdapterTypeField && ( { if (isCreate) { // Reset all adapter-specific fields to defaults when switching adapter type const { adapterType: _at, ...defaults } = defaultCreateValues; const nextValues: CreateConfigValues = { ...defaults, adapterType: t }; if (t === "codex_local") { nextValues.model = DEFAULT_CODEX_LOCAL_MODEL; nextValues.dangerouslyBypassSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; } else if (t === "gemini_local") { nextValues.model = DEFAULT_GEMINI_LOCAL_MODEL; } else if (t === "cursor") { nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL; } else if (t === "opencode_local") { nextValues.model = DEFAULT_OPENCODE_LOCAL_MODEL; } set!(nextValues); } else { // Clear all adapter config and explicitly blank out model + effort/mode keys // so the old adapter's values don't bleed through via eff() setOverlay((prev) => ({ ...prev, adapterType: t, modelProfiles: { cheap: { cleared: true } }, adapterConfig: { model: t === "codex_local" ? DEFAULT_CODEX_LOCAL_MODEL : t === "gemini_local" ? DEFAULT_GEMINI_LOCAL_MODEL : t === "opencode_local" ? DEFAULT_OPENCODE_LOCAL_MODEL : t === "cursor" ? DEFAULT_CURSOR_LOCAL_MODEL : "", effort: "", modelReasoningEffort: "", variant: "", mode: "", ...(t === "codex_local" ? { dangerouslyBypassApprovalsAndSandbox: DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, } : {}), }, })); } }} /> )} {showInlineAdapterTestEnvironmentFeedback && testEnvironment.error && (
{testEnvironment.error instanceof Error ? testEnvironment.error.message : "Environment test failed"}
)} {showInlineAdapterTestEnvironmentFeedback && testEnvironment.data && ( )} {/* Working directory */} {showLegacyWorkingDirectoryField && (
isCreate ? set!({ cwd: v }) : mark("adapterConfig", "cwd", v || undefined) } immediate className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40" placeholder="/path/to/project" />
)} {/* Adapter-specific fields are rendered inside Permissions & Configuration */}
{/* ---- Permissions & Configuration ---- */} {isLocal && (
{cards ?

Permissions & Configuration

:
Permissions & Configuration
}
isCreate ? set!({ command: v }) : mark("adapterConfig", adapterCommandField, v || null) } immediate className={inputClass} placeholder={ ({ claude_local: "claude", codex_local: "codex", gemini_local: "gemini", pi_local: "pi", cursor: "agent", opencode_local: "opencode", } as Record)[adapterType] ?? adapterType.replace(/_local$/, "") } /> {supportsModelProfiles && (
Primary model
)} isCreate ? set!({ model: v }) : mark("adapterConfig", "model", v || undefined) } open={modelOpen} onOpenChange={setModelOpen} allowDefault={adapterType !== "opencode_local"} required={adapterType === "opencode_local"} groupByProvider={adapterType === "opencode_local"} creatable detectedModel={detectedModel} detectedModelCandidates={[]} onDetectModel={adapterType === "opencode_local" ? undefined : async () => { const result = await refetchDetectedModel(); return result.data?.model ?? null; }} onRefreshModels={adapterType === "codex_local" ? handleRefreshModels : undefined} refreshingModels={refreshingModels} detectModelLabel="Detect model" emptyDetectHint="No model detected. Select or enter one manually." /> {(refreshModelsError || fetchedModelsError) && (

{refreshModelsError ?? (fetchedModelsError instanceof Error ? fetchedModelsError.message : "Failed to load adapter models.")}

)} {adapterType === "opencode_local" && currentDefaultEnvironment && currentDefaultEnvironment.driver !== "local" && (

Live OpenCode model discovery only runs for Local environments. Using the curated list and manual entry for {currentDefaultEnvironment.name}.

)} {supportsModelProfiles && ( )} {showThinkingEffort && ( <> isCreate ? set!({ thinkingEffort: v }) : mark("adapterConfig", thinkingEffortKey, v || undefined) } open={thinkingEffortOpen} onOpenChange={setThinkingEffortOpen} /> {adapterType === "codex_local" && codexSearchEnabled && currentThinkingEffort === "minimal" && (

Codex may reject `minimal` thinking when search is enabled.

)} )} {!isCreate && typeof config.bootstrapPromptTemplate === "string" && config.bootstrapPromptTemplate && ( <> mark("adapterConfig", "bootstrapPromptTemplate", v || undefined) } placeholder="Optional initial setup prompt for the first run" contentClassName="min-h-[44px] text-sm font-mono" imageUploadHandler={async (file) => { const namespace = `agents/${props.agent.id}/bootstrap-prompt`; const asset = await uploadMarkdownImage.mutateAsync({ file, namespace }); return asset.contentPath; }} />
Bootstrap prompt is legacy and will be removed in a future release. Consider moving this content into the agent's prompt template or instructions file instead.
)} {adapterType === "claude_local" && ( )} isCreate ? set!({ extraArgs: v }) : mark("adapterConfig", "extraArgs", v?.trim() ? parseCommaArgs(v) : null) } immediate className={inputClass} placeholder="e.g. --verbose, --foo=bar" /> ) : ((eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record)) ) } secrets={availableSecrets} onCreateSecret={async (name, value) => { const created = await createSecret.mutateAsync({ name, value }); return created; }} onChange={(env) => isCreate ? set!({ envBindings: env ?? {}, envVars: "" }) : mark("adapterConfig", "env", env) } /> {/* Edit-only: timeout + grace period */} {!isCreate && ( <> mark("adapterConfig", "timeoutSec", v)} immediate className={inputClass} /> mark("adapterConfig", "graceSec", v)} immediate className={inputClass} /> )}
)} {/* ---- Run Policy ---- */} {isCreate && showCreateRunPolicySection ? (
{cards ?

Run Policy

:
Run Policy
}
set!({ heartbeatEnabled: v })} number={val!.intervalSec} onNumberChange={(v) => set!({ intervalSec: v })} numberLabel="sec" numberPrefix="Run heartbeat every" numberHint={help.intervalSec} showNumber={val!.heartbeatEnabled} />
) : !isCreate ? (
{cards ?

Run Policy

:
Run Policy
}
mark("heartbeat", "enabled", v)} number={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))} onNumberChange={(v) => mark("heartbeat", "intervalSec", v)} numberLabel="sec" numberPrefix="Run heartbeat every" numberHint={help.intervalSec} showNumber={eff("heartbeat", "enabled", heartbeat.enabled === true)} />
setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)} >
mark("heartbeat", "wakeOnDemand", v)} /> mark("heartbeat", "cooldownSec", v)} immediate className={inputClass} /> mark("heartbeat", "maxConcurrentRuns", v)} immediate className={inputClass} />
updateMaxTurnContinuation({ enabled: v })} /> {maxTurnContinuationEnabled ? (
updateMaxTurnContinuation({ maxAttempts: clampInteger(v, 0, MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP), })} immediate className={inputClass} /> updateMaxTurnContinuation({ delayMs: clampDelayMsFromSeconds(v), })} immediate className={inputClass} />
) : null}
) : null}
); } export function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestResult }) { const statusLabel = result.status === "pass" ? "Passed" : result.status === "warn" ? "Warnings" : "Failed"; const statusClass = result.status === "pass" ? "text-green-700 dark:text-green-300 border-green-300 dark:border-green-500/40 bg-green-50 dark:bg-green-500/10" : result.status === "warn" ? "text-amber-700 dark:text-amber-300 border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10" : "text-red-700 dark:text-red-300 border-red-300 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10"; return (
{statusLabel} {new Date(result.testedAt).toLocaleTimeString()}
{result.checks.map((check, idx) => (
{check.level} · {check.message} {check.detail && ({check.detail})} {check.hint && Hint: {check.hint}}
))}
); } /* ---- Internal sub-components ---- */ function AdapterTypeDropdown({ value, onChange, disabledTypes, }: { value: string; onChange: (type: string) => void; disabledTypes: Set; }) { const [open, setOpen] = useState(false); const selectedDisplay = getAdapterDisplay(value); const adapterList = useMemo( () => listAdapterOptions((type) => adapterLabels[type] ?? getAdapterLabel(type)).filter( (item) => !disabledTypes.has(item.value), ), [disabledTypes], ); return ( {adapterList.map((item) => ( ))} ); } function ExperimentalBadge() { return ( Experimental ); } function ModelDropdown({ models, value, onChange, open, onOpenChange, allowDefault, required, groupByProvider, creatable, detectedModel, detectedModelCandidates, onDetectModel, onRefreshModels, refreshingModels, detectModelLabel, emptyDetectHint, defaultLabel, }: { models: AdapterModel[]; value: string; onChange: (id: string) => void; open: boolean; onOpenChange: (open: boolean) => void; allowDefault: boolean; required: boolean; groupByProvider: boolean; creatable?: boolean; detectedModel?: string | null; detectedModelCandidates?: string[]; onDetectModel?: () => Promise; onRefreshModels?: () => Promise; refreshingModels?: boolean; detectModelLabel?: string; emptyDetectHint?: string; defaultLabel?: string; }) { const [modelSearch, setModelSearch] = useState(""); const [detectingModel, setDetectingModel] = useState(false); const selected = models.find((m) => m.id === value); const manualModel = modelSearch.trim(); const canCreateManualModel = Boolean( creatable && manualModel && !models.some((m) => m.id.toLowerCase() === manualModel.toLowerCase()), ); // Model IDs already shown as detected/candidate badges — exclude from regular list const promotedModelIds = useMemo(() => { const set = new Set(); if (detectedModel) set.add(detectedModel); for (const c of detectedModelCandidates ?? []) { if (c) set.add(c); } return set; }, [detectedModel, detectedModelCandidates]); const filteredModels = useMemo(() => { return models.filter((m) => { if (promotedModelIds.has(m.id)) return false; if (!modelSearch.trim()) return true; const q = modelSearch.toLowerCase(); const provider = extractProviderId(m.id) ?? ""; return ( m.id.toLowerCase().includes(q) || m.label.toLowerCase().includes(q) || provider.toLowerCase().includes(q) ); }); }, [models, modelSearch, promotedModelIds]); const groupedModels = useMemo(() => { if (!groupByProvider) { return [ { provider: "models", entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id)), }, ]; } const map = new Map(); for (const model of filteredModels) { const provider = extractProviderId(model.id) ?? "other"; const group = map.get(provider) ?? []; group.push(model); map.set(provider, group); } return Array.from(map.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([provider, entries]) => ({ provider, entries: [...entries].sort((a, b) => a.id.localeCompare(b.id)), })); }, [filteredModels, groupByProvider]); async function handleDetectModel() { if (!onDetectModel) return; setDetectingModel(true); try { const nextModel = await onDetectModel(); if (nextModel) { onChange(nextModel); onOpenChange(false); setModelSearch(""); } } finally { setDetectingModel(false); } } return ( { onOpenChange(nextOpen); if (!nextOpen) setModelSearch(""); }} >
setModelSearch(e.target.value)} autoFocus /> {modelSearch && ( )}
{onDetectModel && !modelSearch.trim() && ( )} {onRefreshModels && !modelSearch.trim() && ( )} {value && (!models.some((m) => m.id === value) || promotedModelIds.has(value)) && ( )} {detectedModel && detectedModel !== value && ( )} {detectedModelCandidates ?.filter((candidate) => candidate && candidate !== detectedModel && candidate !== value) .map((candidate) => { const entry = models.find((m) => m.id === candidate); return ( ); })}
{allowDefault && ( )} {canCreateManualModel && ( )} {groupedModels.map((group) => (
{groupByProvider && (
{group.provider} ({group.entries.length})
)} {group.entries.map((m) => ( ))}
))} {filteredModels.length === 0 && !canCreateManualModel && promotedModelIds.size === 0 && (

{onDetectModel ? (emptyDetectHint ?? "No model detected yet. Enter a provider/model manually.") : "No models found."}

)}
); } function CheapModelSection({ enabled, model, models, adapterType, adapterDefaultModel, onEnabledChange, onModelChange, open, onOpenChange, }: { enabled: boolean; model: string; models: AdapterModel[]; adapterType: string; adapterDefaultModel: string; onEnabledChange: (next: boolean) => void; onModelChange: (next: string) => void; open: boolean; onOpenChange: (open: boolean) => void; }) { const placeholderHint = adapterDefaultModel ? `Adapter default · ${adapterDefaultModel}` : "No adapter default — choose a cheaper model"; return (
Cheap model

Used when a run requests the cheap profile (e.g. routine summaries). The primary model stays unchanged.

{enabled ? ( ) : null} {enabled && !model && adapterDefaultModel ? (

No explicit cheap model selected — runtime falls back to {adapterDefaultModel}.

) : null} {enabled && !model && !adapterDefaultModel ? (

No cheap model selected and the adapter has no default. Cheap-lane runs will continue on the primary model with a fallback note.

) : null}
); } function ThinkingEffortDropdown({ value, options, onChange, open, onOpenChange, }: { value: string; options: ReadonlyArray<{ id: string; label: string }>; onChange: (id: string) => void; open: boolean; onOpenChange: (open: boolean) => void; }) { const selected = options.find((option) => option.id === value) ?? options[0]; return ( {options.map((option) => ( ))} ); }