mirror of
https://github.com/block/buzz.git
synced 2026-11-05 17:01:35 +00:00
Add fieldOwner(field, ctx) exported from agentFormModel.ts. It reads from FIELD_OWNERS for the base owner and handles two sets of context-dependent overrides: (1) rows-9-10 dual-owner fields (respondTo, respondToAllowlist, parallelism) are D-owned in definition-only context and I-owned otherwise; (2) definition-fallback fields (systemPrompt, model, provider) are D-owned when a definition is present and I-owned in instance-only context. Wire fieldOwner into emitAgentFormDiff via isD/isI helpers. Every field routing decision now consults fieldOwner(field, ctx) rather than hardcoded hasInst/def===null conditionals — making FIELD_OWNERS the single authoritative routing source as the JSDoc claims. Add 8 new tests to agentFormModel.test.mjs: fieldOwner context resolution for respondTo/parallelism/systemPrompt/model/provider, delegation to FIELD_OWNERS for static fields, and end-to-end emit routing via fieldOwner. 4568/4568 pass. Revert mobile/pubspec.lock to origin/main (boundary violation from round-4 commit — lockfile churn is pre-existing local dirt, not in scope). Clean up stale AgentInstanceEditDialog references in AGENTS.md, agentConfigOptions.tsx, agentConfigControls.tsx, AgentRunLocationContext.tsx, AgentCreationPreview.tsx, AgentDefinitionDialog.tsx. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
799 lines
27 KiB
TypeScript
799 lines
27 KiB
TypeScript
import type {
|
|
AcpRuntimeCatalogEntry,
|
|
GlobalAgentConfig,
|
|
} from "@/shared/api/types";
|
|
import { BUZZ_AGENT_THINKING_EFFORT } from "./buzzAgentConfig";
|
|
import type { RuntimeFileConfigSubset } from "@/shared/api/tauri";
|
|
// Dialogs import getDefaultPersonaRuntime via this re-export; lib code imports
|
|
// directly from lib/resolvePersonaRuntime.
|
|
export { getDefaultPersonaRuntime } from "../lib/resolvePersonaRuntime";
|
|
|
|
/**
|
|
* Provider ids suppressed from the selection list on internal Block builds.
|
|
* Databricks v1 (`"databricks"`) is boot-migrated to v2 on those builds, so
|
|
* offering it for new selections would create a regression path.
|
|
* OSS builds pass an empty `Set` so v1 remains visible.
|
|
*
|
|
* All dialog sites that show a provider picker import this constant —
|
|
* `AgentDefinitionDialog`, `AgentEditMergedDialog`, and
|
|
* `AgentDefaultsSettingsCard` — making it the single source of truth for
|
|
* which provider ids to suppress on Block builds.
|
|
*/
|
|
export const BLOCK_BUILD_HIDDEN_PROVIDER_IDS: ReadonlySet<string> = new Set([
|
|
"databricks",
|
|
]);
|
|
|
|
export const PERSONA_FIELD_SHELL_CLASS =
|
|
"rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50";
|
|
export const PERSONA_FIELD_CONTROL_CLASS =
|
|
"border-0 bg-transparent text-muted-foreground shadow-none outline-none ring-0 transition-colors duration-150 ease-out placeholder:text-muted-foreground/55 focus:bg-transparent focus:text-muted-foreground focus:outline-hidden focus-visible:ring-0";
|
|
export const PERSONA_LABEL_OPTIONAL_CLASS =
|
|
"ml-1 text-xs font-normal text-muted-foreground/50";
|
|
|
|
/** Shared advanced-fields expand/collapse easing for the agent dialogs. */
|
|
export const ADVANCED_FIELDS_MOTION_TRANSITION = {
|
|
duration: 0.18,
|
|
ease: [0.23, 1, 0.32, 1],
|
|
} as const;
|
|
|
|
export const AUTO_MODEL_DROPDOWN_VALUE = "__auto_model__";
|
|
export const CUSTOM_MODEL_DROPDOWN_VALUE = "__custom_model__";
|
|
export const AUTO_PROVIDER_DROPDOWN_VALUE = "__auto_provider__";
|
|
export const CUSTOM_PROVIDER_DROPDOWN_VALUE = "__custom_provider__";
|
|
export const NO_RUNTIME_DROPDOWN_VALUE = "__no_runtime__";
|
|
|
|
const KNOWN_LLM_PROVIDER_IDS = [
|
|
"anthropic",
|
|
"databricks",
|
|
"databricks_v2",
|
|
"openai",
|
|
"openai-compat",
|
|
"openrouter",
|
|
] as const;
|
|
|
|
type PersonaLlmProviderId = (typeof KNOWN_LLM_PROVIDER_IDS)[number];
|
|
|
|
export type PersonaModelOption = {
|
|
id: string;
|
|
label: string;
|
|
};
|
|
|
|
export type PersonaDropdownOption = {
|
|
disabled?: boolean;
|
|
label: string;
|
|
value: string;
|
|
};
|
|
|
|
/**
|
|
* Per-provider credential configuration.
|
|
*
|
|
* `requiredEnvKeys`: keys that must be present in the agent's effective env for
|
|
* the provider to work (surfaced as amber required rows in EnvVarsEditor).
|
|
* `secretEnvVar` + `apiKeyLabel`: paired — either both are present or neither
|
|
* is. `secretEnvVar` is the env key holding the user-typed secret; clearing
|
|
* it when the user switches providers ensures no orphaned credentials remain.
|
|
* Databricks uses OAuth PKCE (no typed secret), so it carries neither field.
|
|
* `apiKeyLabel` is the human-readable label shown in the credential field;
|
|
* derived by `getProviderApiKeyLabel` — single source of truth for all UI
|
|
* surfaces so they never drift.
|
|
*
|
|
* Mirrors the Rust `readiness::buzz_agent_requirements` /
|
|
* `readiness::goose_requirements` logic — keep in sync.
|
|
*/
|
|
export type ProviderCredentialConfig =
|
|
| {
|
|
requiredEnvKeys: readonly string[];
|
|
secretEnvVar?: undefined;
|
|
apiKeyLabel?: undefined;
|
|
}
|
|
| {
|
|
requiredEnvKeys: readonly string[];
|
|
/** The env key holding the user-typed API secret. */
|
|
secretEnvVar: string;
|
|
/** Display label for the credential input field, e.g. "Anthropic API Key". */
|
|
apiKeyLabel: string;
|
|
};
|
|
|
|
/**
|
|
* Unified provider credential config table. Single source of truth for both
|
|
* required-key surfacing and provider-switch clearing semantics.
|
|
*/
|
|
const PROVIDER_CREDENTIAL_CONFIG: Partial<
|
|
Record<string, ProviderCredentialConfig>
|
|
> = {
|
|
anthropic: {
|
|
requiredEnvKeys: ["ANTHROPIC_API_KEY"],
|
|
secretEnvVar: "ANTHROPIC_API_KEY",
|
|
apiKeyLabel: "Anthropic API Key",
|
|
},
|
|
openai: {
|
|
requiredEnvKeys: ["OPENAI_COMPAT_API_KEY"],
|
|
secretEnvVar: "OPENAI_COMPAT_API_KEY",
|
|
apiKeyLabel: "OpenAI Runtime API Key",
|
|
},
|
|
"openai-compat": {
|
|
requiredEnvKeys: ["OPENAI_COMPAT_API_KEY"],
|
|
secretEnvVar: "OPENAI_COMPAT_API_KEY",
|
|
apiKeyLabel: "OpenAI-compatible Runtime API Key",
|
|
},
|
|
databricks: {
|
|
// DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path.
|
|
requiredEnvKeys: ["DATABRICKS_HOST"],
|
|
// No secretEnvVar / apiKeyLabel: DATABRICKS_HOST is a URL, not a secret
|
|
// credential, and is not cleared on provider switch (unlike API keys).
|
|
},
|
|
databricks_v2: {
|
|
// DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path.
|
|
requiredEnvKeys: ["DATABRICKS_HOST"],
|
|
},
|
|
// Hyphen-alias for databricks_v2 emitted by the migration (#1686).
|
|
"databricks-v2": {
|
|
requiredEnvKeys: ["DATABRICKS_HOST"],
|
|
},
|
|
openrouter: {
|
|
requiredEnvKeys: ["OPENROUTER_API_KEY"],
|
|
secretEnvVar: "OPENROUTER_API_KEY",
|
|
apiKeyLabel: "OpenRouter API Key",
|
|
},
|
|
};
|
|
|
|
const DEFAULT_MODEL_OPTION: PersonaModelOption = {
|
|
id: "",
|
|
label: "Default model",
|
|
};
|
|
|
|
export const PERSONA_LLM_PROVIDER_OPTIONS: readonly PersonaModelOption[] = [
|
|
{ id: "anthropic", label: "Anthropic" },
|
|
{ id: "openai", label: "OpenAI" },
|
|
{ id: "openai-compat", label: "OpenAI-compatible" },
|
|
{ id: "openrouter", label: "OpenRouter" },
|
|
{ id: "relay-mesh", label: "Buzz shared compute" },
|
|
{ id: "databricks", label: "Databricks" },
|
|
{ id: "databricks_v2", label: "Databricks v2" },
|
|
];
|
|
|
|
const PERSONA_MODEL_OPTIONS_BY_RUNTIME: Record<
|
|
string,
|
|
readonly PersonaModelOption[]
|
|
> = {
|
|
goose: [DEFAULT_MODEL_OPTION],
|
|
"buzz-agent": [DEFAULT_MODEL_OPTION],
|
|
claude: [DEFAULT_MODEL_OPTION],
|
|
codex: [DEFAULT_MODEL_OPTION],
|
|
};
|
|
|
|
export function getRuntimePersonaModelOptions(
|
|
runtimeId: string,
|
|
): readonly PersonaModelOption[] {
|
|
return PERSONA_MODEL_OPTIONS_BY_RUNTIME[runtimeId] ?? [DEFAULT_MODEL_OPTION];
|
|
}
|
|
|
|
function isKnownLlmProvider(
|
|
providerId: string,
|
|
): providerId is PersonaLlmProviderId {
|
|
return (KNOWN_LLM_PROVIDER_IDS as readonly string[]).includes(providerId);
|
|
}
|
|
|
|
/**
|
|
* Required credential env keys for the given runtime + provider combination.
|
|
* Derived from PROVIDER_CREDENTIAL_CONFIG — single source of truth.
|
|
*
|
|
* buzz-agent and goose use provider-specific credentials; claude and codex
|
|
* handle auth via CLI login (surfaced separately via the CliLogin surface).
|
|
*/
|
|
export function requiredCredentialEnvKeys(
|
|
runtimeId: string,
|
|
provider: string,
|
|
): readonly string[] {
|
|
const normalizedRuntime = runtimeId.trim();
|
|
if (normalizedRuntime !== "buzz-agent" && normalizedRuntime !== "goose") {
|
|
return [];
|
|
}
|
|
const config = PROVIDER_CREDENTIAL_CONFIG[provider.trim().toLowerCase()];
|
|
return config?.requiredEnvKeys ?? [];
|
|
}
|
|
|
|
export function isMissingRequiredDropdownField(
|
|
field: { isRequired: boolean } | null | undefined,
|
|
value: string,
|
|
) {
|
|
return field?.isRequired === true && value.trim().length === 0;
|
|
}
|
|
|
|
export function runtimeSupportsLlmProviderSelection(runtimeId: string) {
|
|
return runtimeId === "buzz-agent" || runtimeId === "goose";
|
|
}
|
|
|
|
/** Clears values whose meaning or support changes with the selected harness. */
|
|
export function resetConfigForHarnessChange(
|
|
config: GlobalAgentConfig,
|
|
runtimeId: string,
|
|
): GlobalAgentConfig {
|
|
const nextEnvVars = { ...config.env_vars };
|
|
delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT];
|
|
|
|
return {
|
|
...config,
|
|
env_vars: nextEnvVars,
|
|
model: null,
|
|
preferred_runtime: runtimeId || null,
|
|
provider:
|
|
runtimeSupportsLlmProviderSelection(runtimeId) &&
|
|
config.provider !== "relay-mesh"
|
|
? config.provider
|
|
: null,
|
|
};
|
|
}
|
|
|
|
function effectiveModelProviderForOptions(
|
|
runtimeId: string,
|
|
providerId: string | null | undefined,
|
|
) {
|
|
if (
|
|
runtimeId.trim().length > 0 &&
|
|
!runtimeSupportsLlmProviderSelection(runtimeId)
|
|
) {
|
|
return "";
|
|
}
|
|
|
|
return providerId?.trim() ?? "";
|
|
}
|
|
|
|
export function getPersonaModelOptions(
|
|
runtimeId: string,
|
|
providerId: string | null | undefined,
|
|
): readonly PersonaModelOption[] {
|
|
const options = getRuntimePersonaModelOptions(runtimeId);
|
|
const trimmedProvider = effectiveModelProviderForOptions(
|
|
runtimeId,
|
|
providerId,
|
|
);
|
|
if (trimmedProvider.length === 0) {
|
|
return options.filter((option) => option.id.length === 0);
|
|
}
|
|
if (!isKnownLlmProvider(trimmedProvider)) {
|
|
return options;
|
|
}
|
|
|
|
return options.filter(
|
|
(option) =>
|
|
option.id.length === 0 && !providerRequiresExplicitModel(trimmedProvider),
|
|
);
|
|
}
|
|
|
|
function hasExactPersonaModelOption(
|
|
options: readonly PersonaModelOption[],
|
|
modelId: string,
|
|
) {
|
|
const trimmedModel = modelId.trim();
|
|
return (
|
|
trimmedModel.length > 0 &&
|
|
options.some((option) => option.id === trimmedModel)
|
|
);
|
|
}
|
|
|
|
export function hasPersonaModelOption(
|
|
options: readonly PersonaModelOption[],
|
|
modelId: string,
|
|
) {
|
|
const trimmedModel = modelId.trim();
|
|
return (
|
|
trimmedModel.length === 0 ||
|
|
options.some((option) => option.id === trimmedModel)
|
|
);
|
|
}
|
|
|
|
export function getModelSelectValue({
|
|
isCustomModelEditing,
|
|
isModelCustom,
|
|
model,
|
|
}: {
|
|
isCustomModelEditing: boolean;
|
|
isModelCustom: boolean;
|
|
model: string;
|
|
}) {
|
|
if (isCustomModelEditing || isModelCustom) {
|
|
return CUSTOM_MODEL_DROPDOWN_VALUE;
|
|
}
|
|
|
|
return model.trim() || AUTO_MODEL_DROPDOWN_VALUE;
|
|
}
|
|
|
|
export function providerRequiresExplicitModel(
|
|
providerId: string | null | undefined,
|
|
) {
|
|
const trimmedProvider = providerId?.trim() ?? "";
|
|
return (
|
|
trimmedProvider === "anthropic" ||
|
|
trimmedProvider === "openai" ||
|
|
trimmedProvider === "openai-compat" ||
|
|
trimmedProvider === "openrouter"
|
|
);
|
|
}
|
|
|
|
export function providerDisplayLabel(providerId: string) {
|
|
const trimmedProvider = providerId.trim();
|
|
return trimmedProvider === "relay-mesh"
|
|
? "Buzz shared compute"
|
|
: trimmedProvider;
|
|
}
|
|
|
|
export function getDefaultLlmProviderLabel(
|
|
_runtimeId: string,
|
|
globalProvider?: string,
|
|
) {
|
|
const trimmedGlobal = (globalProvider ?? "").trim();
|
|
return trimmedGlobal
|
|
? `Use agent defaults (${providerDisplayLabel(trimmedGlobal)})`
|
|
: "Select a provider\u2026";
|
|
}
|
|
|
|
/** Returns the zero-value model option label.
|
|
*
|
|
* When a global model is configured, the empty-model option reads
|
|
* `Use agent defaults (<model>)` so users can see which model will run.
|
|
* Otherwise falls back to the generic `"Default model"` placeholder.
|
|
*/
|
|
export function getDefaultLlmModelLabel(globalModel?: string) {
|
|
const trimmedGlobal = (globalModel ?? "").trim();
|
|
return trimmedGlobal
|
|
? `Use agent defaults (${trimmedGlobal})`
|
|
: "Default model";
|
|
}
|
|
|
|
/**
|
|
* Builds the base model dropdown options for the template dialog
|
|
* (`AgentDefinitionDialog`), applying the global-model inherit-option guard.
|
|
*
|
|
* Explicit-model providers (e.g. anthropic) have their zero-value option
|
|
* filtered out by `getPersonaModelOptions`, so a relabel-only map would never
|
|
* produce the `Use agent defaults (<model>)` entry. This helper prepends
|
|
* it when `globalModel` is non-empty AND no zero-value option already exists,
|
|
* making the inherited global model visible and selectable in the dropdown.
|
|
*
|
|
* GUARD: prepend only when `globalModel.trim()` is non-empty — if no global
|
|
* model is set, an explicit-model provider must still block Save (no empty
|
|
* inherit entry that bypasses the model requirement).
|
|
*/
|
|
export function buildTemplateModelDropdownOptions(
|
|
modelOptions: readonly PersonaModelOption[],
|
|
inheritedModel: string,
|
|
inheritedModelLabel = getDefaultLlmModelLabel(inheritedModel),
|
|
): PersonaDropdownOption[] {
|
|
const trimmedInheritedModel = inheritedModel.trim();
|
|
const hasZeroValue = modelOptions.some((o) => o.id === "");
|
|
const base: readonly PersonaModelOption[] =
|
|
!hasZeroValue && trimmedInheritedModel.length > 0
|
|
? [{ id: "", label: inheritedModelLabel }, ...modelOptions]
|
|
: modelOptions;
|
|
return base.map((option) => ({
|
|
label: option.id === "" ? inheritedModelLabel : option.label,
|
|
value: option.id || AUTO_MODEL_DROPDOWN_VALUE,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Build the provider dropdown options for a persona/instance dialog.
|
|
*
|
|
* `hideProviderIds` suppresses specific provider ids from the base list while
|
|
* still preserving the `(current)` tail-append for saved values that are in
|
|
* the hidden set — so an agent already persisted with a hidden provider
|
|
* continues to render its current value, while the hidden option is not
|
|
* offered for new selections.
|
|
*
|
|
* Internal Block builds pass `BLOCK_BUILD_HIDDEN_PROVIDER_IDS` to hide the
|
|
* legacy Databricks v1 option (the boot migration rewrites v1→v2 on those
|
|
* builds). OSS builds pass an empty Set so v1 remains visible.
|
|
*/
|
|
export function getPersonaProviderOptions(
|
|
currentProvider: string,
|
|
runtimeId: string,
|
|
globalProvider?: string,
|
|
hideProviderIds?: ReadonlySet<string>,
|
|
): readonly PersonaModelOption[] {
|
|
const trimmedProvider = currentProvider.trim();
|
|
const defaultProviderOptions = [
|
|
{ id: "", label: getDefaultLlmProviderLabel(runtimeId, globalProvider) },
|
|
];
|
|
const filteredOptions = hideProviderIds?.size
|
|
? PERSONA_LLM_PROVIDER_OPTIONS.filter((o) => !hideProviderIds.has(o.id))
|
|
: PERSONA_LLM_PROVIDER_OPTIONS;
|
|
const options = [...defaultProviderOptions, ...filteredOptions];
|
|
if (
|
|
trimmedProvider.length === 0 ||
|
|
options.some((option) => option.id === trimmedProvider)
|
|
) {
|
|
return options;
|
|
}
|
|
|
|
return [
|
|
...options,
|
|
{ id: trimmedProvider, label: `${trimmedProvider} (current)` },
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Returns the secret credential env var for the provider, if any.
|
|
* Derived from PROVIDER_CREDENTIAL_CONFIG.secretEnvVar.
|
|
*/
|
|
export function getProviderApiKeyEnvVar(providerId: string): string | null {
|
|
return (
|
|
PROVIDER_CREDENTIAL_CONFIG[providerId.trim().toLowerCase()]?.secretEnvVar ??
|
|
null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns the display label for the provider's API key field, if any.
|
|
* Derived from PROVIDER_CREDENTIAL_CONFIG.apiKeyLabel — single source of truth
|
|
* for all credential field labels so every surface stays in sync.
|
|
*
|
|
* Returns null when the provider has no typed-secret credential (e.g.,
|
|
* Databricks, which uses OAuth PKCE).
|
|
*/
|
|
export function getProviderApiKeyLabel(providerId: string): string | null {
|
|
return (
|
|
PROVIDER_CREDENTIAL_CONFIG[providerId.trim().toLowerCase()]?.apiKeyLabel ??
|
|
null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Muted contextual hint for the `OPENAI_API_KEY` row in env editors.
|
|
* Pass as `keyAnnotations` to every `EnvVarsEditor` that may surface this key
|
|
* (Agent Defaults, agent edit dialog, persona definition dialog). Exported
|
|
* so the constant is defined once and never duplicated across surfaces.
|
|
*/
|
|
export const CARD_MINT_KEY_ANNOTATIONS: Readonly<Record<string, string>> = {
|
|
OPENAI_API_KEY: "Used for minting agent trading cards",
|
|
};
|
|
|
|
export function shouldClearKnownModelForSelectionScope({
|
|
model,
|
|
provider,
|
|
runtime,
|
|
}: {
|
|
model: string;
|
|
provider: string | null | undefined;
|
|
runtime: string;
|
|
}) {
|
|
const runtimeOptions = getRuntimePersonaModelOptions(runtime);
|
|
const scopedOptions = getPersonaModelOptions(runtime, provider);
|
|
return (
|
|
hasExactPersonaModelOption(runtimeOptions, model) &&
|
|
!hasExactPersonaModelOption(scopedOptions, model)
|
|
);
|
|
}
|
|
|
|
export function formatRuntimeOptionLabel(runtime: AcpRuntimeCatalogEntry) {
|
|
const suffix =
|
|
runtime.availability === "adapter_missing"
|
|
? " (adapter missing)"
|
|
: runtime.availability === "adapter_outdated"
|
|
? " (adapter outdated)"
|
|
: runtime.availability === "cli_missing"
|
|
? " (CLI missing)"
|
|
: runtime.availability === "not_installed"
|
|
? " (not installed)"
|
|
: "";
|
|
return `${runtime.label}${suffix}`;
|
|
}
|
|
|
|
export function buildPersonaRuntimeDropdownOptions({
|
|
defaultRuntimeId,
|
|
isCreateMode,
|
|
runtime,
|
|
runtimes,
|
|
runtimesLoading,
|
|
}: {
|
|
defaultRuntimeId?: string;
|
|
isCreateMode: boolean;
|
|
runtime: string;
|
|
runtimes: AcpRuntimeCatalogEntry[];
|
|
runtimesLoading: boolean;
|
|
}): {
|
|
blankRuntimeOptionLabel: string;
|
|
runtimeDropdownOptions: PersonaDropdownOption[];
|
|
} {
|
|
const blankRuntimeOptionLabel = runtimesLoading
|
|
? "Loading harnesses..."
|
|
: isCreateMode
|
|
? "Choose a harness"
|
|
: "No preference (use app default)";
|
|
const runtimeDropdownOptions: PersonaDropdownOption[] = [
|
|
...(!isCreateMode
|
|
? [
|
|
{
|
|
label: blankRuntimeOptionLabel,
|
|
value: NO_RUNTIME_DROPDOWN_VALUE,
|
|
},
|
|
]
|
|
: []),
|
|
...sortPersonaRuntimes(runtimes).map((candidate) => ({
|
|
disabled:
|
|
isCreateMode &&
|
|
defaultRuntimeId !== undefined &&
|
|
candidate.availability !== "available",
|
|
label: `${formatRuntimeOptionLabel(candidate)}${
|
|
isCreateMode && candidate.id === defaultRuntimeId ? " (default)" : ""
|
|
}`,
|
|
value: candidate.id,
|
|
})),
|
|
];
|
|
const currentRuntime = runtime.trim();
|
|
if (
|
|
currentRuntime.length > 0 &&
|
|
!runtimeDropdownOptions.some((option) => option.value === currentRuntime)
|
|
) {
|
|
runtimeDropdownOptions.push({
|
|
label: `${currentRuntime} (current)`,
|
|
value: currentRuntime,
|
|
});
|
|
}
|
|
return { blankRuntimeOptionLabel, runtimeDropdownOptions };
|
|
}
|
|
|
|
function runtimeAvailabilitySortRank(
|
|
availability: AcpRuntimeCatalogEntry["availability"],
|
|
) {
|
|
switch (availability) {
|
|
case "available":
|
|
return 0;
|
|
case "cli_missing":
|
|
return 1;
|
|
case "not_installed":
|
|
return 2;
|
|
case "adapter_missing":
|
|
return 3;
|
|
case "adapter_outdated":
|
|
return 3;
|
|
}
|
|
}
|
|
|
|
function runtimePreferenceSortRank(runtimeId: string) {
|
|
switch (runtimeId) {
|
|
case "buzz-agent":
|
|
return 0;
|
|
case "goose":
|
|
return 1;
|
|
default:
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
export function sortPersonaRuntimes(
|
|
runtimes: readonly AcpRuntimeCatalogEntry[],
|
|
) {
|
|
return [...runtimes].sort((left, right) => {
|
|
const availabilityDelta =
|
|
runtimeAvailabilitySortRank(left.availability) -
|
|
runtimeAvailabilitySortRank(right.availability);
|
|
if (availabilityDelta !== 0) {
|
|
return availabilityDelta;
|
|
}
|
|
|
|
const preferenceDelta =
|
|
runtimePreferenceSortRank(left.id) - runtimePreferenceSortRank(right.id);
|
|
if (preferenceDelta !== 0) {
|
|
return preferenceDelta;
|
|
}
|
|
|
|
return left.label.localeCompare(right.label);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns true when `key` is satisfied at the global layer AND the agent-local
|
|
* `envVars` does NOT explicitly shadow it with an empty string.
|
|
*
|
|
* Matches backend semantics: agent env.extend() overwrites global, so an
|
|
* agent-local value of "" makes the effective value empty → key is missing.
|
|
* A key absent from `envVars` entirely leaves the global value intact.
|
|
*
|
|
* Used by both `computeLocalModeGate` (create dialog) and
|
|
* `useRequiredCredentialState` (edit dialog) so the two gates cannot drift.
|
|
*/
|
|
export function isGloballySatisfiedCredentialKey(
|
|
key: string,
|
|
globalEnvVars: Record<string, string> | undefined,
|
|
envVars: Record<string, string>,
|
|
): boolean {
|
|
const globalValue = globalEnvVars?.[key] ?? "";
|
|
if (globalValue.length === 0) return false;
|
|
// Agent-local "" explicitly shadows the global — effective value is empty.
|
|
const agentExplicitlyClearedKey =
|
|
key in envVars && (envVars[key] ?? "").length === 0;
|
|
return !agentExplicitlyClearedKey;
|
|
}
|
|
|
|
/**
|
|
* Filter a required-key list down to those satisfied by the baked build env.
|
|
*
|
|
* A key is baked-satisfied when the agent has no local value for it AND the
|
|
* baked env (compile-time, Block-internal builds) contains it. This mirrors the
|
|
* backend readiness gate Layer 1 (`resolve_effective_agent_env`) so the dialogs
|
|
* don't surface a spurious "Required" badge for keys that are already baked in.
|
|
*
|
|
* OSS builds have an empty baked env, so this always returns `[]` there —
|
|
* OSS behavior is unchanged.
|
|
*
|
|
* **UX asymmetry:** baked-satisfied keys are FULLY silenced — no amber Required
|
|
* row, no "Set in config" info row. This differs from file-satisfied keys, which
|
|
* render an info row ("Set in goose config"). Baked env is invisible
|
|
* infrastructure; surfacing it would be noise for users.
|
|
*
|
|
* **Precedence:** agent-local > baked > global > file for satisfaction. An
|
|
* explicit local empty string is still an agent-local override, so it must NOT
|
|
* fall through to the baked layer.
|
|
*/
|
|
export function getBakedSatisfiedEnvKeys(
|
|
requiredKeys: readonly string[],
|
|
envVars: Record<string, string>,
|
|
bakedEnvKeys: readonly string[] | undefined,
|
|
): string[] {
|
|
if (!bakedEnvKeys || bakedEnvKeys.length === 0) return [];
|
|
const bakedSet = new Set(bakedEnvKeys);
|
|
return requiredKeys.filter((key) => !(key in envVars) && bakedSet.has(key));
|
|
}
|
|
|
|
/**
|
|
* Pure local-mode readiness gate for Create (no existing agent, no config
|
|
* surface query). Returns the missing normalized fields (provider, model) and
|
|
* the missing credential env keys so the caller can derive `canSubmit`,
|
|
* field `isRequired`, and `EnvVarsEditor.requiredKeys` from the same source.
|
|
*
|
|
* Two classes of required field for provider-selection runtimes (buzz-agent,
|
|
* goose) — both required unconditionally per readiness.rs:
|
|
* 1. Normalized fields: provider + model (empty string = NotReady)
|
|
* 2. Credential env keys: provider-specific (e.g. ANTHROPIC_API_KEY)
|
|
*
|
|
* Provider mode is not subject to this gate because it has its own readiness
|
|
* checks. Pass `isProviderMode=true` to bypass.
|
|
*/
|
|
export function computeLocalModeGate({
|
|
bakedEnvKeys,
|
|
envVars,
|
|
globalEnvVars = {},
|
|
globalProvider = "",
|
|
globalModel = "",
|
|
isProviderMode,
|
|
model,
|
|
provider,
|
|
runtimeId,
|
|
runtimeFileConfig,
|
|
}: {
|
|
/** Optional baked build env key names (Block-internal builds only).
|
|
* When provided, requirements already covered by the baked env are silenced,
|
|
* mirroring `resolve_effective_agent_env` Layer 1 in the backend readiness
|
|
* gate. Absent (or empty) on OSS builds — existing call sites are unaffected. */
|
|
bakedEnvKeys?: readonly string[];
|
|
envVars: Record<string, string>;
|
|
/**
|
|
* Global agent config env vars. Required credential keys satisfied here
|
|
* are excluded from `missingEnvKeys` so global config silences the gate.
|
|
*/
|
|
globalEnvVars?: Record<string, string>;
|
|
/**
|
|
* Global fallback provider. When the agent's own provider is empty but a
|
|
* global provider is set, the provider normalized-field gate is satisfied.
|
|
*/
|
|
globalProvider?: string;
|
|
/**
|
|
* Global fallback model. When the agent's own model is empty but a global
|
|
* model is set, the model normalized-field gate is satisfied.
|
|
*/
|
|
globalModel?: string;
|
|
isProviderMode: boolean;
|
|
model: string;
|
|
provider: string;
|
|
runtimeId: string;
|
|
/** Optional file-layer config for the runtime (e.g. goose config.yaml).
|
|
* When provided, requirements already satisfied there are silenced. */
|
|
runtimeFileConfig?: RuntimeFileConfigSubset | null;
|
|
}): {
|
|
/** Normalized field names that are required but empty ("provider", "model"). */
|
|
missingNormalizedFields: string[];
|
|
/**
|
|
* Credential env key names that are required but not yet supplied in the
|
|
* agent-local or global env (gate state — drives the readiness badge).
|
|
* A key is removed from this list as soon as ANY env value provides it.
|
|
*/
|
|
missingEnvKeys: string[];
|
|
/**
|
|
* Full list of credential env keys that need a locked amber row in
|
|
* EnvVarsEditor — uses the effective provider so an agent inheriting a
|
|
* global provider shows the correct rows. Excludes keys already satisfied
|
|
* by global defaults or the runtime config file (those are shown
|
|
* differently or not at all). Includes locally-filled keys so the locked
|
|
* row remains stable while the user types a value.
|
|
*/
|
|
requiredEnvKeys: string[];
|
|
/** Env keys that are not set in Buzz but are satisfied in the runtime's
|
|
* config file (e.g. "Set in goose config"). */
|
|
fileSatisfiedEnvKeys: string[];
|
|
/** True when the create button may be enabled (from this gate's perspective). */
|
|
satisfied: boolean;
|
|
} {
|
|
if (isProviderMode) {
|
|
return {
|
|
missingNormalizedFields: [],
|
|
missingEnvKeys: [],
|
|
requiredEnvKeys: [],
|
|
fileSatisfiedEnvKeys: [],
|
|
satisfied: true,
|
|
};
|
|
}
|
|
|
|
const needsProviderSelection = runtimeSupportsLlmProviderSelection(runtimeId);
|
|
|
|
// File-layer values for goose-style runtimes. These silence requirements
|
|
// when the runtime config file provides the value — the file layer is the
|
|
// lowest precedence fallback: env → global → file.
|
|
const fileProvider = runtimeFileConfig?.provider?.trim() ?? "";
|
|
const fileModel = runtimeFileConfig?.model?.trim() ?? "";
|
|
const fileSatisfiedKeys = new Set(runtimeFileConfig?.satisfiedEnvKeys ?? []);
|
|
|
|
// Effective provider/model: agent value → global fallback → file fallback.
|
|
const effectiveProvider =
|
|
provider.trim() || (globalProvider ?? "").trim() || fileProvider;
|
|
const effectiveModel =
|
|
model.trim() || (globalModel ?? "").trim() || fileModel;
|
|
|
|
const missingNormalizedFields: string[] = [];
|
|
if (needsProviderSelection) {
|
|
if (effectiveProvider.length === 0)
|
|
missingNormalizedFields.push("provider");
|
|
if (effectiveModel.length === 0) missingNormalizedFields.push("model");
|
|
}
|
|
|
|
// Credential keys depend on the selected provider (empty provider → no keys
|
|
// required beyond the normalized field gate above).
|
|
// Use the effective provider (env → global → file) so credential
|
|
// requirements are computed correctly for all config sources.
|
|
const providerForKeys = needsProviderSelection ? effectiveProvider : "";
|
|
const requiredKeys = requiredCredentialEnvKeys(runtimeId, providerForKeys);
|
|
|
|
// Keys satisfied by the baked build env (Block-internal builds only).
|
|
const bakedSatisfiedSet = new Set(
|
|
getBakedSatisfiedEnvKeys(requiredKeys, envVars, bakedEnvKeys),
|
|
);
|
|
|
|
const missingEnvKeys: string[] = [];
|
|
const fileSatisfiedEnvKeys: string[] = [];
|
|
// requiredEnvKeys: the full locked-row list for EnvVarsEditor. Includes
|
|
// locally-filled keys so the amber row stays stable while the user types.
|
|
// Excludes keys satisfied by global defaults (no locked row needed — the
|
|
// key is already set) or by the runtime config file (shown differently).
|
|
const requiredEnvKeys: string[] = [];
|
|
for (const key of requiredKeys) {
|
|
const agentValue = envVars[key] ?? "";
|
|
if (isGloballySatisfiedCredentialKey(key, globalEnvVars, envVars)) {
|
|
// Globally satisfied and not shadowed by an explicit local empty override —
|
|
// not a missing key, and no locked row needed.
|
|
} else if (bakedSatisfiedSet.has(key)) {
|
|
// Not in global env but covered by the baked build env — silenced.
|
|
// Don't add to fileSatisfiedEnvKeys; baked keys produce no info row.
|
|
} else if (!(key in envVars) && fileSatisfiedKeys.has(key)) {
|
|
// No higher-priority local override and present in the runtime config file.
|
|
fileSatisfiedEnvKeys.push(key);
|
|
} else {
|
|
// Key needs a locked amber row in EnvVarsEditor (whether or not the
|
|
// agent-local value is already filled — keep the row stable).
|
|
requiredEnvKeys.push(key);
|
|
if (agentValue.length === 0) {
|
|
// Not filled anywhere — also surfaces as missing for gate state.
|
|
missingEnvKeys.push(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
missingNormalizedFields,
|
|
missingEnvKeys,
|
|
requiredEnvKeys,
|
|
fileSatisfiedEnvKeys,
|
|
satisfied:
|
|
missingNormalizedFields.length === 0 && missingEnvKeys.length === 0,
|
|
};
|
|
}
|