mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add Agent Config Core: harness capability model behind agent config surfaces (#2158)
This commit is contained in:
@@ -9,85 +9,9 @@ use crate::managed_agents::{
|
||||
AcpAvailabilityStatus, AcpRuntimeCatalogEntry, AuthStatus, CommandAvailabilityInfo,
|
||||
};
|
||||
|
||||
pub(crate) struct KnownAcpRuntime {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
pub commands: &'static [&'static str],
|
||||
pub aliases: &'static [&'static str],
|
||||
pub avatar_url: &'static str,
|
||||
/// Legacy MCP server binary field. Vestigial — all agents now use the bundled CLI.
|
||||
/// directly. Will be removed when runtime discovery is simplified.
|
||||
pub mcp_command: Option<&'static str>,
|
||||
/// Whether to enable MCP hook tools (`_Stop`, `_PostCompact`) for this agent.
|
||||
pub mcp_hooks: bool,
|
||||
/// CLI binary that indicates partial install (e.g. `"claude"` when `claude-agent-acp` is missing).
|
||||
pub underlying_cli: Option<&'static str>,
|
||||
/// Shell commands to install the runtime CLI itself (run sequentially).
|
||||
pub cli_install_commands: &'static [&'static str],
|
||||
/// Windows-specific CLI install commands (e.g. PowerShell installers).
|
||||
/// When non-empty on Windows, these are used instead of `cli_install_commands`.
|
||||
#[allow(dead_code)] // read only on Windows via cli_install_commands_for_os()
|
||||
pub cli_install_commands_windows: &'static [&'static str],
|
||||
/// Shell commands to install the ACP adapter (run sequentially, after CLI).
|
||||
pub adapter_install_commands: &'static [&'static str],
|
||||
/// Link to docs/repo for manual instructions.
|
||||
pub install_instructions_url: &'static str,
|
||||
/// Human-readable hint about installing the CLI binary.
|
||||
pub cli_install_hint: &'static str,
|
||||
/// Human-readable hint about installing the ACP adapter.
|
||||
pub adapter_install_hint: &'static str,
|
||||
/// Harness-specific skill discovery directory (e.g. `.goose/skills`).
|
||||
/// `Some(dir)` → Buzz creates a symlink at `<nest>/<dir>/buzz-cli`
|
||||
/// pointing to the canonical `.agents/skills/buzz-cli`. `None` → this
|
||||
/// runtime reads the canonical path directly or has no skill support.
|
||||
pub skill_dir: Option<&'static str>,
|
||||
/// Whether this runtime handles model switching via ACP protocol natively.
|
||||
/// Currently unused — env var injection runs unconditionally regardless of
|
||||
/// this value. Retained as scaffolding for when ACP model switching matures.
|
||||
#[allow(dead_code)]
|
||||
pub supports_acp_model_switching: bool,
|
||||
pub model_env_var: Option<&'static str>,
|
||||
pub provider_env_var: Option<&'static str>,
|
||||
pub provider_locked: bool,
|
||||
pub default_env: &'static [(&'static str, &'static str)],
|
||||
pub config_file_path: Option<&'static str>,
|
||||
#[allow(dead_code)] // reserved for format-based dispatch when readers are unified
|
||||
pub config_file_format: Option<&'static str>,
|
||||
pub supports_acp_native_config: bool, // tier 1a: config/read+write
|
||||
pub thinking_env_var: Option<&'static str>,
|
||||
/// Env var for normalizing `max_output_tokens`. `None` when the harness
|
||||
/// does not have a first-class env var for this field (config-file only).
|
||||
pub max_tokens_env_var: Option<&'static str>,
|
||||
/// Env var for normalizing `context_limit`. `None` when not applicable.
|
||||
pub context_limit_env_var: Option<&'static str>,
|
||||
/// Normalized field keys that must be set for this harness to function.
|
||||
/// Used by the config bridge to mark fields as required in the UI.
|
||||
/// Keys match the camelCase names used in `NormalizedConfig` (e.g. "model", "provider").
|
||||
pub required_normalized_fields: &'static [&'static str],
|
||||
/// Human-readable hint shown in Doctor when the runtime is available but not
|
||||
/// authenticated. `None` for runtimes that have no login step (goose, buzz-agent).
|
||||
pub login_hint: Option<&'static str>,
|
||||
/// CLI args for probing authentication status. `args[0]` is the binary name;
|
||||
/// the remainder are the subcommand. `None` for runtimes with no login step.
|
||||
pub auth_probe_args: Option<&'static [&'static str]>,
|
||||
}
|
||||
mod runtime_metadata;
|
||||
|
||||
impl KnownAcpRuntime {
|
||||
/// Return the CLI install commands for the current platform.
|
||||
///
|
||||
/// On Windows, returns `cli_install_commands_windows` when non-empty,
|
||||
/// falling back to the default `cli_install_commands`. On other platforms
|
||||
/// always returns `cli_install_commands`.
|
||||
pub fn cli_install_commands_for_os(&self) -> &[&str] {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if !self.cli_install_commands_windows.is_empty() {
|
||||
return self.cli_install_commands_windows;
|
||||
}
|
||||
}
|
||||
self.cli_install_commands
|
||||
}
|
||||
}
|
||||
pub(crate) use runtime_metadata::KnownAcpRuntime;
|
||||
|
||||
const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png";
|
||||
const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default";
|
||||
@@ -1318,6 +1242,9 @@ pub fn discover_acp_runtimes() -> Vec<AcpRuntimeCatalogEntry> {
|
||||
binary_path,
|
||||
default_args,
|
||||
mcp_command: runtime.mcp_command.map(str::to_string),
|
||||
model_env_var: runtime.model_env_var.map(str::to_string),
|
||||
provider_env_var: runtime.provider_env_var.map(str::to_string),
|
||||
thinking_env_var: runtime.thinking_env_var.map(str::to_string),
|
||||
install_hint,
|
||||
install_instructions_url: runtime.install_instructions_url.to_string(),
|
||||
can_auto_install,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/// Static capabilities and installation metadata for a known ACP runtime.
|
||||
pub(crate) struct KnownAcpRuntime {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
pub commands: &'static [&'static str],
|
||||
pub aliases: &'static [&'static str],
|
||||
pub avatar_url: &'static str,
|
||||
/// Legacy MCP server binary field. Vestigial — all agents now use the bundled CLI
|
||||
/// directly. Will be removed when runtime discovery is simplified.
|
||||
pub mcp_command: Option<&'static str>,
|
||||
/// Whether to enable MCP hook tools (`_Stop`, `_PostCompact`) for this agent.
|
||||
pub mcp_hooks: bool,
|
||||
/// CLI binary that indicates partial install (e.g. `"claude"` when `claude-agent-acp` is missing).
|
||||
pub underlying_cli: Option<&'static str>,
|
||||
/// Shell commands to install the runtime CLI itself (run sequentially).
|
||||
pub cli_install_commands: &'static [&'static str],
|
||||
/// Windows-specific CLI install commands (e.g. PowerShell installers).
|
||||
/// When non-empty on Windows, these are used instead of `cli_install_commands`.
|
||||
#[allow(dead_code)] // read only on Windows via cli_install_commands_for_os()
|
||||
pub cli_install_commands_windows: &'static [&'static str],
|
||||
/// Shell commands to install the ACP adapter (run sequentially, after CLI).
|
||||
pub adapter_install_commands: &'static [&'static str],
|
||||
/// Link to docs/repo for manual instructions.
|
||||
pub install_instructions_url: &'static str,
|
||||
/// Human-readable hint about installing the CLI binary.
|
||||
pub cli_install_hint: &'static str,
|
||||
/// Human-readable hint about installing the ACP adapter.
|
||||
pub adapter_install_hint: &'static str,
|
||||
/// Harness-specific skill discovery directory (e.g. `.goose/skills`).
|
||||
/// `Some(dir)` → Buzz creates a symlink at `<nest>/<dir>/buzz-cli`
|
||||
/// pointing to the canonical `.agents/skills/buzz-cli`. `None` → this
|
||||
/// runtime reads the canonical path directly or has no skill support.
|
||||
pub skill_dir: Option<&'static str>,
|
||||
/// Whether this runtime handles model switching via ACP protocol natively.
|
||||
/// Currently unused — env var injection runs unconditionally regardless of
|
||||
/// this value. Retained as scaffolding for when ACP model switching matures.
|
||||
#[allow(dead_code)]
|
||||
pub supports_acp_model_switching: bool,
|
||||
pub model_env_var: Option<&'static str>,
|
||||
pub provider_env_var: Option<&'static str>,
|
||||
pub provider_locked: bool,
|
||||
pub default_env: &'static [(&'static str, &'static str)],
|
||||
pub config_file_path: Option<&'static str>,
|
||||
#[allow(dead_code)] // reserved for format-based dispatch when readers are unified
|
||||
pub config_file_format: Option<&'static str>,
|
||||
pub supports_acp_native_config: bool, // tier 1a: config/read+write
|
||||
pub thinking_env_var: Option<&'static str>,
|
||||
/// Env var for normalizing `max_output_tokens`. `None` when the harness
|
||||
/// does not have a first-class env var for this field (config-file only).
|
||||
pub max_tokens_env_var: Option<&'static str>,
|
||||
/// Env var for normalizing `context_limit`. `None` when not applicable.
|
||||
pub context_limit_env_var: Option<&'static str>,
|
||||
/// Normalized field keys that must be set for this harness to function.
|
||||
/// Used by the config bridge to mark fields as required in the UI.
|
||||
/// Keys match the camelCase names used in `NormalizedConfig` (e.g. "model", "provider").
|
||||
pub required_normalized_fields: &'static [&'static str],
|
||||
/// Human-readable hint shown in Doctor when the runtime is available but not
|
||||
/// authenticated. `None` for runtimes that have no login step (goose, buzz-agent).
|
||||
pub login_hint: Option<&'static str>,
|
||||
/// CLI args for probing authentication status. `args[0]` is the binary name;
|
||||
/// the remainder are the subcommand. `None` for runtimes with no login step.
|
||||
pub auth_probe_args: Option<&'static [&'static str]>,
|
||||
}
|
||||
|
||||
impl KnownAcpRuntime {
|
||||
/// Return the CLI install commands for the current platform.
|
||||
///
|
||||
/// On Windows, returns `cli_install_commands_windows` when non-empty,
|
||||
/// falling back to the default `cli_install_commands`. On other platforms
|
||||
/// always returns `cli_install_commands`.
|
||||
pub fn cli_install_commands_for_os(&self) -> &[&str] {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if !self.cli_install_commands_windows.is_empty() {
|
||||
return self.cli_install_commands_windows;
|
||||
}
|
||||
}
|
||||
self.cli_install_commands
|
||||
}
|
||||
}
|
||||
@@ -573,6 +573,12 @@ pub struct AcpRuntimeCatalogEntry {
|
||||
pub binary_path: Option<String>,
|
||||
pub default_args: Vec<String>,
|
||||
pub mcp_command: Option<String>,
|
||||
/// Environment variable used to apply the initial model, when supported.
|
||||
pub model_env_var: Option<String>,
|
||||
/// Environment variable used to apply the selected LLM provider, when supported.
|
||||
pub provider_env_var: Option<String>,
|
||||
/// Environment variable used to apply thinking effort, when supported.
|
||||
pub thinking_env_var: Option<String>,
|
||||
pub install_hint: String,
|
||||
pub install_instructions_url: String,
|
||||
/// true when at least one automated install step is available
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Agent Configuration — Contributor Rules
|
||||
|
||||
Scope: `desktop/src/features/agents/` (config surfaces, shared config renderer,
|
||||
and the agent config core). Read this before changing how harness / provider /
|
||||
model / effort configuration is modeled, rendered, persisted, or applied.
|
||||
|
||||
Plan of record: `Buzz/Harness-Provider-Model.md` in Morgan's Obsidian vault
|
||||
(PR sequence, decisions log). PRs: #2140 (rename), #2148 (flag reduction),
|
||||
#2156 (honest model states), #2158 (Agent Config Core).
|
||||
|
||||
## The one rule
|
||||
|
||||
**Harness capability facts have exactly one source: the Rust runtime catalog.**
|
||||
`KnownAcpRuntime` (`desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs`)
|
||||
declares each harness's model/provider/effort env keys and capabilities. Spawn
|
||||
applies them; `AcpRuntimeCatalogEntry` exposes them over IPC; and
|
||||
`lib/agentConfigCore.ts` projects them into field descriptors. The frontend
|
||||
never maintains a rival copy of this table.
|
||||
|
||||
If you need a new capability fact (a new env key, a native option, a "supports
|
||||
X" flag): add it to `KnownAcpRuntime` first, expose it on
|
||||
`AcpRuntimeCatalogEntry`, then project it through the core. Do not shortcut
|
||||
with a TypeScript lookup table or an id comparison in a component.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **No hardcoded harness-ID checks in render code.** `runtime.id === "claude"`
|
||||
belongs in `deriveAgentConfigFieldModel` (once, with a named reason), never
|
||||
in a component. Components ask the field model what exists
|
||||
(`hasRenderableAgentConfigField`, `getRenderableEffortField`).
|
||||
2. **Effort reads/writes go through the descriptor.** Use the effort
|
||||
descriptor's `currentPersistence` key — never a raw
|
||||
`BUZZ_AGENT_THINKING_EFFORT` literal in UI code. `currentPersistence` is
|
||||
where the value lives *today*; `targetApplication` is how the harness
|
||||
*should* receive it. They intentionally differ until PR 2.7 migrates
|
||||
Goose/Claude — do not "fix" one to match the other without doing the
|
||||
migration work.
|
||||
3. **Field absence has a named reason, not a boolean.** Codex effort is
|
||||
`ownedByModelId`; Claude effort is `deferredUntilNativeOptionsAvailable`.
|
||||
New absences get new named reasons in `AgentConfigOmission` /
|
||||
`render` — never a `showX` prop.
|
||||
4. **The clearing policy is the named types.** `onContextChange:
|
||||
"resetDependentValues"` (user changed harness/provider → dependent values
|
||||
reset everywhere) vs `onCatalogMismatch: "explainOnly" | "onboardingCleanup"`
|
||||
(an async catalog miss never silently erases saved state outside
|
||||
onboarding's named cleanup). Do not add mutation booleans like
|
||||
`clearInvalidModel`; extend the policy types.
|
||||
5. **"Metadata unknown" ≠ "harness lacks the capability".** Passing
|
||||
`runtime: undefined` to the core means fields won't render. Surfaces must
|
||||
gate on the runtime catalog query settling (loading/error states) rather
|
||||
than letting fields silently vanish — see `AgentDefaultsEditor` /
|
||||
`DefaultConfigStep` for the pattern.
|
||||
6. **One canonical behavior, disclosure presets for visibility.** Behavior
|
||||
flags were deliberately killed in #2148 (`CANONICAL_CONFIG_BEHAVIORS`).
|
||||
Surface differences are expressed via the `disclosure` preset, not new
|
||||
boolean props.
|
||||
7. **Onboarding does not change.** `onboarding-agent-defaults.spec.ts` is the
|
||||
acceptance gate for anything touching the shared renderer.
|
||||
|
||||
## The tests that enforce this
|
||||
|
||||
- `lib/agentConfigCore.test.mjs` — field model per harness × scope, clearing
|
||||
policy. Update when the capability model changes.
|
||||
- `ui/agentConfigFieldsContract.test.mjs` — canonical behaviors + disclosure
|
||||
presets. If this fails, you probably reintroduced a per-surface flag.
|
||||
- `desktop/tests/e2e/onboarding-agent-defaults.spec.ts` — onboarding behavior
|
||||
pin (35 tests).
|
||||
- Rust: `runtime_metadata_env_vars` tests pin spawn-time key application.
|
||||
|
||||
## Keep this file true
|
||||
|
||||
**If you change how agent configuration is modeled, rendered, persisted,
|
||||
applied, or cleared — update this file in the same PR.** A rule that no longer
|
||||
matches the code is worse than no rule; a new pattern that isn't written down
|
||||
here will be broken by the next agent that never learns it existed. Reviewers:
|
||||
treat a config-behavior diff without a matching AGENTS.md diff (or an explicit
|
||||
"no rules changed" note) as incomplete.
|
||||
@@ -0,0 +1,154 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { deriveAgentConfigFieldModel } from "./agentConfigCore.ts";
|
||||
|
||||
const config = {
|
||||
env_vars: { BUZZ_AGENT_THINKING_EFFORT: "high" },
|
||||
model: "test-model",
|
||||
preferred_runtime: null,
|
||||
provider: "anthropic",
|
||||
};
|
||||
|
||||
function runtime(id, metadata = {}) {
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
avatarUrl: "",
|
||||
availability: "available",
|
||||
command: id,
|
||||
binaryPath: id,
|
||||
defaultArgs: [],
|
||||
mcpCommand: null,
|
||||
modelEnvVar: null,
|
||||
providerEnvVar: null,
|
||||
thinkingEnvVar: null,
|
||||
installHint: "",
|
||||
installInstructionsUrl: "",
|
||||
canAutoInstall: false,
|
||||
underlyingCliPath: null,
|
||||
nodeRequired: false,
|
||||
authStatus: { status: "not_applicable" },
|
||||
loginHint: null,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
function field(model, kind) {
|
||||
return model.fields.find((candidate) => candidate.kind === kind);
|
||||
}
|
||||
|
||||
test("Buzz Agent exposes provider, model, and Buzz-owned effort", () => {
|
||||
const model = deriveAgentConfigFieldModel({
|
||||
config,
|
||||
runtime: runtime("buzz-agent", {
|
||||
modelEnvVar: "BUZZ_AGENT_MODEL",
|
||||
providerEnvVar: "BUZZ_AGENT_PROVIDER",
|
||||
thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT",
|
||||
}),
|
||||
scope: "global",
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
model.fields.map((item) => item.kind),
|
||||
["provider", "model", "effort"],
|
||||
);
|
||||
assert.equal(field(model, "effort").optionSource, "buzzAgentCatalog");
|
||||
assert.deepEqual(field(model, "effort").targetApplication, {
|
||||
kind: "envVar",
|
||||
key: "BUZZ_AGENT_THINKING_EFFORT",
|
||||
});
|
||||
});
|
||||
|
||||
test("Goose exposes provider, model, and its real effort application key", () => {
|
||||
const model = deriveAgentConfigFieldModel({
|
||||
config,
|
||||
runtime: runtime("goose", {
|
||||
modelEnvVar: "GOOSE_MODEL",
|
||||
providerEnvVar: "GOOSE_PROVIDER",
|
||||
thinkingEnvVar: "GOOSE_THINKING_EFFORT",
|
||||
}),
|
||||
scope: "global",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
field(model, "effort").optionSource,
|
||||
"legacyProviderModelCatalog",
|
||||
);
|
||||
assert.deepEqual(field(model, "effort").currentPersistence, {
|
||||
kind: "envVar",
|
||||
key: "BUZZ_AGENT_THINKING_EFFORT",
|
||||
});
|
||||
assert.deepEqual(field(model, "effort").targetApplication, {
|
||||
kind: "envVar",
|
||||
key: "GOOSE_THINKING_EFFORT",
|
||||
});
|
||||
});
|
||||
|
||||
test("Claude models effort as a deferred native ACP option", () => {
|
||||
const model = deriveAgentConfigFieldModel({
|
||||
config,
|
||||
runtime: runtime("claude"),
|
||||
scope: "global",
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
model.fields.map((item) => item.kind),
|
||||
["model", "effort"],
|
||||
);
|
||||
assert.equal(
|
||||
field(model, "effort").render,
|
||||
"deferredUntilNativeOptionsAvailable",
|
||||
);
|
||||
assert.deepEqual(field(model, "effort").currentPersistence, {
|
||||
kind: "unavailable",
|
||||
});
|
||||
assert.deepEqual(field(model, "effort").targetApplication, {
|
||||
kind: "acpConfigOption",
|
||||
id: "effort",
|
||||
category: "thought_level",
|
||||
});
|
||||
});
|
||||
|
||||
test("Codex omits separate effort because model IDs own it", () => {
|
||||
const model = deriveAgentConfigFieldModel({
|
||||
config,
|
||||
runtime: runtime("codex"),
|
||||
scope: "global",
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
model.fields.map((item) => item.kind),
|
||||
["model"],
|
||||
);
|
||||
assert.deepEqual(model.omissions, [
|
||||
{ kind: "effort", reason: "ownedByModelId" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("catalog mismatch cleanup is named and restricted to onboarding", () => {
|
||||
const selectedRuntime = runtime("buzz-agent", {
|
||||
modelEnvVar: "BUZZ_AGENT_MODEL",
|
||||
providerEnvVar: "BUZZ_AGENT_PROVIDER",
|
||||
thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT",
|
||||
});
|
||||
const onboarding = deriveAgentConfigFieldModel({
|
||||
config,
|
||||
runtime: selectedRuntime,
|
||||
scope: "onboarding",
|
||||
});
|
||||
const evergreen = deriveAgentConfigFieldModel({
|
||||
config,
|
||||
runtime: selectedRuntime,
|
||||
scope: "instance",
|
||||
});
|
||||
|
||||
assert.deepEqual(onboarding.dependentValuePolicy, {
|
||||
onContextChange: "resetDependentValues",
|
||||
onCatalogMismatch: "onboardingCleanup",
|
||||
});
|
||||
assert.deepEqual(evergreen.dependentValuePolicy, {
|
||||
onContextChange: "resetDependentValues",
|
||||
onCatalogMismatch: "explainOnly",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import type {
|
||||
AcpRuntimeCatalogEntry,
|
||||
GlobalAgentConfig,
|
||||
} from "@/shared/api/types";
|
||||
import { BUZZ_AGENT_THINKING_EFFORT } from "../ui/buzzAgentConfig";
|
||||
|
||||
export type AgentConfigScope =
|
||||
| "onboarding"
|
||||
| "global"
|
||||
| "definition"
|
||||
| "instance";
|
||||
|
||||
export type DependentValuePolicy = {
|
||||
onContextChange: "resetDependentValues";
|
||||
onCatalogMismatch: "explainOnly" | "onboardingCleanup";
|
||||
};
|
||||
|
||||
type NormalizedFieldPersistence = {
|
||||
kind: "normalizedField";
|
||||
field: "provider" | "model";
|
||||
};
|
||||
|
||||
type EnvVarPersistence = {
|
||||
kind: "envVar";
|
||||
key: string;
|
||||
};
|
||||
|
||||
type AcpConfigOptionPersistence = {
|
||||
kind: "acpConfigOption";
|
||||
id: string;
|
||||
category: string;
|
||||
};
|
||||
|
||||
type UnavailablePersistence = {
|
||||
kind: "unavailable";
|
||||
};
|
||||
|
||||
export type AgentConfigFieldDescriptor =
|
||||
| {
|
||||
kind: "provider";
|
||||
optionSource: "providerCatalog";
|
||||
persistence: NormalizedFieldPersistence;
|
||||
targetApplication: { kind: "envVar"; key: string };
|
||||
render: "control";
|
||||
value: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "model";
|
||||
optionSource: "acpModels";
|
||||
persistence: NormalizedFieldPersistence;
|
||||
targetApplication:
|
||||
| { kind: "envVar"; key: string }
|
||||
| { kind: "acpNative" };
|
||||
render: "control";
|
||||
value: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "effort";
|
||||
optionSource:
|
||||
| "buzzAgentCatalog"
|
||||
| "legacyProviderModelCatalog"
|
||||
| "harnessNative";
|
||||
currentPersistence:
|
||||
| EnvVarPersistence
|
||||
| AcpConfigOptionPersistence
|
||||
| UnavailablePersistence;
|
||||
targetApplication:
|
||||
| { kind: "envVar"; key: string }
|
||||
| { kind: "acpConfigOption"; id: string; category: string };
|
||||
render: "control" | "deferredUntilNativeOptionsAvailable";
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
export type AgentConfigOmission = {
|
||||
kind: "effort";
|
||||
reason: "ownedByModelId" | "unsupportedByHarness";
|
||||
};
|
||||
|
||||
export type AgentConfigFieldModel = {
|
||||
fields: AgentConfigFieldDescriptor[];
|
||||
omissions: AgentConfigOmission[];
|
||||
dependentValuePolicy: DependentValuePolicy;
|
||||
};
|
||||
|
||||
function valueFromEnv(config: GlobalAgentConfig, key: string) {
|
||||
return config.env_vars[key]?.trim() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the harness-scoped field model consumed by agent config renderers.
|
||||
*
|
||||
* The runtime catalog is authoritative for environment-variable application.
|
||||
* Harness-native ACP options are named here until discovery exposes them to the
|
||||
* desktop; descriptors marked deferred must not be rendered as generic fields.
|
||||
*/
|
||||
export function deriveAgentConfigFieldModel({
|
||||
config,
|
||||
runtime,
|
||||
scope,
|
||||
}: {
|
||||
config: GlobalAgentConfig;
|
||||
runtime: AcpRuntimeCatalogEntry | undefined;
|
||||
scope: AgentConfigScope;
|
||||
}): AgentConfigFieldModel {
|
||||
const fields: AgentConfigFieldDescriptor[] = [];
|
||||
const omissions: AgentConfigOmission[] = [];
|
||||
|
||||
if (runtime?.providerEnvVar) {
|
||||
fields.push({
|
||||
kind: "provider",
|
||||
optionSource: "providerCatalog",
|
||||
persistence: { kind: "normalizedField", field: "provider" },
|
||||
targetApplication: { kind: "envVar", key: runtime.providerEnvVar },
|
||||
render: "control",
|
||||
value: config.provider,
|
||||
});
|
||||
}
|
||||
|
||||
fields.push({
|
||||
kind: "model",
|
||||
optionSource: "acpModels",
|
||||
persistence: { kind: "normalizedField", field: "model" },
|
||||
targetApplication: runtime?.modelEnvVar
|
||||
? { kind: "envVar", key: runtime.modelEnvVar }
|
||||
: { kind: "acpNative" },
|
||||
render: "control",
|
||||
value: config.model,
|
||||
});
|
||||
|
||||
if (runtime?.thinkingEnvVar) {
|
||||
fields.push({
|
||||
kind: "effort",
|
||||
optionSource:
|
||||
runtime.id === "buzz-agent"
|
||||
? "buzzAgentCatalog"
|
||||
: "legacyProviderModelCatalog",
|
||||
currentPersistence: {
|
||||
kind: "envVar",
|
||||
key: BUZZ_AGENT_THINKING_EFFORT,
|
||||
},
|
||||
targetApplication: { kind: "envVar", key: runtime.thinkingEnvVar },
|
||||
render: "control",
|
||||
value: valueFromEnv(config, BUZZ_AGENT_THINKING_EFFORT),
|
||||
});
|
||||
} else if (runtime?.id === "claude") {
|
||||
fields.push({
|
||||
kind: "effort",
|
||||
optionSource: "harnessNative",
|
||||
currentPersistence: { kind: "unavailable" },
|
||||
targetApplication: {
|
||||
kind: "acpConfigOption",
|
||||
id: "effort",
|
||||
category: "thought_level",
|
||||
},
|
||||
render: "deferredUntilNativeOptionsAvailable",
|
||||
value: null,
|
||||
});
|
||||
} else {
|
||||
omissions.push({
|
||||
kind: "effort",
|
||||
reason:
|
||||
runtime?.id === "codex" ? "ownedByModelId" : "unsupportedByHarness",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
fields,
|
||||
omissions,
|
||||
dependentValuePolicy: {
|
||||
onContextChange: "resetDependentValues",
|
||||
onCatalogMismatch:
|
||||
scope === "onboarding" ? "onboardingCleanup" : "explainOnly",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function hasRenderableAgentConfigField(
|
||||
model: AgentConfigFieldModel,
|
||||
kind: AgentConfigFieldDescriptor["kind"],
|
||||
) {
|
||||
return model.fields.some(
|
||||
(field) => field.kind === kind && field.render === "control",
|
||||
);
|
||||
}
|
||||
|
||||
export function getRenderableEffortField(
|
||||
model: AgentConfigFieldModel,
|
||||
): Extract<AgentConfigFieldDescriptor, { kind: "effort" }> | undefined {
|
||||
return model.fields.find(
|
||||
(field): field is Extract<AgentConfigFieldDescriptor, { kind: "effort" }> =>
|
||||
field.kind === "effort" && field.render === "control",
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,11 @@ import type {
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { EnvVarsEditor } from "@/features/agents/ui/EnvVarsEditor";
|
||||
import type { InheritedEnvRow } from "@/features/agents/ui/EnvVarsEditor";
|
||||
import {
|
||||
deriveAgentConfigFieldModel,
|
||||
getRenderableEffortField,
|
||||
hasRenderableAgentConfigField,
|
||||
} from "@/features/agents/lib/agentConfigCore";
|
||||
import {
|
||||
getBakedProviderInheritLabel,
|
||||
getGlobalModelFallback,
|
||||
@@ -132,12 +137,6 @@ export type AgentConfigFieldsProps = {
|
||||
* match onboarding.
|
||||
*/
|
||||
disclosure?: "full" | "onboarding-essential";
|
||||
/**
|
||||
* Harness-conditional, not part of the disclosure preset: provider choice
|
||||
* only exists for runtimes that support LLM provider selection. PR 3
|
||||
* derives this internally from selectedRuntime and deletes the prop.
|
||||
*/
|
||||
showProviderField?: boolean;
|
||||
unstyled?: boolean;
|
||||
useCustomSelect?: boolean;
|
||||
useChevronSelectIcon?: boolean;
|
||||
@@ -156,7 +155,6 @@ export function AgentConfigFields({
|
||||
placeholderClassName,
|
||||
selectClassName,
|
||||
disclosure = "full",
|
||||
showProviderField = true,
|
||||
unstyled = false,
|
||||
useCustomSelect = false,
|
||||
useChevronSelectIcon = false,
|
||||
@@ -172,12 +170,29 @@ export function AgentConfigFields({
|
||||
showUnavailableEffortOptions,
|
||||
} = resolveDisclosure(disclosure);
|
||||
|
||||
const fieldModel = React.useMemo(
|
||||
() =>
|
||||
deriveAgentConfigFieldModel({
|
||||
config,
|
||||
runtime: selectedRuntime,
|
||||
scope: disclosure === "onboarding-essential" ? "onboarding" : "global",
|
||||
}),
|
||||
[config, disclosure, selectedRuntime],
|
||||
);
|
||||
const effortField = getRenderableEffortField(fieldModel);
|
||||
const effortPersistenceKey =
|
||||
effortField?.currentPersistence.kind === "envVar"
|
||||
? effortField.currentPersistence.key
|
||||
: null;
|
||||
const bakedProvider = React.useMemo(
|
||||
() => bakedEnv.find((e) => e.key === "BUZZ_AGENT_PROVIDER")?.value ?? null,
|
||||
[bakedEnv],
|
||||
);
|
||||
const selectedRuntimeId = selectedRuntime?.id ?? "";
|
||||
const providerFieldVisible = showProviderField;
|
||||
const providerFieldVisible = hasRenderableAgentConfigField(
|
||||
fieldModel,
|
||||
"provider",
|
||||
);
|
||||
const effectiveProvider = providerFieldVisible
|
||||
? config.provider?.trim() || bakedProvider || ""
|
||||
: "";
|
||||
@@ -254,7 +269,8 @@ export function AgentConfigFields({
|
||||
// Evergreen surfaces (Settings, dialogs) edit saved data that may pair with
|
||||
// higher layers (see PR #2148 review thread), so they only act after the
|
||||
// user explicitly edits the provider in this session.
|
||||
const healOnMount = disclosure === "onboarding-essential";
|
||||
const healOnMount =
|
||||
fieldModel.dependentValuePolicy.onCatalogMismatch === "onboardingCleanup";
|
||||
const userEditedProviderRef = React.useRef(false);
|
||||
// Read inside effects via ref so biome's exhaustive-deps stays honest:
|
||||
// refs are stable, and healOnMount is captured at declaration.
|
||||
@@ -295,8 +311,9 @@ export function AgentConfigFields({
|
||||
selectedRuntimeId,
|
||||
]);
|
||||
|
||||
const currentEffortForAutoClear =
|
||||
config.env_vars[BUZZ_AGENT_THINKING_EFFORT] ?? "";
|
||||
const currentEffortForAutoClear = effortPersistenceKey
|
||||
? (config.env_vars[effortPersistenceKey] ?? "")
|
||||
: "";
|
||||
|
||||
// When the selected harness changes outside this component (Back → setup
|
||||
// page → choose a different harness → Next), the saved model can belong to
|
||||
@@ -315,7 +332,7 @@ export function AgentConfigFields({
|
||||
}
|
||||
|
||||
const nextEnvVars = { ...config.env_vars };
|
||||
delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT];
|
||||
if (effortPersistenceKey) delete nextEnvVars[effortPersistenceKey];
|
||||
onCustomModelEditingChange(false);
|
||||
onConfigChange({ ...config, env_vars: nextEnvVars, model: null });
|
||||
}, [
|
||||
@@ -325,6 +342,7 @@ export function AgentConfigFields({
|
||||
onConfigChange,
|
||||
onCustomModelEditingChange,
|
||||
healOnMount,
|
||||
effortPersistenceKey,
|
||||
]);
|
||||
|
||||
// Orphan-model clearing follows the mount-time healing policy above: the
|
||||
@@ -346,7 +364,7 @@ export function AgentConfigFields({
|
||||
}
|
||||
|
||||
const nextEnvVars = { ...config.env_vars };
|
||||
delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT];
|
||||
if (effortPersistenceKey) delete nextEnvVars[effortPersistenceKey];
|
||||
onCustomModelEditingChange(false);
|
||||
onConfigChange({ ...config, env_vars: nextEnvVars, model: null });
|
||||
}, [
|
||||
@@ -355,6 +373,7 @@ export function AgentConfigFields({
|
||||
dependentFieldsDisabled,
|
||||
onConfigChange,
|
||||
onCustomModelEditingChange,
|
||||
effortPersistenceKey,
|
||||
]);
|
||||
const { validValues: effortValidForAutoClear } = getProviderEffortConfig(
|
||||
config.provider ?? "",
|
||||
@@ -365,7 +384,7 @@ export function AgentConfigFields({
|
||||
effortValid: effortValidForAutoClear,
|
||||
onClear: () => {
|
||||
const nextEnvVars = { ...config.env_vars };
|
||||
delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT];
|
||||
if (effortPersistenceKey) delete nextEnvVars[effortPersistenceKey];
|
||||
onConfigChange({ ...config, env_vars: nextEnvVars });
|
||||
},
|
||||
});
|
||||
@@ -423,11 +442,13 @@ export function AgentConfigFields({
|
||||
}
|
||||
|
||||
function handleEnvVarsChange(next: Record<string, string>) {
|
||||
const effort = config.env_vars[BUZZ_AGENT_THINKING_EFFORT];
|
||||
const merged =
|
||||
effort !== undefined
|
||||
? { ...next, [BUZZ_AGENT_THINKING_EFFORT]: effort }
|
||||
: next;
|
||||
const effort = effortPersistenceKey
|
||||
? config.env_vars[effortPersistenceKey]
|
||||
: undefined;
|
||||
const merged = { ...next };
|
||||
if (effortPersistenceKey && effort !== undefined) {
|
||||
merged[effortPersistenceKey] = effort;
|
||||
}
|
||||
onConfigChange({ ...config, env_vars: merged });
|
||||
}
|
||||
|
||||
@@ -481,16 +502,10 @@ export function AgentConfigFields({
|
||||
: implicitEffortProvider;
|
||||
const { validValues: effortValid, defaultValue: effortDefault } =
|
||||
getProviderEffortConfig(effortProvider, config.model ?? "");
|
||||
const currentEffort = config.env_vars[BUZZ_AGENT_THINKING_EFFORT] ?? "";
|
||||
// Claude Code and Codex both have harness-native effort semantics that are
|
||||
// not represented by Buzz Agent's generic BUZZ_AGENT_THINKING_EFFORT env var:
|
||||
// Claude exposes ACP config option `effort`; Codex currently encodes effort
|
||||
// into model ids (e.g. `gpt-5.5[low]`). Hide the generic control until the
|
||||
// config core can render harness-native options honestly.
|
||||
const effortFieldVisible =
|
||||
showEffortField &&
|
||||
selectedRuntimeId !== "claude" &&
|
||||
selectedRuntimeId !== "codex";
|
||||
const currentEffort = effortPersistenceKey
|
||||
? (config.env_vars[effortPersistenceKey] ?? "")
|
||||
: "";
|
||||
const effortFieldVisible = showEffortField && effortField !== undefined;
|
||||
|
||||
const fieldClassName = unstyled ? "space-y-4" : "space-y-1.5 p-3";
|
||||
const blockClassName = unstyled ? "" : "p-3";
|
||||
@@ -684,9 +699,11 @@ export function AgentConfigFields({
|
||||
onChange={(value) => {
|
||||
const nextEnvVars = { ...config.env_vars };
|
||||
if (value === "") {
|
||||
delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT];
|
||||
if (effortPersistenceKey)
|
||||
delete nextEnvVars[effortPersistenceKey];
|
||||
} else {
|
||||
nextEnvVars[BUZZ_AGENT_THINKING_EFFORT] = value;
|
||||
if (effortPersistenceKey)
|
||||
nextEnvVars[effortPersistenceKey] = value;
|
||||
}
|
||||
onConfigChange({ ...config, env_vars: nextEnvVars });
|
||||
}}
|
||||
|
||||
@@ -111,6 +111,11 @@ export function AgentDefaultsEditor({
|
||||
() => (runtimesQuery.data ?? []).find((r) => r.id === "buzz-agent"),
|
||||
[runtimesQuery.data],
|
||||
);
|
||||
const configSurfaceLoading = isLoading || runtimesQuery.isLoading;
|
||||
const configSurfaceError =
|
||||
loadError ||
|
||||
runtimesQuery.isError ||
|
||||
(!configSurfaceLoading && buzzAgentRuntime === undefined);
|
||||
|
||||
function handleConfigChange(next: GlobalAgentConfig) {
|
||||
configRef.current = next;
|
||||
@@ -167,12 +172,12 @@ export function AgentDefaultsEditor({
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-4">
|
||||
{isLoading ? (
|
||||
{configSurfaceLoading ? (
|
||||
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
|
||||
<Loader className="size-4 animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
) : loadError ? (
|
||||
) : configSurfaceError ? (
|
||||
<div className="flex items-center gap-2 py-4 text-sm text-destructive">
|
||||
<AlertCircle className="size-4" />
|
||||
Couldn't load agent defaults. Restart the app to try again.
|
||||
@@ -192,7 +197,7 @@ export function AgentDefaultsEditor({
|
||||
)}
|
||||
|
||||
{/* Save bar */}
|
||||
{!isLoading && !loadError && (
|
||||
{!configSurfaceLoading && !configSurfaceError && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
{saveState === "saved" && (
|
||||
<span className="flex min-w-0 items-center gap-1 text-sm text-green-600 dark:text-green-400">
|
||||
|
||||
@@ -129,8 +129,12 @@ function AgentDefaultsSection({
|
||||
}, [config.preferred_runtime, selectedRuntimes]);
|
||||
const selectedRuntimeId =
|
||||
selectedRuntime?.id ?? config.preferred_runtime ?? "";
|
||||
const selectedRuntimeSupportsModelProvider =
|
||||
runtimeSupportsLlmProviderSelection(selectedRuntimeId);
|
||||
const configSurfaceLoading = isLoading || runtimesQuery.isLoading;
|
||||
const configSurfaceError =
|
||||
runtimesQuery.isError ||
|
||||
(!configSurfaceLoading &&
|
||||
selectedRuntimeIds.length > 0 &&
|
||||
!selectedRuntime);
|
||||
const harnessOptions = React.useMemo(
|
||||
() =>
|
||||
selectedRuntimes.map((runtime) => ({
|
||||
@@ -182,11 +186,15 @@ function AgentDefaultsSection({
|
||||
|
||||
return (
|
||||
<section className="w-full space-y-4 text-left text-sm">
|
||||
{isLoading ? (
|
||||
{configSurfaceLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">
|
||||
<Spinner className="h-4 w-4 border-2" />
|
||||
Loading…
|
||||
</div>
|
||||
) : configSurfaceError ? (
|
||||
<p className="py-4 text-center text-sm text-destructive">
|
||||
Couldn't load harness settings. Go back and try again.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-7">
|
||||
<div className="space-y-4">
|
||||
@@ -226,7 +234,6 @@ function AgentDefaultsSection({
|
||||
placeholderClassName="text-foreground/70"
|
||||
selectClassName="h-12 rounded-2xl border-foreground/15 bg-white px-4 py-2 text-sm shadow-none hover:bg-white/95"
|
||||
disclosure="onboarding-essential"
|
||||
showProviderField={selectedRuntimeSupportsModelProvider}
|
||||
unstyled
|
||||
useCustomSelect
|
||||
/>
|
||||
|
||||
@@ -174,6 +174,9 @@ export type RawAcpRuntimeCatalogEntry = {
|
||||
binary_path: string | null;
|
||||
default_args: string[];
|
||||
mcp_command: string | null;
|
||||
model_env_var?: string | null;
|
||||
provider_env_var?: string | null;
|
||||
thinking_env_var?: string | null;
|
||||
install_hint: string;
|
||||
install_instructions_url: string;
|
||||
can_auto_install: boolean;
|
||||
@@ -710,6 +713,9 @@ function fromRawAcpRuntimeCatalogEntry(
|
||||
binaryPath: entry.binary_path,
|
||||
defaultArgs: entry.default_args,
|
||||
mcpCommand: entry.mcp_command,
|
||||
modelEnvVar: entry.model_env_var ?? null,
|
||||
providerEnvVar: entry.provider_env_var ?? null,
|
||||
thinkingEnvVar: entry.thinking_env_var ?? null,
|
||||
installHint: entry.install_hint,
|
||||
installInstructionsUrl: entry.install_instructions_url,
|
||||
canAutoInstall: entry.can_auto_install,
|
||||
|
||||
@@ -517,6 +517,12 @@ export type AcpRuntimeCatalogEntry = {
|
||||
binaryPath: string | null;
|
||||
defaultArgs: string[];
|
||||
mcpCommand: string | null;
|
||||
/** Environment variable used to apply the initial model, when supported. */
|
||||
modelEnvVar: string | null;
|
||||
/** Environment variable used to apply the selected LLM provider, when supported. */
|
||||
providerEnvVar: string | null;
|
||||
/** Environment variable used to apply thinking effort, when supported. */
|
||||
thinkingEnvVar: string | null;
|
||||
installHint: string;
|
||||
installInstructionsUrl: string;
|
||||
canAutoInstall: boolean;
|
||||
|
||||
@@ -6689,6 +6689,38 @@ async function handleListRelayAgents(
|
||||
return mockRelayAgents.map(cloneRelayAgent);
|
||||
}
|
||||
|
||||
function withMockRuntimeConfigMetadata(
|
||||
runtime: RawAcpRuntimeCatalogEntry,
|
||||
): RawAcpRuntimeCatalogEntry {
|
||||
return {
|
||||
...runtime,
|
||||
model_env_var:
|
||||
"model_env_var" in runtime
|
||||
? runtime.model_env_var
|
||||
: runtime.id === "buzz-agent"
|
||||
? "BUZZ_AGENT_MODEL"
|
||||
: runtime.id === "goose"
|
||||
? "GOOSE_MODEL"
|
||||
: null,
|
||||
provider_env_var:
|
||||
"provider_env_var" in runtime
|
||||
? runtime.provider_env_var
|
||||
: runtime.id === "buzz-agent"
|
||||
? "BUZZ_AGENT_PROVIDER"
|
||||
: runtime.id === "goose"
|
||||
? "GOOSE_PROVIDER"
|
||||
: null,
|
||||
thinking_env_var:
|
||||
"thinking_env_var" in runtime
|
||||
? runtime.thinking_env_var
|
||||
: runtime.id === "buzz-agent"
|
||||
? "BUZZ_AGENT_THINKING_EFFORT"
|
||||
: runtime.id === "goose"
|
||||
? "GOOSE_THINKING_EFFORT"
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleDiscoverAcpRuntimes(
|
||||
config: E2eConfig | undefined,
|
||||
): Promise<RawAcpRuntimeCatalogEntry[]> {
|
||||
@@ -6701,9 +6733,9 @@ async function handleDiscoverAcpRuntimes(
|
||||
|
||||
const configured = config?.mock?.acpRuntimesCatalog;
|
||||
if (configured) {
|
||||
return configured;
|
||||
return configured.map(withMockRuntimeConfigMetadata);
|
||||
}
|
||||
return [
|
||||
const defaultCatalog: RawAcpRuntimeCatalogEntry[] = [
|
||||
{
|
||||
id: "goose",
|
||||
label: "Goose",
|
||||
@@ -6775,6 +6807,7 @@ async function handleDiscoverAcpRuntimes(
|
||||
login_hint: undefined,
|
||||
},
|
||||
];
|
||||
return defaultCatalog.map(withMockRuntimeConfigMetadata);
|
||||
}
|
||||
|
||||
async function handleDiscoverAcpAuthMethods(
|
||||
|
||||
Reference in New Issue
Block a user