mirror of
https://github.com/block/buzz.git
synced 2026-11-06 17:31:19 +00:00
## Summary - discover shared managed agents from authenticated relay directory records instead of treating channel membership as sufficient proof - publish and refresh access-policy changes immediately so running clients converge across machines without a restart or five-minute poll - route profile edits through the exact managed instance and stop/restart runtimes around access changes so unrelated edits cannot silently widen access - keep mention send-time revalidation and Block owner-only build enforcement fail closed - explain invalid custom provider/model configuration instead of leaving Save silently disabled ### Related issue Fixes #3204 ### Known residuals - a brand-new remote agent's first policy record can wait for the bounded directory poll when no authenticated directory coordinate exists yet; send-time mention revalidation remains fail closed - a failed remote-provider policy redeploy is recorded but cannot undeploy the older provider instance until the provider protocol gains the destructor tracked by #5570 ### Testing - full Desktop unit suite: 4,961 tests passed - focused profile editor Playwright workflow passed, including Customize access edits and prompt-only edits after tightening an instance - Desktop TypeScript, Biome formatting, file-size ratchet, Tauri checks, and pre-push suites passed - independently reviewed for authenticated directory trust, live subscription teardown, runtime revocation ordering, fail-open edit paths, and per-agent provider deployment serialization --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Signed-off-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz> Co-authored-by: diegorumo <diegorumo@gmail.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz>
1034 lines
36 KiB
TypeScript
1034 lines
36 KiB
TypeScript
import * as React from "react";
|
|
import { ChevronDown } from "lucide-react";
|
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
|
|
import type {
|
|
AcpRuntimeCatalogEntry,
|
|
CreatePersonaInput,
|
|
UpdatePersonaInput,
|
|
} from "@/shared/api/types";
|
|
import { cn } from "@/shared/lib/cn";
|
|
import { Input } from "@/shared/ui/input";
|
|
import { Textarea } from "@/shared/ui/textarea";
|
|
import { AgentCreationPreview } from "./AgentCreationPreview";
|
|
import { PersonaDropdownField } from "./PersonaDropdownField";
|
|
import type { EnvVarsValue } from "./EnvVarsEditor";
|
|
import { PersonaAdvancedFields } from "./PersonaAdvancedFields";
|
|
import { PersonaModelField } from "./PersonaModelField";
|
|
import { runtimeAvailabilityWarning } from "./runtimeAvailabilityWarning";
|
|
import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField";
|
|
import {
|
|
canSubmitPersonaDialog,
|
|
formatPersonaNamePoolText,
|
|
parsePersonaNamePoolText,
|
|
} from "./personaDialogState";
|
|
import { hasText } from "./personaDialogEnvVars";
|
|
import {
|
|
behaviorForSubmit,
|
|
draftFromBehavior,
|
|
emptyPersonaBehaviorDraft,
|
|
personaBehaviorDraftValid,
|
|
} from "./personaBehaviorDraft";
|
|
import {
|
|
ADVANCED_FIELDS_MOTION_TRANSITION,
|
|
AUTO_MODEL_DROPDOWN_VALUE,
|
|
AUTO_PROVIDER_DROPDOWN_VALUE,
|
|
BLOCK_BUILD_HIDDEN_PROVIDER_IDS,
|
|
buildPersonaRuntimeDropdownOptions,
|
|
CUSTOM_PROVIDER_DROPDOWN_VALUE,
|
|
computeLocalModeGate,
|
|
formatRuntimeOptionLabel,
|
|
getDefaultPersonaRuntime,
|
|
getPersonaModelOptions,
|
|
getPersonaProviderOptions,
|
|
getProviderApiKeyLabel,
|
|
getRuntimePersonaModelOptions,
|
|
NO_RUNTIME_DROPDOWN_VALUE,
|
|
runtimeSupportsLlmProviderSelection,
|
|
type PersonaDropdownOption,
|
|
PERSONA_FIELD_CONTROL_CLASS,
|
|
PERSONA_FIELD_SHELL_CLASS,
|
|
PERSONA_LABEL_OPTIONAL_CLASS,
|
|
shouldClearKnownModelForSelectionScope,
|
|
} from "./agentConfigOptions";
|
|
import { RequiredFieldLabel } from "./agentConfigControls";
|
|
import {
|
|
modelDropdownOptions as buildModelDropdownOptions,
|
|
relayMeshModelPickerState,
|
|
} from "./relayMeshModelPicker";
|
|
import {
|
|
selectionOnModelDropdownChange,
|
|
selectionOnProviderDropdownChange,
|
|
selectionOnRuntimeChange,
|
|
type RuntimeModelProviderSelection,
|
|
} from "./runtimeModelProviderSelection";
|
|
import {
|
|
MODEL_DISCOVERY_LOADING_VALUE,
|
|
usePersonaModelDiscovery,
|
|
} from "./usePersonaModelDiscovery";
|
|
import { useBakedBuildEnvKeysQuery, useRuntimeFileConfigQuery } from "../hooks";
|
|
import { useAgentDialogDefaults } from "./useAgentDialogDefaults";
|
|
import { AgentDefaultsDialog } from "./AgentDefaultsDialog";
|
|
import { AgentHarnessField } from "./AgentHarnessField";
|
|
import {
|
|
AgentAiConfigurationModeField,
|
|
AgentCreateAiDefaultsSummary,
|
|
type AgentAiConfigurationMode,
|
|
} from "./AgentAiConfigurationMode";
|
|
import {
|
|
agentAiConfigurationModeSatisfied,
|
|
agentAiConfigurationPairForMode,
|
|
initialAgentAiConfigurationMode,
|
|
} from "./agentAiConfigurationPolicy";
|
|
import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState";
|
|
import { buildRuntimeModelProviderPayload } from "./agentDefinitionSubmitPayload";
|
|
import { AgentDefinitionDialogFooter } from "./AgentDefinitionDialogFooter";
|
|
import { AgentDefinitionDialogShell } from "./AgentDefinitionDialogShell";
|
|
import { AddCustomHarnessDialog } from "./AddCustomHarnessDialog";
|
|
import {
|
|
ADD_CUSTOM_HARNESS_OPTION,
|
|
runtimeDropdownAction,
|
|
usePendingHarnessSelection,
|
|
} from "./addCustomHarness";
|
|
|
|
type AgentDefinitionDialogProps = {
|
|
open: boolean;
|
|
embedded?: boolean;
|
|
title: string;
|
|
description: string;
|
|
submitLabel: string;
|
|
initialValues: CreatePersonaInput | UpdatePersonaInput | null;
|
|
error: Error | null;
|
|
isPending: boolean;
|
|
runtimes: AcpRuntimeCatalogEntry[];
|
|
runtimeCatalogStatus?: "loading" | "ready" | "error";
|
|
onDirtyChange?: (dirty: boolean) => void;
|
|
onOpenChange: (open: boolean) => void;
|
|
onSubmit: (
|
|
input: CreatePersonaInput | UpdatePersonaInput,
|
|
options: AgentDefinitionSubmitOptions,
|
|
) => Promise<unknown>;
|
|
/** Publishes saved changes when the edited agent is shared in the catalog. */
|
|
publishCatalogUpdatesOnSave?: boolean;
|
|
createRunSection?: React.ReactNode;
|
|
/** Extra create-mode submit gate (e.g. incomplete provider config). */
|
|
createSubmitBlocked?: boolean;
|
|
};
|
|
|
|
export type AgentDefinitionSubmitOptions = {
|
|
publishCatalogUpdates: boolean;
|
|
};
|
|
|
|
export function AgentDefinitionDialog({
|
|
open,
|
|
embedded = false,
|
|
title,
|
|
description,
|
|
submitLabel,
|
|
initialValues,
|
|
error,
|
|
isPending,
|
|
runtimes,
|
|
runtimeCatalogStatus = "ready" as const,
|
|
onDirtyChange,
|
|
onOpenChange,
|
|
onSubmit,
|
|
publishCatalogUpdatesOnSave = false,
|
|
createRunSection,
|
|
createSubmitBlocked = false,
|
|
}: AgentDefinitionDialogProps) {
|
|
const runtimesLoading = runtimeCatalogStatus === "loading";
|
|
const [displayName, setDisplayName] = React.useState("");
|
|
const [aiDefaultsOpen, setAiDefaultsOpen] = React.useState(false);
|
|
const aiDefaultsTriggerRef = React.useRef<HTMLButtonElement>(null);
|
|
const [avatarUrl, setAvatarUrl] = React.useState("");
|
|
const [systemPrompt, setSystemPrompt] = React.useState("");
|
|
const [runtime, setRuntime] = React.useState("");
|
|
const [model, setModel] = React.useState("");
|
|
const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false);
|
|
const [provider, setProvider] = React.useState("");
|
|
const [aiConfigurationMode, setAiConfigurationMode] =
|
|
React.useState<AgentAiConfigurationMode>("defaults");
|
|
const [isCustomProviderEditing, setIsCustomProviderEditing] =
|
|
React.useState(false);
|
|
const [namePoolText, setNamePoolText] = React.useState("");
|
|
const [envVars, setEnvVars] = React.useState<EnvVarsValue>({});
|
|
const [behaviorDraft, setBehaviorDraft] = React.useState(
|
|
emptyPersonaBehaviorDraft,
|
|
);
|
|
// The seed the draft is diffed against at submit: an untouched quad
|
|
// submits no behavior group, keeping unrelated edits hash-quiet.
|
|
const behaviorSeedRef = React.useRef(emptyPersonaBehaviorDraft);
|
|
// Tracks when the runtime was auto-seeded by the default-runtime effect in
|
|
// edit mode (i.e. the user never explicitly chose a runtime). Used to omit
|
|
// the seeded runtime from the submit payload for builtin definitions whose
|
|
// canonical runtime is null — the sync would revert it anyway.
|
|
const isRuntimeAutoSeededRef = React.useRef(false);
|
|
// Guards the seeding effect so it fires at most once per dialog-open.
|
|
// Without this, clearing runtime back to "" via "No preference" would re-
|
|
// trigger the effect (the `runtime` dep would pass the length guard) and
|
|
// snap the dropdown back to the default — an edit-mode regression.
|
|
const hasSeededForOpenRef = React.useRef(false);
|
|
const [showAdvancedFields, setShowAdvancedFields] = React.useState(false);
|
|
const [isAvatarUploadPending, setIsAvatarUploadPending] =
|
|
React.useState(false);
|
|
const [hasUserChanges, setHasUserChanges] = React.useState(false);
|
|
const [isAddHarnessOpen, setIsAddHarnessOpen] = React.useState(false);
|
|
const {
|
|
globalConfig,
|
|
inheritedDefaults: {
|
|
provider: inheritedProviderDefault,
|
|
model: inheritedModelDefault,
|
|
},
|
|
inheritedEnvVars: inheritedEnvVarsForAdvanced,
|
|
} = useAgentDialogDefaults({ open });
|
|
const defaultRuntime = React.useMemo(
|
|
() => getDefaultPersonaRuntime(runtimes, globalConfig.preferred_runtime),
|
|
[globalConfig.preferred_runtime, runtimes],
|
|
);
|
|
const isCreateMode = Boolean(initialValues && !("id" in initialValues));
|
|
const shouldReduceMotion = useReducedMotion();
|
|
const initialModelProviderEditableWithoutRuntime = Boolean(
|
|
initialValues &&
|
|
"id" in initialValues &&
|
|
!hasText(initialValues.runtime) &&
|
|
(hasText(initialValues.model) || hasText(initialValues.provider)),
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
onDirtyChange?.(hasUserChanges);
|
|
}, [hasUserChanges, onDirtyChange]);
|
|
|
|
React.useEffect(() => {
|
|
if (!open || !initialValues) {
|
|
return;
|
|
}
|
|
|
|
setDisplayName(initialValues.displayName);
|
|
setAvatarUrl(initialValues.avatarUrl ?? "");
|
|
setSystemPrompt(initialValues.systemPrompt);
|
|
setRuntime(initialValues.runtime ?? "");
|
|
setModel(initialValues.model ?? "");
|
|
setIsCustomModelEditing(false);
|
|
setProvider(initialValues.provider ?? "");
|
|
setAiConfigurationMode(
|
|
initialAgentAiConfigurationMode({
|
|
provider: initialValues.provider ?? "",
|
|
model: initialValues.model ?? "",
|
|
}),
|
|
);
|
|
setIsCustomProviderEditing(false);
|
|
const nextNamePoolText =
|
|
"namePool" in initialValues
|
|
? formatPersonaNamePoolText(initialValues.namePool)
|
|
: "";
|
|
const nextEnvVars =
|
|
"envVars" in initialValues ? (initialValues.envVars ?? {}) : {};
|
|
const nextBehaviorDraft = draftFromBehavior(initialValues.behavior);
|
|
behaviorSeedRef.current = draftFromBehavior(initialValues.behavior);
|
|
setBehaviorDraft(nextBehaviorDraft);
|
|
setNamePoolText(nextNamePoolText);
|
|
setEnvVars(nextEnvVars);
|
|
// Advanced always starts collapsed and only changes from its toggle.
|
|
setShowAdvancedFields(false);
|
|
setIsAvatarUploadPending(false);
|
|
setHasUserChanges(false);
|
|
isRuntimeAutoSeededRef.current = false;
|
|
hasSeededForOpenRef.current = false;
|
|
}, [initialValues, open]);
|
|
|
|
React.useEffect(() => {
|
|
if (
|
|
!open ||
|
|
!initialValues ||
|
|
initialValues.runtime?.trim() ||
|
|
runtimesLoading ||
|
|
runtime.trim().length > 0 ||
|
|
defaultRuntime === null ||
|
|
hasSeededForOpenRef.current
|
|
) {
|
|
return;
|
|
}
|
|
|
|
setRuntime(defaultRuntime.id);
|
|
hasSeededForOpenRef.current = true;
|
|
if ("id" in initialValues) {
|
|
// Edit mode: record that this runtime was auto-seeded so the submit path
|
|
// can omit it from the payload for builtin definitions (canonical runtime
|
|
// null; sync would revert the value anyway). Explicit user changes via
|
|
// the dropdown clear this flag.
|
|
isRuntimeAutoSeededRef.current = true;
|
|
}
|
|
}, [defaultRuntime, initialValues, open, runtime, runtimesLoading]);
|
|
|
|
// Keep an inherited Create runtime synced with defaults saved in-place.
|
|
React.useEffect(() => {
|
|
if (
|
|
!open ||
|
|
!initialValues ||
|
|
"id" in initialValues ||
|
|
initialValues.runtime?.trim() ||
|
|
aiConfigurationMode !== "defaults" ||
|
|
runtimesLoading ||
|
|
defaultRuntime === null ||
|
|
(runtime.trim().length > 0 && !isRuntimeAutoSeededRef.current)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (runtime !== defaultRuntime.id) setRuntime(defaultRuntime.id);
|
|
isRuntimeAutoSeededRef.current = true;
|
|
hasSeededForOpenRef.current = true;
|
|
}, [
|
|
aiConfigurationMode,
|
|
defaultRuntime,
|
|
initialValues,
|
|
open,
|
|
runtime,
|
|
runtimesLoading,
|
|
]);
|
|
|
|
// Keep setup guidance reachable when no available runtime can be inherited.
|
|
React.useEffect(() => {
|
|
if (
|
|
open &&
|
|
isCreateMode &&
|
|
!runtimesLoading &&
|
|
defaultRuntime === null &&
|
|
runtime.trim().length === 0
|
|
) {
|
|
setAiConfigurationMode("custom");
|
|
}
|
|
}, [defaultRuntime, isCreateMode, open, runtime, runtimesLoading]);
|
|
|
|
function handleOpenChange(next: boolean) {
|
|
// The catalog may veto embedded close requests; preserve the draft until unmount.
|
|
if (!next && !embedded) {
|
|
setDisplayName("");
|
|
setAvatarUrl("");
|
|
setSystemPrompt("");
|
|
setRuntime("");
|
|
setModel("");
|
|
setIsCustomModelEditing(false);
|
|
setProvider("");
|
|
setAiConfigurationMode("defaults");
|
|
setIsCustomProviderEditing(false);
|
|
setNamePoolText("");
|
|
setEnvVars({});
|
|
setBehaviorDraft(emptyPersonaBehaviorDraft);
|
|
behaviorSeedRef.current = emptyPersonaBehaviorDraft;
|
|
setShowAdvancedFields(false);
|
|
setIsAvatarUploadPending(false);
|
|
setHasUserChanges(false);
|
|
setIsAddHarnessOpen(false);
|
|
// isRuntimeAutoSeededRef and hasSeededForOpenRef are NOT reset here — the
|
|
// [initialValues, open] effect resets both when the dialog re-opens.
|
|
}
|
|
|
|
onOpenChange(next);
|
|
}
|
|
|
|
async function handleSubmit() {
|
|
// D1: the same localModeSatisfied gate as canSubmit prevents form-submit
|
|
// (Enter) from bypassing a missing credential.
|
|
if (!initialValues || !localModeSatisfied || !canSubmit) return;
|
|
|
|
const {
|
|
runtime: runtimeForSubmit,
|
|
model: modelForSubmit,
|
|
provider: providerForSubmit,
|
|
} = buildRuntimeModelProviderPayload({
|
|
runtime,
|
|
model: aiConfigurationMode === "defaults" ? "" : model,
|
|
provider: aiConfigurationMode === "defaults" ? "" : provider,
|
|
isEditMode: "id" in initialValues,
|
|
isAutoSeeded: isRuntimeAutoSeededRef.current,
|
|
initialPreviousRuntime: initialValues.runtime?.trim() ?? "",
|
|
initialModel: initialValues.model,
|
|
initialProvider: initialValues.provider,
|
|
initialModelProviderEditableWithoutRuntime,
|
|
});
|
|
const namePool = parsePersonaNamePoolText(namePoolText);
|
|
const namePoolInput =
|
|
namePool.length > 0
|
|
? namePool
|
|
: "namePool" in initialValues
|
|
? []
|
|
: undefined;
|
|
const baseInput = {
|
|
displayName: displayName.trim(),
|
|
avatarUrl: avatarUrl.trim() || undefined,
|
|
systemPrompt: systemPrompt,
|
|
runtime: runtimeForSubmit,
|
|
model: modelForSubmit,
|
|
provider: providerForSubmit,
|
|
namePool: namePoolInput,
|
|
envVars,
|
|
behavior: behaviorForSubmit(
|
|
behaviorDraft,
|
|
behaviorSeedRef.current,
|
|
"id" in initialValues,
|
|
),
|
|
};
|
|
|
|
if ("id" in initialValues) {
|
|
await onSubmit(
|
|
{
|
|
id: initialValues.id,
|
|
...baseInput,
|
|
},
|
|
{
|
|
publishCatalogUpdates: publishCatalogUpdatesOnSave && hasUserChanges,
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
await onSubmit(baseInput, { publishCatalogUpdates: false });
|
|
}
|
|
|
|
function handleSubmitForm(event: React.FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
void handleSubmit();
|
|
}
|
|
|
|
const selectedRuntime = runtimes.find((p) => p.id === runtime);
|
|
const blankRuntimeModelProviderEditable =
|
|
initialModelProviderEditableWithoutRuntime && runtime.trim().length === 0;
|
|
const runtimeCanChooseLlmProvider =
|
|
runtimeSupportsLlmProviderSelection(runtime) ||
|
|
blankRuntimeModelProviderEditable;
|
|
const llmProviderFieldVisible =
|
|
(runtime.trim().length > 0 && runtimeCanChooseLlmProvider) ||
|
|
blankRuntimeModelProviderEditable;
|
|
const trimmedProvider = provider.trim();
|
|
// Required credential env keys and file-layer config; silences requirements satisfied in the file layer.
|
|
const { data: runtimeFileConfig } = useRuntimeFileConfigQuery(runtime, {
|
|
enabled: open,
|
|
});
|
|
function handleAiConfigurationModeChange(nextMode: AgentAiConfigurationMode) {
|
|
setHasUserChanges(true);
|
|
setAiConfigurationMode(nextMode);
|
|
setIsCustomProviderEditing(false);
|
|
setIsCustomModelEditing(false);
|
|
const nextPair = agentAiConfigurationPairForMode({
|
|
current: { provider, model },
|
|
inherited: runtimeCanChooseLlmProvider
|
|
? {
|
|
provider: inheritedProviderDefault.value,
|
|
model: inheritedModelDefault.value,
|
|
}
|
|
: { provider: "", model: runtimeFileConfig?.model?.trim() ?? "" },
|
|
mode: nextMode,
|
|
needsProviderSelection: runtimeCanChooseLlmProvider,
|
|
});
|
|
setProvider(nextPair.provider);
|
|
setModel(nextPair.model);
|
|
}
|
|
const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open });
|
|
const localModeGate = React.useMemo(
|
|
() =>
|
|
computeLocalModeGate({
|
|
bakedEnvKeys,
|
|
envVars,
|
|
globalEnvVars: globalConfig.env_vars,
|
|
globalProvider: inheritedProviderDefault.value,
|
|
globalModel: inheritedModelDefault.value,
|
|
isProviderMode: false,
|
|
model,
|
|
provider: trimmedProvider,
|
|
runtimeId: runtime,
|
|
runtimeFileConfig,
|
|
}),
|
|
[
|
|
bakedEnvKeys,
|
|
envVars,
|
|
globalConfig.env_vars,
|
|
inheritedModelDefault.value,
|
|
inheritedProviderDefault.value,
|
|
model,
|
|
trimmedProvider,
|
|
runtime,
|
|
runtimeFileConfig,
|
|
],
|
|
);
|
|
// requiredEnvKeys: the gate already handles baked-, global-, and file-
|
|
// satisfied keys so no further filtering is needed.
|
|
const { requiredEnvKeys } = localModeGate;
|
|
const localModeSatisfied = localModeGate.satisfied;
|
|
// Effective provider: agent value → global fallback → file fallback.
|
|
// Mirrors the chain inside computeLocalModeGate so model-option scoping and
|
|
// model requiredness are consistent with the readiness gate.
|
|
const fileProvider = runtimeFileConfig?.provider?.trim() ?? "";
|
|
const effectiveProvider =
|
|
trimmedProvider || inheritedProviderDefault.value || fileProvider;
|
|
const apiKeyFieldState = useProviderApiKeyFieldState({
|
|
bakedEnvKeys,
|
|
effectiveEnvVars: envVars,
|
|
envVars,
|
|
fileSatisfiedEnvKeys: localModeGate.fileSatisfiedEnvKeys,
|
|
globalEnvVars: globalConfig.env_vars,
|
|
provider: effectiveProvider,
|
|
requiredEnvKeys,
|
|
});
|
|
const {
|
|
advancedRequiredEnvKeys,
|
|
inheritedLabel: apiKeyInheritedLabel,
|
|
isInherited: apiKeyIsInherited,
|
|
isRequired: apiKeyIsRequired,
|
|
secretEnvVar: topLevelSecretEnvVar,
|
|
value: apiKeyValue,
|
|
} = apiKeyFieldState;
|
|
const providerIsRequired =
|
|
aiConfigurationMode === "custom" && runtimeCanChooseLlmProvider;
|
|
const modelFieldVisible =
|
|
runtime.trim().length > 0 || blankRuntimeModelProviderEditable;
|
|
const isExplicitModelRequired = aiConfigurationMode === "custom";
|
|
const customAiPairSatisfied = agentAiConfigurationModeSatisfied(
|
|
aiConfigurationMode,
|
|
{ provider, model },
|
|
runtimeCanChooseLlmProvider,
|
|
);
|
|
const selectedRuntimeIsAvailable =
|
|
runtime.trim().length === 0 ||
|
|
selectedRuntime?.availability === "available";
|
|
// Gate model/provider validity through missingNormalizedFields — single
|
|
// source of truth with the readiness gate so display and Save can't drift.
|
|
const canSubmit =
|
|
canSubmitPersonaDialog({ displayName, isPending }) &&
|
|
(!isCreateMode || runtime.trim().length > 0) &&
|
|
(!isCreateMode || selectedRuntimeIsAvailable) &&
|
|
(!isCreateMode || !createSubmitBlocked) &&
|
|
// Crash-loop guard, create AND edit: an empty allowlist would crash
|
|
// every instance minted from this definition at startup.
|
|
personaBehaviorDraftValid(behaviorDraft) &&
|
|
// D1: localModeSatisfied covers both missingNormalizedFields AND
|
|
// missingEnvKeys — credential env keys now block submit, not just display.
|
|
localModeSatisfied &&
|
|
customAiPairSatisfied &&
|
|
!isAvatarUploadPending;
|
|
|
|
// Merge global env as the base layer so credential keys satisfied via global
|
|
// config are available to model discovery — same rationale as in AgentInstanceEditDialog.
|
|
const envVarsForDiscovery = React.useMemo(
|
|
() => ({ ...globalConfig.env_vars, ...envVars }),
|
|
[globalConfig.env_vars, envVars],
|
|
);
|
|
const {
|
|
discoveredModelOptions,
|
|
modelDiscoveryLoading,
|
|
modelDiscoveryStatus,
|
|
} = usePersonaModelDiscovery({
|
|
envVars: envVarsForDiscovery,
|
|
isCustomProviderEditing,
|
|
modelFieldVisible,
|
|
open,
|
|
// Gate provider by runtime: runtimes that don't support LLM provider
|
|
// selection (codex, claude) must not inherit the global provider — doing
|
|
// so causes them to discover models from the wrong provider.
|
|
provider: runtimeSupportsLlmProviderSelection(runtime)
|
|
? effectiveProvider
|
|
: "",
|
|
selectedRuntime,
|
|
});
|
|
const staticModelOptions = getPersonaModelOptions(runtime, effectiveProvider);
|
|
const runtimeModelOptions = getRuntimePersonaModelOptions(runtime);
|
|
const {
|
|
isCustom: isModelCustom,
|
|
isRelayMesh,
|
|
options: modelOptions,
|
|
selectValue: modelSelectValue,
|
|
showCustomInput: showCustomModelInput,
|
|
} = relayMeshModelPickerState({
|
|
discoveredOptions: discoveredModelOptions,
|
|
fallbackOptions: staticModelOptions,
|
|
knownOptions: discoveredModelOptions ?? runtimeModelOptions,
|
|
isCustomEditing: isCustomModelEditing,
|
|
model,
|
|
modelFieldVisible,
|
|
provider: effectiveProvider,
|
|
});
|
|
// On internal Block builds, BUZZ_AGENT_PROVIDER is baked in and a boot
|
|
// migration rewrites any persisted Databricks v1 values → v2. Hide the v1
|
|
// option there so it is not offered for new selections. OSS builds have no
|
|
// baked provider, so v1 remains visible.
|
|
const hideProviderIds = React.useMemo(
|
|
() =>
|
|
(bakedEnvKeys ?? []).includes("BUZZ_AGENT_PROVIDER")
|
|
? BLOCK_BUILD_HIDDEN_PROVIDER_IDS
|
|
: new Set<string>(),
|
|
[bakedEnvKeys],
|
|
);
|
|
const providerOptions = getPersonaProviderOptions(
|
|
trimmedProvider,
|
|
runtime,
|
|
inheritedProviderDefault.source === "global"
|
|
? inheritedProviderDefault.value
|
|
: "",
|
|
hideProviderIds,
|
|
);
|
|
const providerSelectValue = isCustomProviderEditing
|
|
? CUSTOM_PROVIDER_DROPDOWN_VALUE
|
|
: trimmedProvider || AUTO_PROVIDER_DROPDOWN_VALUE;
|
|
const showCustomProviderInput =
|
|
llmProviderFieldVisible && isCustomProviderEditing;
|
|
const runtimeDropdownValue = runtime.trim() || NO_RUNTIME_DROPDOWN_VALUE;
|
|
const { blankRuntimeOptionLabel, runtimeDropdownOptions } =
|
|
buildPersonaRuntimeDropdownOptions({
|
|
defaultRuntimeId: defaultRuntime?.id,
|
|
isCreateMode,
|
|
runtime,
|
|
runtimes,
|
|
runtimesLoading,
|
|
});
|
|
runtimeDropdownOptions.push(ADD_CUSTOM_HARNESS_OPTION);
|
|
const runtimeSummaryLabel = selectedRuntime
|
|
? formatRuntimeOptionLabel(selectedRuntime)
|
|
: runtime.trim() || "Not configured";
|
|
const providerDropdownOptions: PersonaDropdownOption[] = [
|
|
...providerOptions
|
|
.filter((option) => option.id.trim().length > 0)
|
|
.map((option) => ({
|
|
label: option.label,
|
|
value: option.id,
|
|
})),
|
|
{ label: "Custom provider...", value: CUSTOM_PROVIDER_DROPDOWN_VALUE },
|
|
];
|
|
const modelDropdownOptions: PersonaDropdownOption[] =
|
|
buildModelDropdownOptions({
|
|
allowCustom: !isRelayMesh,
|
|
globalModel: undefined,
|
|
loading: modelDiscoveryLoading && discoveredModelOptions === null,
|
|
loadingValue: MODEL_DISCOVERY_LOADING_VALUE,
|
|
options: modelOptions,
|
|
})
|
|
.filter(
|
|
(option) => isRelayMesh || option.value !== AUTO_MODEL_DROPDOWN_VALUE,
|
|
)
|
|
.map((option) =>
|
|
isRelayMesh && option.value === AUTO_MODEL_DROPDOWN_VALUE
|
|
? { ...option, label: "Automatic" }
|
|
: option,
|
|
);
|
|
const previewLabel = displayName.trim() || "Agent name";
|
|
const previewAvatarUrl = avatarUrl.trim() || null;
|
|
const runtimeWarningText = selectedRuntime
|
|
? runtimeAvailabilityWarning(selectedRuntime)
|
|
: null;
|
|
const runtimeWarning = runtimeWarningText ? (
|
|
<p className="text-xs text-warning">
|
|
{runtimeWarningText} Visit Settings > Agents to set it up.
|
|
</p>
|
|
) : null;
|
|
const advancedFieldsTransition = shouldReduceMotion
|
|
? { duration: 0 }
|
|
: ADVANCED_FIELDS_MOTION_TRANSITION;
|
|
|
|
React.useEffect(() => {
|
|
if (
|
|
!open ||
|
|
!modelFieldVisible ||
|
|
isCustomModelEditing ||
|
|
!shouldClearKnownModelForSelectionScope({
|
|
model,
|
|
provider: effectiveProvider,
|
|
runtime,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
|
|
setModel("");
|
|
setIsCustomModelEditing(false);
|
|
}, [
|
|
isCustomModelEditing,
|
|
model,
|
|
modelFieldVisible,
|
|
open,
|
|
effectiveProvider,
|
|
runtime,
|
|
]);
|
|
|
|
const selection: RuntimeModelProviderSelection = {
|
|
provider,
|
|
model,
|
|
isCustomProviderEditing,
|
|
isCustomModelEditing,
|
|
envVars,
|
|
};
|
|
|
|
function applySelection(next: RuntimeModelProviderSelection) {
|
|
setProvider(next.provider);
|
|
setModel(next.model);
|
|
setIsCustomProviderEditing(next.isCustomProviderEditing);
|
|
setIsCustomModelEditing(next.isCustomModelEditing);
|
|
setEnvVars(next.envVars);
|
|
}
|
|
|
|
function handleRuntimeDropdownChange(nextValue: string) {
|
|
const action = runtimeDropdownAction(nextValue);
|
|
if (action.kind === "add-custom-harness") {
|
|
setIsAddHarnessOpen(true);
|
|
return;
|
|
}
|
|
setHasUserChanges(true);
|
|
const nextRuntime = action.runtimeId;
|
|
// The user made an explicit choice — no longer auto-seeded.
|
|
isRuntimeAutoSeededRef.current = false;
|
|
setRuntime(nextRuntime);
|
|
applySelection(
|
|
selectionOnRuntimeChange(selection, {
|
|
previousRuntime: runtime,
|
|
nextRuntime,
|
|
nextRuntimeCanChooseProvider:
|
|
nextRuntime.trim().length > 0 &&
|
|
runtimeSupportsLlmProviderSelection(nextRuntime),
|
|
lockedRuntimeReset: "full",
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Routed through the normal change handler so a harness registered inline
|
|
// resets model/provider exactly as a hand-picked one would. Scoped to `open`
|
|
// so a pending id can't outlive the dialog that started the registration.
|
|
const selectSavedHarness = usePendingHarnessSelection(
|
|
runtimes,
|
|
handleRuntimeDropdownChange,
|
|
open,
|
|
);
|
|
|
|
function handleProviderDropdownChange(nextValue: string) {
|
|
setHasUserChanges(true);
|
|
const nextProvider =
|
|
nextValue === AUTO_PROVIDER_DROPDOWN_VALUE ? "" : nextValue;
|
|
if (nextProvider === "relay-mesh" && runtime !== "buzz-agent") {
|
|
handleRuntimeDropdownChange("buzz-agent");
|
|
}
|
|
const nextSelection = selectionOnProviderDropdownChange(selection, {
|
|
runtime: nextProvider === "relay-mesh" ? "buzz-agent" : runtime,
|
|
nextValue,
|
|
clearModelWhenApiKeyMissing: true,
|
|
});
|
|
applySelection({
|
|
...nextSelection,
|
|
model: nextProvider === "relay-mesh" ? "auto" : nextSelection.model,
|
|
});
|
|
}
|
|
|
|
function handleModelDropdownChange(nextValue: string) {
|
|
setHasUserChanges(true);
|
|
applySelection(
|
|
selectionOnModelDropdownChange(selection, {
|
|
nextValue,
|
|
clearKnownModelOnCustomEntry: true,
|
|
isModelCustom,
|
|
}),
|
|
);
|
|
}
|
|
|
|
const footer = (
|
|
<AgentDefinitionDialogFooter
|
|
canSubmit={canSubmit}
|
|
isAvatarUploadPending={isAvatarUploadPending}
|
|
isPending={isPending}
|
|
onCancel={() => handleOpenChange(false)}
|
|
publishesCatalogUpdates={publishCatalogUpdatesOnSave && hasUserChanges}
|
|
submitLabel={submitLabel}
|
|
/>
|
|
);
|
|
const form = (
|
|
<form
|
|
className="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)]"
|
|
id="persona-dialog-form"
|
|
onChangeCapture={() => setHasUserChanges(true)}
|
|
onSubmit={handleSubmitForm}
|
|
>
|
|
<AgentCreationPreview
|
|
avatarUrl={previewAvatarUrl}
|
|
disabled={isPending || isAvatarUploadPending}
|
|
label={previewLabel}
|
|
onClearAvatar={() => {
|
|
setHasUserChanges(true);
|
|
setAvatarUrl("");
|
|
}}
|
|
onUploadPendingChange={setIsAvatarUploadPending}
|
|
onSelectAvatar={(nextAvatarUrl) => {
|
|
setHasUserChanges(true);
|
|
setAvatarUrl(nextAvatarUrl);
|
|
}}
|
|
/>
|
|
|
|
<div className="space-y-5">
|
|
<div className="space-y-1.5">
|
|
<label
|
|
className="text-sm font-medium text-foreground"
|
|
htmlFor="persona-display-name"
|
|
>
|
|
Agent name
|
|
</label>
|
|
<div
|
|
className={cn(
|
|
"flex min-h-11 items-center px-3",
|
|
PERSONA_FIELD_SHELL_CLASS,
|
|
)}
|
|
>
|
|
<Input
|
|
autoCorrect="off"
|
|
className={cn(
|
|
"h-8 px-0 py-0 leading-6",
|
|
PERSONA_FIELD_CONTROL_CLASS,
|
|
)}
|
|
disabled={isPending}
|
|
id="persona-display-name"
|
|
onChange={(event) => setDisplayName(event.target.value)}
|
|
placeholder="Fizz"
|
|
value={displayName}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label
|
|
className="text-sm font-medium text-foreground"
|
|
htmlFor="persona-system-prompt"
|
|
>
|
|
Agent instructions
|
|
</label>
|
|
<div className={PERSONA_FIELD_SHELL_CLASS}>
|
|
<Textarea
|
|
className={cn(
|
|
"min-h-40 resize-y px-3 py-3 leading-5",
|
|
PERSONA_FIELD_CONTROL_CLASS,
|
|
)}
|
|
disabled={isPending}
|
|
id="persona-system-prompt"
|
|
onChange={(event) => setSystemPrompt(event.target.value)}
|
|
placeholder="Describe what this agent should do."
|
|
value={systemPrompt}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{modelFieldVisible ? (
|
|
<AgentAiConfigurationModeField
|
|
mode={aiConfigurationMode}
|
|
needsProviderSelection={runtimeCanChooseLlmProvider}
|
|
onModeChange={handleAiConfigurationModeChange}
|
|
/>
|
|
) : null}
|
|
|
|
<div
|
|
className="space-y-5"
|
|
data-testid={`agent-${aiConfigurationMode}-configuration-section`}
|
|
>
|
|
{aiConfigurationMode === "custom" ? (
|
|
<AgentHarnessField
|
|
disabled={isPending || runtimesLoading}
|
|
onValueChange={handleRuntimeDropdownChange}
|
|
options={runtimeDropdownOptions}
|
|
placeholder={blankRuntimeOptionLabel}
|
|
value={runtimeDropdownValue}
|
|
warning={runtimeWarning}
|
|
/>
|
|
) : null}
|
|
|
|
{llmProviderFieldVisible && aiConfigurationMode === "custom" ? (
|
|
<div className="space-y-1.5">
|
|
<RequiredFieldLabel
|
|
htmlFor="persona-llm-provider"
|
|
isRequired={providerIsRequired}
|
|
>
|
|
LLM provider
|
|
{!providerIsRequired ? (
|
|
<span className={PERSONA_LABEL_OPTIONAL_CLASS}>Optional</span>
|
|
) : null}
|
|
</RequiredFieldLabel>
|
|
<PersonaDropdownField
|
|
disabled={isPending}
|
|
id="persona-llm-provider"
|
|
onValueChange={handleProviderDropdownChange}
|
|
options={providerDropdownOptions}
|
|
placeholder="Choose a provider"
|
|
value={providerSelectValue}
|
|
/>
|
|
{showCustomProviderInput ? (
|
|
<div
|
|
className={cn(
|
|
"mt-2 flex min-h-11 items-center px-3",
|
|
PERSONA_FIELD_SHELL_CLASS,
|
|
)}
|
|
>
|
|
<Input
|
|
aria-label="Custom provider ID"
|
|
autoCorrect="off"
|
|
className={cn(
|
|
"h-8 px-0 py-0 leading-6",
|
|
PERSONA_FIELD_CONTROL_CLASS,
|
|
)}
|
|
disabled={isPending}
|
|
id="persona-custom-provider"
|
|
onChange={(event) => setProvider(event.target.value)}
|
|
placeholder="Custom provider ID"
|
|
value={provider}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{llmProviderFieldVisible &&
|
|
aiConfigurationMode === "custom" &&
|
|
topLevelSecretEnvVar ? (
|
|
<PersonaProviderApiKeyField
|
|
disabled={isPending}
|
|
envVarName={topLevelSecretEnvVar}
|
|
isInherited={apiKeyIsInherited}
|
|
inheritedLabel={apiKeyInheritedLabel}
|
|
isRequired={apiKeyIsRequired}
|
|
label={getProviderApiKeyLabel(effectiveProvider) ?? "API key"}
|
|
onValueChange={(next) => {
|
|
setEnvVars((prev) => ({
|
|
...prev,
|
|
[topLevelSecretEnvVar]: next,
|
|
}));
|
|
}}
|
|
value={apiKeyValue}
|
|
/>
|
|
) : null}
|
|
|
|
<AnimatePresence initial={false}>
|
|
{modelFieldVisible && aiConfigurationMode === "custom" ? (
|
|
<PersonaModelField
|
|
disabled={isPending}
|
|
isExplicitModelRequired={isExplicitModelRequired}
|
|
model={model}
|
|
modelDiscoveryStatus={modelDiscoveryStatus}
|
|
modelDropdownOptions={modelDropdownOptions}
|
|
modelSelectValue={modelSelectValue}
|
|
onCustomModelChange={setModel}
|
|
showSharedComputeAutoHint={
|
|
isRelayMesh && modelSelectValue === AUTO_MODEL_DROPDOWN_VALUE
|
|
}
|
|
onModelValueChange={handleModelDropdownChange}
|
|
showCustomModelInput={showCustomModelInput}
|
|
transition={advancedFieldsTransition}
|
|
/>
|
|
) : null}
|
|
</AnimatePresence>
|
|
|
|
{aiConfigurationMode === "defaults" ? (
|
|
<AgentCreateAiDefaultsSummary
|
|
canChooseProvider={runtimeCanChooseLlmProvider}
|
|
harness={runtimeSummaryLabel}
|
|
inheritedModel={inheritedModelDefault}
|
|
inheritedProvider={inheritedProviderDefault}
|
|
isConfigured={localModeGate.satisfied}
|
|
model={runtimeFileConfig?.model}
|
|
onEditDefaults={() => setAiDefaultsOpen(true)}
|
|
triggerRef={aiDefaultsTriggerRef}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
|
|
<AgentDefaultsDialog
|
|
onOpenChange={setAiDefaultsOpen}
|
|
open={runtimeCanChooseLlmProvider && aiDefaultsOpen}
|
|
returnFocusRef={aiDefaultsTriggerRef}
|
|
/>
|
|
|
|
<AddCustomHarnessDialog
|
|
onOpenChange={setIsAddHarnessOpen}
|
|
onSaved={selectSavedHarness}
|
|
open={isAddHarnessOpen}
|
|
/>
|
|
|
|
<div className="space-y-3">
|
|
<button
|
|
aria-expanded={showAdvancedFields}
|
|
className="inline-flex h-9 items-center gap-1.5 text-sm font-medium text-foreground transition-colors hover:text-foreground/80 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
|
onClick={() => setShowAdvancedFields((current) => !current)}
|
|
type="button"
|
|
>
|
|
<span>Advanced</span>
|
|
{(isCreateMode && createSubmitBlocked) ||
|
|
localModeGate.missingEnvKeys.some((key) =>
|
|
advancedRequiredEnvKeys.includes(key),
|
|
) ? (
|
|
<span
|
|
aria-hidden="true"
|
|
className="rounded-full bg-destructive/10 px-2 py-0.5 text-xs text-destructive"
|
|
data-testid="persona-advanced-required-badge"
|
|
>
|
|
Required
|
|
</span>
|
|
) : null}
|
|
<ChevronDown
|
|
className={cn(
|
|
"h-4 w-4 text-muted-foreground transition-transform duration-150 ease-out",
|
|
showAdvancedFields && "rotate-180",
|
|
)}
|
|
/>
|
|
</button>
|
|
<AnimatePresence initial={false}>
|
|
{showAdvancedFields ? (
|
|
<motion.div
|
|
animate={{ height: "auto", opacity: 1, scale: 1 }}
|
|
className="origin-top overflow-hidden"
|
|
exit={{ height: 0, opacity: 0, scale: 0.98 }}
|
|
initial={{ height: 0, opacity: 0, scale: 0.98 }}
|
|
key="persona-advanced-fields"
|
|
transition={advancedFieldsTransition}
|
|
>
|
|
<PersonaAdvancedFields
|
|
afterRespondTo={isCreateMode ? createRunSection : undefined}
|
|
behaviorDraft={behaviorDraft}
|
|
disabled={isPending}
|
|
envVars={envVars}
|
|
fileSatisfiedEnvKeys={localModeGate.fileSatisfiedEnvKeys}
|
|
hiddenEnvKeys={
|
|
topLevelSecretEnvVar ? [topLevelSecretEnvVar] : []
|
|
}
|
|
inheritedEnvVars={inheritedEnvVarsForAdvanced}
|
|
model={model}
|
|
modelTuningRuntimeId={runtime}
|
|
namePoolText={namePoolText}
|
|
catalogStatus={runtimeCatalogStatus}
|
|
selectedRuntime={selectedRuntime}
|
|
onBehaviorDraftChange={(nextBehaviorDraft) => {
|
|
setHasUserChanges(true);
|
|
setBehaviorDraft(nextBehaviorDraft);
|
|
}}
|
|
onEnvVarsChange={setEnvVars}
|
|
onNamePoolTextChange={setNamePoolText}
|
|
provider={effectiveProvider}
|
|
requiredEnvKeys={advancedRequiredEnvKeys}
|
|
/>
|
|
</motion.div>
|
|
) : null}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{error ? (
|
|
<p className="text-sm text-destructive">{error.message}</p>
|
|
) : null}
|
|
</div>
|
|
</form>
|
|
);
|
|
|
|
return (
|
|
<AgentDefinitionDialogShell
|
|
description={description}
|
|
embedded={embedded}
|
|
footer={footer}
|
|
onOpenChange={(nextOpen) => {
|
|
if (!nextOpen && (isPending || isAvatarUploadPending)) return;
|
|
handleOpenChange(nextOpen);
|
|
}}
|
|
open={open}
|
|
title={title}
|
|
>
|
|
{form}
|
|
</AgentDefinitionDialogShell>
|
|
);
|
|
}
|