feat(agents): model-tuning parity in global Agent Defaults editor (#4578)

## Overview

The global Agent Defaults surface (Settings card, defaults modal,
onboarding) exposed structured controls for Effort but left Max Output
Tokens, Context Limit, and Max Rounds as raw env vars. Per-agent dialogs
had structured numeric fields but only for `isBuzzAgentRuntime` —
incorrectly excluding Goose. This PR unifies numeric-tuning capability
across all surfaces, fixes a pre-existing dual-editor defect, and adds
full test coverage.

## What changed

### Phase 1 — Catalog projection

- Add `max_rounds_env_var` to `KnownAcpRuntime` in `runtime_metadata.rs`
(`Some("BUZZ_AGENT_MAX_ROUNDS")` for buzz-agent, `None` elsewhere).
- Project all three numeric env-var fields (`max_tokens_env_var`,
`context_limit_env_var`, `max_rounds_env_var`) end-to-end:
`AcpRuntimeCatalogEntry` Rust struct, TS `types.ts`,
`RawAcpRuntimeCatalogEntry` + `fromRawAcpRuntimeCatalogEntry` in
`tauri.ts`, and the e2e mock bridge (`withMockRuntimeConfigMetadata`).

### Phase 2 — Field model

- `deriveAgentConfigFieldModel` now derives `maxOutputTokens` /
`contextLimit` / `maxRounds` descriptors from catalog-projected fields.
- `structuredEnvKeys(descriptors)` — exported helper that takes the
**rendered** descriptor set (not the whole model). Hidden keys follow
what is actually rendered per surface: global hides effort + all three
numeric keys for buzz-agent / two for Goose; per-agent buzz-agent hides
effort + three numeric keys; per-agent Goose hides only its two numeric
keys. `BUZZ_AGENT_THINKING_EFFORT` stays a visible generic env row
per-agent because no effort control renders there.

### Phase 3 — UI

- Extract `NumericTuningFields` from `buzzAgentModelTuningFields.tsx` as
a shared descriptor-driven component (`descriptors`, `envVars`,
`inheritedEnvVars`, `onEnvVarChange`). Kind-specific minima:
`NUMERIC_KIND_MIN` map (`maxOutputTokens`/`contextLimit`: 1,
`maxRounds`: 0) applied to `<input min>`.
- **Global surface** (`AgentConfigFields.tsx`): deduplicate the
previously duplicated Advanced env-editor block; render
`NumericTuningFields` below the env editor when descriptors exist;
`hiddenKeys` and `bakedGenericRows` exclusions use `structuredEnvKeys`
so structured keys are never double-rendered. Under 1000 lines.
- **Per-agent surfaces** (`EditAgentAdvancedFields`,
`PersonaAdvancedFields`): replace `isBuzzAgentRuntime` as the
numeric-field gate with `deriveNumericDescriptors(selectedRuntime)` from
`agentConfigCore`; hidden keys come from
`structuredEnvKeys(numericDescriptors)` — the same rendered descriptor
set, no local rebuilding (fixes pre-existing dual-editor defect).
Catalog status carried as `RuntimeCatalogStatus` (`loading | ready |
error`); both error and loading withhold structured controls and leave
saved values visible as generic rows, making error distinguishable from
"runtime not capable" (`ready` + no runtime).
- **Dialogs** (`AgentDefinitionDialog`, `AgentInstanceEditDialog`,
callers): `AgentDefinitionDialog` accepts `runtimeCatalogStatus?:
"loading" | "ready" | "error"` (replaces separate
`runtimesLoading`/`runtimesError` booleans); all call sites —
`AgentManagementDialogs`, `AgentsView`, `RequestedAgentCreateDialogs`,
`UserProfilePersonaDialogs` — compute and pass the status.

### Phase 4 — Tests

- `buildRecord` exported from `EnvVarsEditor.tsx` as a pure `(nextRows,
value, requiredKeys, hiddenKeys) => Record<string, string>` helper for
isolation testing.
- **17 new node tests** in `agentConfigCore.test.mjs`:
`deriveNumericDescriptors` (all three fields, partial, undefined
runtime, matches field-model subset); `structuredEnvKeys` per surface
including discriminating Goose per-agent effort-key invariant;
`NUMERIC_KIND_MIN` values.
- **4 new node tests** in `EnvVarsEditor.test.mjs`: hidden tuning key
preserved through generic row edits; runtime-switch then generic edit
(derives both descriptor sets, asserts new-runtime hidden key survives
`buildRecord` via `hiddenKeys` and old-runtime key survives via generic
rows); baked numeric key excluded via `filterBakedGenericRows` with
`numericTuningPlaceholder` assertion; clearing a structured override —
`numericTuningPlaceholder` verifies placeholder text.
- **5 new Playwright tests** in `agent-numeric-tuning.spec.ts` (added to
smoke project `testMatch`): global numeric fields visible for
buzz-agent; global: non-capable runtime hides numeric controls; Goose
per-agent shows `Inherit (16384)` after saving global value through the
UI; delayed catalog: saved values visible as generic rows while loading
then structured controls appear after settle; failed catalog: saved
values remain visible as generic rows (never the "unsupported" empty
state).

## Result

- buzz-agent global defaults: Max output tokens, Context limit, Max
rounds as structured inputs with `Inherit (N)` placeholders from baked
env.
- Goose global defaults: Max output tokens, Context limit as structured
inputs.
- A Goose global value surfaces as `Inherit (<value>)` in the per-agent
Goose edit dialog.
- No structured key is editable in two places on any surface; no
persisted key has zero editors.
- No `runtime.id === "buzz-agent"` comparison decides numeric-field
visibility anywhere — capability flows catalog →
`AcpRuntimeCatalogEntry` → field model → UI.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-08-03 18:09:24 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 027a74a61c
commit 985cdcc6ea
32 changed files with 1769 additions and 322 deletions
@@ -1,7 +1,12 @@
import assert from "node:assert/strict";
import test from "node:test";
import { deriveAgentConfigFieldModel } from "./agentConfigCore.ts";
import {
deriveAgentConfigFieldModel,
deriveNumericDescriptors,
structuredEnvKeys,
} from "./agentConfigCore.ts";
import { NUMERIC_KIND_MIN } from "../ui/buzzAgentModelTuningFields.tsx";
const config = {
env_vars: { BUZZ_AGENT_THINKING_EFFORT: "high" },
@@ -23,6 +28,9 @@ function runtime(id, metadata = {}) {
modelEnvVar: null,
providerEnvVar: null,
thinkingEnvVar: null,
maxTokensEnvVar: null,
contextLimitEnvVar: null,
maxRoundsEnvVar: null,
installHint: "",
installInstructionsUrl: "",
canAutoInstall: false,
@@ -152,3 +160,404 @@ test("catalog mismatch cleanup is named and restricted to onboarding", () => {
onCatalogMismatch: "explainOnly",
});
});
// ── Numeric descriptor derivation per runtime ─────────────────────────────
//
// The catalog-projected fields (maxTokensEnvVar, contextLimitEnvVar,
// maxRoundsEnvVar) determine which numeric descriptors appear in the field
// model. Capability facts flow catalog → descriptor → UI; no runtime-ID
// comparison decides numeric-field visibility.
test("buzz-agent derives three numeric descriptors from catalog fields", () => {
const model = deriveAgentConfigFieldModel({
config,
runtime: runtime("buzz-agent", {
modelEnvVar: "BUZZ_AGENT_MODEL",
providerEnvVar: "BUZZ_AGENT_PROVIDER",
thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT",
maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS",
contextLimitEnvVar: "BUZZ_AGENT_MAX_CONTEXT_TOKENS",
maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS",
}),
scope: "global",
});
const numericKinds = model.fields
.filter((f) =>
["maxOutputTokens", "contextLimit", "maxRounds"].includes(f.kind),
)
.map((f) => f.kind);
assert.deepEqual(numericKinds, [
"maxOutputTokens",
"contextLimit",
"maxRounds",
]);
const maxOutput = field(model, "maxOutputTokens");
assert.equal(maxOutput.render, "control");
assert.deepEqual(maxOutput.currentPersistence, {
kind: "envVar",
key: "BUZZ_AGENT_MAX_OUTPUT_TOKENS",
});
assert.deepEqual(maxOutput.targetApplication, {
kind: "envVar",
key: "BUZZ_AGENT_MAX_OUTPUT_TOKENS",
});
const ctx = field(model, "contextLimit");
assert.deepEqual(ctx.currentPersistence, {
kind: "envVar",
key: "BUZZ_AGENT_MAX_CONTEXT_TOKENS",
});
const rounds = field(model, "maxRounds");
assert.deepEqual(rounds.currentPersistence, {
kind: "envVar",
key: "BUZZ_AGENT_MAX_ROUNDS",
});
});
test("Goose derives two numeric descriptors and no maxRounds", () => {
const model = deriveAgentConfigFieldModel({
config,
runtime: runtime("goose", {
modelEnvVar: "GOOSE_MODEL",
providerEnvVar: "GOOSE_PROVIDER",
thinkingEnvVar: "GOOSE_THINKING_EFFORT",
maxTokensEnvVar: "GOOSE_MAX_TOKENS",
contextLimitEnvVar: "GOOSE_CONTEXT_LIMIT",
maxRoundsEnvVar: null, // Goose has no max-rounds env var
}),
scope: "global",
});
const numericKinds = model.fields
.filter((f) =>
["maxOutputTokens", "contextLimit", "maxRounds"].includes(f.kind),
)
.map((f) => f.kind);
assert.deepEqual(numericKinds, ["maxOutputTokens", "contextLimit"]);
assert.equal(
field(model, "maxRounds"),
undefined,
"maxRounds must be absent for Goose",
);
assert.deepEqual(field(model, "maxOutputTokens").currentPersistence, {
kind: "envVar",
key: "GOOSE_MAX_TOKENS",
});
assert.deepEqual(field(model, "contextLimit").currentPersistence, {
kind: "envVar",
key: "GOOSE_CONTEXT_LIMIT",
});
});
test("Claude derives no numeric descriptors", () => {
const model = deriveAgentConfigFieldModel({
config,
runtime: runtime("claude"),
scope: "global",
});
const hasNumeric = model.fields.some((f) =>
["maxOutputTokens", "contextLimit", "maxRounds"].includes(f.kind),
);
assert.equal(hasNumeric, false, "Claude must have no numeric descriptors");
});
test("Codex derives no numeric descriptors", () => {
const model = deriveAgentConfigFieldModel({
config,
runtime: runtime("codex"),
scope: "global",
});
const hasNumeric = model.fields.some((f) =>
["maxOutputTokens", "contextLimit", "maxRounds"].includes(f.kind),
);
assert.equal(hasNumeric, false, "Codex must have no numeric descriptors");
});
test("numeric descriptor value is read from env_vars when set", () => {
const cfgWithTuning = {
env_vars: {
BUZZ_AGENT_MAX_OUTPUT_TOKENS: "8192",
BUZZ_AGENT_MAX_CONTEXT_TOKENS: "100000",
BUZZ_AGENT_MAX_ROUNDS: "25",
},
model: "test-model",
preferred_runtime: null,
provider: "anthropic",
};
const model = deriveAgentConfigFieldModel({
config: cfgWithTuning,
runtime: runtime("buzz-agent", {
maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS",
contextLimitEnvVar: "BUZZ_AGENT_MAX_CONTEXT_TOKENS",
maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS",
}),
scope: "global",
});
assert.equal(field(model, "maxOutputTokens").value, "8192");
assert.equal(field(model, "contextLimit").value, "100000");
assert.equal(field(model, "maxRounds").value, "25");
});
test("numeric descriptor value is null when env var is absent", () => {
const cfgEmpty = {
env_vars: {},
model: "test-model",
preferred_runtime: null,
provider: null,
};
const model = deriveAgentConfigFieldModel({
config: cfgEmpty,
runtime: runtime("buzz-agent", {
maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS",
contextLimitEnvVar: "BUZZ_AGENT_MAX_CONTEXT_TOKENS",
maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS",
}),
scope: "global",
});
assert.equal(field(model, "maxOutputTokens").value, null);
assert.equal(field(model, "contextLimit").value, null);
assert.equal(field(model, "maxRounds").value, null);
});
// ── structuredEnvKeys: rendered-descriptor ownership ─────────────────────
//
// structuredEnvKeys accepts the descriptors a surface ACTUALLY renders and
// returns the env-var keys that surface owns. Keys only appear in the output
// when a first-class control for them renders — a persisted value must never
// have zero editors.
//
// Critical invariant: per-agent Goose passes only its two numeric descriptors
// (no effort descriptor, because no effort control renders there). The effort
// key (BUZZ_AGENT_THINKING_EFFORT) must NOT appear in the output — it must
// stay a visible generic env row where any saved value can be edited.
test("structuredEnvKeys_global_includes_effort_key_and_numeric_keys", () => {
// Global surface renders effort + all numeric descriptors.
const buzzAgentModel = deriveAgentConfigFieldModel({
config,
runtime: runtime("buzz-agent", {
modelEnvVar: "BUZZ_AGENT_MODEL",
providerEnvVar: "BUZZ_AGENT_PROVIDER",
thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT",
maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS",
contextLimitEnvVar: "BUZZ_AGENT_MAX_CONTEXT_TOKENS",
maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS",
}),
scope: "global",
});
// Global renders all renderable descriptors.
const renderedDescriptors = buzzAgentModel.fields.filter(
(f) => f.render === "control",
);
const keys = structuredEnvKeys(renderedDescriptors);
assert.ok(
keys.includes("BUZZ_AGENT_THINKING_EFFORT"),
"effort key must be hidden on global (effort control renders)",
);
assert.ok(
keys.includes("BUZZ_AGENT_MAX_OUTPUT_TOKENS"),
"maxOutputTokens key must be hidden on global",
);
assert.ok(
keys.includes("BUZZ_AGENT_MAX_CONTEXT_TOKENS"),
"contextLimit key must be hidden on global",
);
assert.ok(
keys.includes("BUZZ_AGENT_MAX_ROUNDS"),
"maxRounds key must be hidden on global",
);
});
test("structuredEnvKeys_per_agent_buzz_agent_includes_effort_and_numeric_keys", () => {
// Per-agent buzz-agent renders effort + all 3 numeric descriptors.
const buzzAgentModel = deriveAgentConfigFieldModel({
config,
runtime: runtime("buzz-agent", {
thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT",
maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS",
contextLimitEnvVar: "BUZZ_AGENT_MAX_CONTEXT_TOKENS",
maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS",
}),
scope: "definition",
});
const renderedDescriptors = buzzAgentModel.fields.filter(
(f) => f.render === "control",
);
const keys = structuredEnvKeys(renderedDescriptors);
assert.ok(keys.includes("BUZZ_AGENT_THINKING_EFFORT"), "effort key present");
assert.ok(keys.includes("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), "maxTokens present");
assert.ok(
keys.includes("BUZZ_AGENT_MAX_CONTEXT_TOKENS"),
"contextLimit present",
);
assert.ok(keys.includes("BUZZ_AGENT_MAX_ROUNDS"), "maxRounds present");
});
test("structuredEnvKeys_per_agent_goose_excludes_effort_key_discriminating_invariant", () => {
// Per-agent Goose: effort migration is out of scope, so no effort control
// renders on the per-agent surface for Goose. Only the 2 numeric descriptors
// are passed as the rendered set. The effort persistence key
// (BUZZ_AGENT_THINKING_EFFORT) must NOT appear in the output — any saved
// value must remain visible and editable as a generic env row.
const gooseModel = deriveAgentConfigFieldModel({
config,
runtime: runtime("goose", {
thinkingEnvVar: "GOOSE_THINKING_EFFORT",
maxTokensEnvVar: "GOOSE_MAX_TOKENS",
contextLimitEnvVar: "GOOSE_CONTEXT_LIMIT",
}),
scope: "definition",
});
// Simulate per-agent surface: only the numeric descriptors render (no effort
// control for Goose per-agent — effort migration is out of scope).
const numericDescriptorsOnly = gooseModel.fields.filter((f) =>
["maxOutputTokens", "contextLimit", "maxRounds"].includes(f.kind),
);
const keys = structuredEnvKeys(numericDescriptorsOnly);
assert.equal(
keys.includes("BUZZ_AGENT_THINKING_EFFORT"),
false,
"effort persistence key must NOT be hidden for Goose per-agent — no editor would replace it",
);
assert.ok(
keys.includes("GOOSE_MAX_TOKENS"),
"maxTokens key must be present (control renders)",
);
assert.ok(
keys.includes("GOOSE_CONTEXT_LIMIT"),
"contextLimit key must be present (control renders)",
);
});
test("structuredEnvKeys_deferred_effort_excluded_from_result", () => {
// A deferred effort descriptor (render !== "control") must not contribute
// its key to the hidden set — the value has no editor on this surface.
const claudeModel = deriveAgentConfigFieldModel({
config,
runtime: runtime("claude"),
scope: "global",
});
const allDescriptors = claudeModel.fields; // includes deferred effort
const keys = structuredEnvKeys(allDescriptors);
// Claude's deferred effort has currentPersistence.kind === "unavailable"
// and render === "deferredUntilNativeOptionsAvailable"; no key emitted.
assert.equal(
keys.length,
0,
"deferred effort and model descriptors must not contribute hidden keys",
);
});
// ── deriveNumericDescriptors: standalone helper ───────────────────────────
//
// The same logic that populates the numeric portion of deriveAgentConfigFieldModel
// is available as a standalone helper for per-agent surfaces that don't need
// the full field model.
test("deriveNumericDescriptors_undefined_runtime_returns_empty", () => {
const ds = deriveNumericDescriptors(undefined);
assert.deepEqual(ds, []);
});
test("deriveNumericDescriptors_runtime_with_all_three_fields", () => {
const ds = deriveNumericDescriptors(
runtime("buzz-agent", {
maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS",
contextLimitEnvVar: "BUZZ_AGENT_MAX_CONTEXT_TOKENS",
maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS",
}),
);
assert.deepEqual(
ds.map((d) => d.kind),
["maxOutputTokens", "contextLimit", "maxRounds"],
);
for (const d of ds) {
assert.equal(d.render, "control");
assert.equal(d.currentPersistence.kind, "envVar");
assert.equal(d.value, null, "standalone helper returns null values");
}
});
test("deriveNumericDescriptors_partial_fields_match_catalog_projection", () => {
// Goose: two numeric fields, no maxRounds.
const ds = deriveNumericDescriptors(
runtime("goose", {
maxTokensEnvVar: "GOOSE_MAX_TOKENS",
contextLimitEnvVar: "GOOSE_CONTEXT_LIMIT",
maxRoundsEnvVar: null,
}),
);
assert.deepEqual(
ds.map((d) => d.kind),
["maxOutputTokens", "contextLimit"],
);
});
test("deriveNumericDescriptors_matches_deriveAgentConfigFieldModel_numeric_subset", () => {
// The standalone helper must produce the same descriptor set (without values)
// that deriveAgentConfigFieldModel embeds, so surfaces that call the helper
// directly get a consistent policy with the full field model.
const runtimeEntry = runtime("buzz-agent", {
maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS",
contextLimitEnvVar: "BUZZ_AGENT_MAX_CONTEXT_TOKENS",
maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS",
});
const standalone = deriveNumericDescriptors(runtimeEntry);
const fromModel = deriveAgentConfigFieldModel({
config,
runtime: runtimeEntry,
scope: "global",
}).fields.filter((f) =>
["maxOutputTokens", "contextLimit", "maxRounds"].includes(f.kind),
);
// Kinds and keys must match; values differ (standalone returns null, model
// reads from config).
assert.deepEqual(
standalone.map((d) => d.kind),
fromModel.map((d) => d.kind),
"descriptor kinds must match",
);
for (let i = 0; i < standalone.length; i++) {
assert.deepEqual(
standalone[i].currentPersistence,
fromModel[i].currentPersistence,
`persistence must match for descriptor ${i}`,
);
}
});
// ── NUMERIC_KIND_MIN: kind-specific input minima ──────────────────────────
//
// max output tokens and context limit must have min=1 (buzz-agent rejects 0).
// max rounds allows 0 (meaning unlimited).
test("NUMERIC_KIND_MIN_maxOutputTokens_is_1", () => {
assert.equal(NUMERIC_KIND_MIN.maxOutputTokens, 1);
});
test("NUMERIC_KIND_MIN_contextLimit_is_1", () => {
assert.equal(NUMERIC_KIND_MIN.contextLimit, 1);
});
test("NUMERIC_KIND_MIN_maxRounds_is_0", () => {
assert.equal(NUMERIC_KIND_MIN.maxRounds, 0);
});
@@ -4,6 +4,18 @@ import type {
} from "@/shared/api/types";
import { BUZZ_AGENT_THINKING_EFFORT } from "../ui/buzzAgentConfig";
/**
* Lifecycle status of the ACP runtime catalog query on a per-agent surface.
*
* - `loading` — query in flight; structured controls are withheld and env-var
* keys are not hidden (saved values remain visible as generic rows).
* - `ready` — query resolved; descriptors derived from `selectedRuntime`.
* - `error` — query failed; same gate as loading: no structured controls,
* keys not hidden, saved values stay visible. Distinguishable from
* "runtime not capable" (which is `ready` + no selectedRuntime).
*/
export type RuntimeCatalogStatus = "loading" | "ready" | "error";
export type AgentConfigScope =
| "onboarding"
| "global"
@@ -69,6 +81,13 @@ export type AgentConfigFieldDescriptor =
| { kind: "acpConfigOption"; id: string; category: string };
render: "control" | "deferredUntilNativeOptionsAvailable";
value: string | null;
}
| {
kind: "maxOutputTokens" | "contextLimit" | "maxRounds";
currentPersistence: EnvVarPersistence;
targetApplication: { kind: "envVar"; key: string };
render: "control";
value: string | null;
};
export type AgentConfigOmission = {
@@ -76,6 +95,18 @@ export type AgentConfigOmission = {
reason: "ownedByModelId" | "unsupportedByHarness";
};
/**
* A numeric tuning descriptor: one of the three env-var-backed number fields
* (max output tokens, context limit, max rounds).
*
* Defined here so both the field model derivation and the rendering surfaces
* share a single type — avoids the type being redefined in UI layers.
*/
export type NumericDescriptor = Extract<
AgentConfigFieldDescriptor,
{ kind: "maxOutputTokens" | "contextLimit" | "maxRounds" }
>;
export type AgentConfigFieldModel = {
fields: AgentConfigFieldDescriptor[];
omissions: AgentConfigOmission[];
@@ -86,6 +117,51 @@ function valueFromEnv(config: GlobalAgentConfig, key: string) {
return config.env_vars[key]?.trim() || null;
}
/**
* Derives the numeric descriptor set for a runtime from catalog fields.
*
* The returned descriptors drive `NumericTuningFields` on any surface that
* renders numeric knobs. Surfaces pass the same descriptor set to both the
* renderer and `structuredEnvKeys()` — one policy, no local rebuilding.
*
* Returns `[]` when `runtime` is undefined (catalog not yet settled, or the
* runtime has no numeric env-var fields).
*/
export function deriveNumericDescriptors(
runtime: AcpRuntimeCatalogEntry | undefined,
): NumericDescriptor[] {
if (!runtime) return [];
const ds: NumericDescriptor[] = [];
if (runtime.maxTokensEnvVar) {
ds.push({
kind: "maxOutputTokens",
currentPersistence: { kind: "envVar", key: runtime.maxTokensEnvVar },
targetApplication: { kind: "envVar", key: runtime.maxTokensEnvVar },
render: "control",
value: null,
});
}
if (runtime.contextLimitEnvVar) {
ds.push({
kind: "contextLimit",
currentPersistence: { kind: "envVar", key: runtime.contextLimitEnvVar },
targetApplication: { kind: "envVar", key: runtime.contextLimitEnvVar },
render: "control",
value: null,
});
}
if (runtime.maxRoundsEnvVar) {
ds.push({
kind: "maxRounds",
currentPersistence: { kind: "envVar", key: runtime.maxRoundsEnvVar },
targetApplication: { kind: "envVar", key: runtime.maxRoundsEnvVar },
render: "control",
value: null,
});
}
return ds;
}
/**
* Derives the harness-scoped field model consumed by agent config renderers.
*
@@ -163,6 +239,16 @@ export function deriveAgentConfigFieldModel({
});
}
// Numeric fields — derived from the shared helper, then value-populated
// from config. Any surface needing only the descriptor structure (without
// saved values) calls deriveNumericDescriptors(runtime) directly.
for (const d of deriveNumericDescriptors(runtime)) {
fields.push({
...d,
value: valueFromEnv(config, d.currentPersistence.key),
});
}
return {
fields,
omissions,
@@ -191,3 +277,74 @@ export function getRenderableEffortField(
field.kind === "effort" && field.render === "control",
);
}
/**
* Returns the env-var keys owned by the rendered descriptors on a surface.
*
* Pass only the descriptors that **actually render controls** on the surface —
* the resulting key set should be used as `EnvVarsEditor.hiddenKeys` and to
* exclude keys from baked-row generic display.
*
* Invariant: a key appears in the output only when a first-class control for
* it renders on the surface — a persisted value must never have zero editors.
*
* Per-surface consequences (assuming standard descriptor sets):
* - Global: effort key + numeric keys rendered by the descriptors
* - Per-agent buzz-agent: effort key + 3 numeric keys
* - Per-agent Goose: 2 numeric keys only — Goose effort (BUZZ_AGENT_THINKING_EFFORT)
* stays a visible generic env row because no effort control renders per-agent
* for Goose (effort migration is out of scope)
*/
export function structuredEnvKeys(
renderedDescriptors: AgentConfigFieldDescriptor[],
): string[] {
const keys: string[] = [];
for (const d of renderedDescriptors) {
if (d.render !== "control") continue;
if (d.kind === "effort" && d.currentPersistence.kind === "envVar") {
keys.push(d.currentPersistence.key);
} else if (
d.kind === "maxOutputTokens" ||
d.kind === "contextLimit" ||
d.kind === "maxRounds"
) {
keys.push(d.currentPersistence.key);
}
}
return keys;
}
/**
* Filters a baked-env row array to exclude keys already covered by structured
* controls, preventing double-editing. The result is the set of baked rows
* that the generic env-vars editor should display.
*
* Call with the union of always-structured keys (provider/model/effort set)
* and numeric structured keys derived from `structuredEnvKeys()`.
*
* Pure — suitable for Node-layer unit tests without a component renderer.
*/
export function filterBakedGenericRows<T extends { key: string }>(
bakedEnv: readonly T[],
excludeKeys: ReadonlySet<string> | readonly string[],
): T[] {
const exclude =
excludeKeys instanceof Set ? excludeKeys : new Set(excludeKeys);
return bakedEnv.filter((e) => !exclude.has(e.key));
}
/**
* Returns the placeholder string for a numeric tuning input.
*
* When an inherited value is present, the field shows `"Inherit (<value>)"`.
* When absent (no global setting), the field shows `"Inherit (agent default)"`.
*
* Pure — used by NumericTuningFields and testable without a component renderer.
*/
export function numericTuningPlaceholder(
inheritedValue: string | null | undefined,
): string {
return inheritedValue
? `Inherit (${inheritedValue})`
: "Inherit (agent default)";
}
@@ -24,6 +24,8 @@ import {
deriveAgentConfigFieldModel,
getRenderableEffortField,
hasRenderableAgentConfigField,
structuredEnvKeys,
filterBakedGenericRows,
} from "@/features/agents/lib/agentConfigCore";
import {
getBakedProviderInheritLabel,
@@ -52,7 +54,9 @@ import {
} from "@/features/agents/ui/buzzAgentConfig";
import {
EffortSelectField,
NumericTuningFields,
useEffortAutoClear,
type NumericDescriptor,
} from "@/features/agents/ui/buzzAgentModelTuningFields";
import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup";
import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge";
@@ -66,7 +70,6 @@ export const EMPTY_GLOBAL_CONFIG: GlobalAgentConfig = {
preferred_runtime: null,
};
/** Baked env keys that route to structured controls, not the generic env editor. */
const BAKED_STRUCTURED_KEYS = new Set([
"BUZZ_AGENT_PROVIDER",
"BUZZ_AGENT_MODEL",
@@ -103,12 +106,7 @@ export const CANONICAL_CONFIG_BEHAVIORS = {
requireProviderForModelAndEffort,
} as const;
/**
* Disclosure preset → the eight visibility decisions it owns. Full and
* progressive defaults expose the same controls; the progressive preset
* changes only when those controls are revealed. Exported for the contract
* test.
*/
/** Disclosure preset → the eight visibility decisions it owns. Exported for the contract test. */
export function resolveDisclosure(disclosure: AgentConfigDisclosure) {
const full = disclosure !== "onboarding-essential";
return {
@@ -139,14 +137,7 @@ export function shouldRevealDependentConfigFields({
);
}
/**
* Determines whether the status line beneath the Model field should render.
*
* Discovery warnings bypass the `onboarding-essential` preset so that a
* first-run failure is never silently invisible. On the happy path
* (`status === null`) the status line stays hidden in onboarding, keeping
* the page clean.
*/
/** Whether the status line under the Model field renders. Discovery warnings bypass onboarding-essential so first-run failures are never invisible. */
export function shouldShowModelStatusMessage(
showDescriptions: boolean,
status: { message: string; tone: string } | null,
@@ -155,14 +146,8 @@ export function shouldShowModelStatusMessage(
}
/**
* Whether the Model control should render given discovery state.
*
* Optional-model harnesses (Claude Code / Codex, `acpNative`) omit the control
* while discovery is in flight and after a **confirmed successful empty**
* catalog (IPC resolved, no usable options) — there is nothing useful to pick.
* Discovery failures / unavailable runtimes keep the control so #2246 failure
* UI can render. Full disclosure still shows the control when Custom model is
* available. Required-model harnesses always render the control.
* Renders the Model control given discovery state. Optional-model harnesses omit it while
* discovery is loading or after confirmed successful empty; failures keep it for the #2246 UI.
*/
export function shouldRenderModelControl({
discoveredModelOptions,
@@ -269,6 +254,19 @@ export function AgentConfigFields({
effortField?.currentPersistence.kind === "envVar"
? effortField.currentPersistence.key
: null;
const numericDescriptors = fieldModel.fields.filter(
(d): d is NumericDescriptor =>
(d.kind === "maxOutputTokens" ||
d.kind === "contextLimit" ||
d.kind === "maxRounds") &&
d.render === "control",
);
const allStructuredKeys = structuredEnvKeys([
...(effortField ? [effortField] : []),
...numericDescriptors,
]);
const bakedEnvMap = Object.fromEntries(bakedEnv.map((e) => [e.key, e.value]));
const bakedProvider = React.useMemo(
() => bakedEnv.find((e) => e.key === "BUZZ_AGENT_PROVIDER")?.value ?? null,
[bakedEnv],
@@ -301,8 +299,12 @@ export function AgentConfigFields({
[bakedEnv],
);
const bakedGenericRows = React.useMemo<readonly InheritedEnvRow[]>(
() => bakedEnv.filter((e) => !BAKED_STRUCTURED_KEYS.has(e.key)),
[bakedEnv],
() =>
filterBakedGenericRows(bakedEnv, [
...BAKED_STRUCTURED_KEYS,
...allStructuredKeys,
]),
[bakedEnv, allStructuredKeys],
);
const providerValue = providerFieldVisible ? (config.provider ?? "") : "";
@@ -573,16 +575,15 @@ export function AgentConfigFields({
}
function handleEnvVarsChange(next: Record<string, string>) {
const effort = effortPersistenceKey
? config.env_vars[effortPersistenceKey]
: undefined;
const merged = { ...next };
if (effortPersistenceKey && effort !== undefined) {
merged[effortPersistenceKey] = effort;
}
onConfigChange({ ...config, env_vars: merged });
onConfigChange({ ...config, env_vars: next });
}
const handleNumericEnvVarChange = (key: string, value: string) => {
const next = { ...config.env_vars, [key]: value };
if (value === "") delete next[key];
onConfigChange({ ...config, env_vars: next });
};
// On internal Block builds, BUZZ_AGENT_PROVIDER is baked in and a boot
// migration rewrites v1→v2. Hide the legacy v1 option so it is not offered
// for new selections; OSS builds show it.
@@ -739,6 +740,33 @@ export function AgentConfigFields({
</div>
) : null;
const advancedEditorBlock = (
<>
<EnvVarsEditor
fileSatisfiedKeys={advancedFileSatisfiedEnvKeys}
hiddenKeys={[
...(apiKeyEnvVar ? [apiKeyEnvVar] : []),
...allStructuredKeys,
]}
inheritedRows={bakedGenericRows}
inheritedRowsLabel="build"
keyAnnotations={CARD_MINT_KEY_ANNOTATIONS}
label="Environment variables"
onChange={handleEnvVarsChange}
requiredKeys={advancedRequiredEnvKeys}
value={config.env_vars}
/>
{numericDescriptors.length > 0 ? (
<NumericTuningFields
descriptors={numericDescriptors}
envVars={config.env_vars}
inheritedEnvVars={bakedEnvMap}
onEnvVarChange={handleNumericEnvVarChange}
/>
) : null}
</>
);
const dependentContent = (
<>
{providerFieldVisible && apiKeyEnvVar ? (
@@ -903,40 +931,12 @@ export function AgentConfigFields({
: PROGRESSIVE_FIELDS_TRANSITION
}
>
<EnvVarsEditor
fileSatisfiedKeys={advancedFileSatisfiedEnvKeys}
hiddenKeys={apiKeyEnvVar ? [apiKeyEnvVar] : []}
inheritedRows={bakedGenericRows}
inheritedRowsLabel="build"
keyAnnotations={CARD_MINT_KEY_ANNOTATIONS}
label="Environment variables"
onChange={handleEnvVarsChange}
requiredKeys={advancedRequiredEnvKeys}
value={Object.fromEntries(
Object.entries(config.env_vars).filter(
([k]) => k !== BUZZ_AGENT_THINKING_EFFORT,
),
)}
/>
{advancedEditorBlock}
</motion.div>
) : null}
</AnimatePresence>
) : advancedOpen ? (
<EnvVarsEditor
fileSatisfiedKeys={advancedFileSatisfiedEnvKeys}
hiddenKeys={apiKeyEnvVar ? [apiKeyEnvVar] : []}
inheritedRows={bakedGenericRows}
inheritedRowsLabel="build"
keyAnnotations={CARD_MINT_KEY_ANNOTATIONS}
label="Environment variables"
onChange={handleEnvVarsChange}
requiredKeys={advancedRequiredEnvKeys}
value={Object.fromEntries(
Object.entries(config.env_vars).filter(
([k]) => k !== BUZZ_AGENT_THINKING_EFFORT,
),
)}
/>
advancedEditorBlock
) : null}
</div>
) : null}
@@ -101,7 +101,7 @@ type AgentDefinitionDialogProps = {
error: Error | null;
isPending: boolean;
runtimes: AcpRuntimeCatalogEntry[];
runtimesLoading?: boolean;
runtimeCatalogStatus?: "loading" | "ready" | "error";
onOpenChange: (open: boolean) => void;
onSubmit: (
input: CreatePersonaInput | UpdatePersonaInput,
@@ -128,13 +128,14 @@ export function AgentDefinitionDialog({
error,
isPending,
runtimes,
runtimesLoading = false,
runtimeCatalogStatus = "ready" as const,
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);
@@ -394,11 +395,7 @@ export function AgentDefinitionDialog({
(runtime.trim().length > 0 && runtimeCanChooseLlmProvider) ||
blankRuntimeModelProviderEditable;
const trimmedProvider = provider.trim();
// Required credential env keys for this runtime + provider combination.
// Used to show required markers on the LLM provider label and amber
// locked rows in the env vars editor.
// File-layer config for the selected runtime (e.g. goose config.yaml).
// Used to silence requirements already satisfied there.
// Required credential env keys and file-layer config; silences requirements satisfied in the file layer.
const { data: runtimeFileConfig } = useRuntimeFileConfigQuery(runtime, {
enabled: open,
});
@@ -1016,6 +1013,8 @@ export function AgentDefinitionDialog({
model={model}
modelTuningRuntimeId={runtime}
namePoolText={namePoolText}
catalogStatus={runtimeCatalogStatus}
selectedRuntime={selectedRuntime}
onBehaviorDraftChange={(nextBehaviorDraft) => {
setHasUserChanges(true);
setBehaviorDraft(nextBehaviorDraft);
@@ -34,7 +34,7 @@ type AgentDialogCreateProps = {
definitionError: Error | null;
isDefinitionPending: boolean;
runtimes: AcpRuntimeCatalogEntry[];
runtimesLoading: boolean;
runtimeCatalogStatus: "loading" | "ready" | "error";
onSubmitDefinition: (
input: CreatePersonaInput | UpdatePersonaInput,
intent: AgentCreateIntent,
@@ -68,7 +68,7 @@ type AgentDialogDefinitionEditProps = {
error: Error | null;
isPending: boolean;
runtimes: AcpRuntimeCatalogEntry[];
runtimesLoading?: boolean;
runtimeCatalogStatus?: "loading" | "ready" | "error";
onOpenChange: (open: boolean) => void;
onSubmit: (
input: CreatePersonaInput | UpdatePersonaInput,
@@ -125,7 +125,7 @@ function AgentCreateDialogRouter({
definitionError,
isDefinitionPending,
runtimes,
runtimesLoading,
runtimeCatalogStatus,
onSubmitDefinition,
}: AgentDialogCreateProps) {
const [runDraft, setRunDraft] = React.useState(emptyWhereToRunDraft);
@@ -166,7 +166,7 @@ function AgentCreateDialogRouter({
}}
open
runtimes={runtimes}
runtimesLoading={runtimesLoading}
runtimeCatalogStatus={runtimeCatalogStatus}
submitLabel={copy.submitLabel}
title={copy.title}
/>
@@ -266,16 +266,10 @@ export function AgentInstanceEditDialog({
return runtimeSupportsLlmProviderSelection(matched?.id ?? "");
}, [runtimes, originalAgentCommand]);
// The runtime id that will actually be active after submit. When inheriting,
// resolve from the LINKED PERSONA's runtime — that is what will run once the
// override is cleared. Deriving from agent.agentCommand here is wrong for a
// pinned agent that just toggled "Inherit runtime from template": the override
// (e.g. a Claude pin) is still present on the record, so it would resolve to
// the old pin instead of the persona's runtime, hiding required credentials.
// Fall back to the agent.agentCommand dual-match (command path, then id) only
// when there is no linked persona or its runtime is unset. This single
// prospective id feeds BOTH the block-save gate (requiredEnvKeys) and the
// submit path so they never disagree on which runtime is being saved.
// The runtime id active after submit. Inheriting resolves from the LINKED PERSONA's runtime
// (that is what runs once the override is cleared, not the current override).
// Falls back to dual-match (command path, then id) when no persona or its runtime is unset.
// This single prospective id feeds BOTH the block-save gate and submit so they always agree.
const prospectiveRuntimeId = React.useMemo(() => {
if (!inheritHarness) {
return selectedRuntime?.id ?? selectedRuntimeId;
@@ -307,6 +301,15 @@ export function AgentInstanceEditDialog({
const llmProviderFieldVisible =
runtimeSupportsLlmProviderSelection(prospectiveRuntimeId);
const prospectiveRuntime = runtimes.find(
(r) => r.id === prospectiveRuntimeId,
);
const runtimeCatalogStatus = runtimesQuery.isLoading
? ("loading" as const)
: runtimesQuery.isError
? ("error" as const)
: ("ready" as const);
// One-shot focus: when the dialog opens from a card deep-link, scroll and
// focus the relevant field. The effect re-runs when `llmProviderFieldVisible`
// changes so a provider-field focus request fires once the field materializes.
@@ -339,9 +342,8 @@ export function AgentInstanceEditDialog({
return () => cancelAnimationFrame(id);
}, [open, initialFocus, agent.pubkey, llmProviderFieldVisible]);
// Provider + env to PERSIST on submit — also fed to the credential gate so
// gate, saved record, and spawn snapshot all agree on one resolved value.
// See resolveInheritedRuntimeSubmission for the inherit/transition contract.
// Provider + env to PERSIST on submit — also fed to the credential gate so gate, saved record,
// and spawn snapshot all agree on one resolved value. See resolveInheritedRuntimeSubmission.
const inheritedSubmission = React.useMemo(
() =>
resolveInheritedRuntimeSubmission({
@@ -376,12 +378,8 @@ export function AgentInstanceEditDialog({
inheritedEnvVars: inheritedEnvVarsForAdvanced,
} = useAgentDialogDefaults({ inheritedEnvVars, open });
// Runtime/provider-required credential state, derived from the PROSPECTIVE
// post-submit runtime — see the hook for the inherit-transition rationale.
// Pass globalProvider so the hook uses it as a fallback when the per-agent
// provider is empty (global-provider-only configs must surface required keys).
// Pass globalEnvVars so keys satisfied by global config are excluded from
// requiredEnvKeys and do not block Save (display and gate agree).
// Runtime/provider-required credential state for the PROSPECTIVE post-submit runtime.
// globalProvider/globalEnvVars: fallback for empty per-agent provider; keys satisfied globally don't block Save.
const { requiredEnvKeys, fileSatisfiedEnvKeys, requiredEnvKeyMissing } =
useRequiredCredentialState({
open,
@@ -1199,6 +1197,8 @@ export function AgentInstanceEditDialog({
parallelism={parallelism}
provider={effectiveProvider}
requiredEnvKeys={advancedRequiredEnvKeys}
catalogStatus={runtimeCatalogStatus}
selectedRuntime={prospectiveRuntime}
systemPrompt={systemPrompt}
onAcpCommandChange={setAcpCommand}
onAgentArgsChange={setAgentArgs}
@@ -22,7 +22,7 @@ export function AgentManagementDialogs() {
}}
onSubmitDefinition={management.submitCreate}
runtimes={management.runtimes}
runtimesLoading={management.runtimesLoading}
runtimeCatalogStatus={management.runtimeCatalogStatus}
/>
) : null}
{management.createdAgent ? (
@@ -51,7 +51,7 @@ export function AgentManagementDialogs() {
onSubmit={management.submitUpdate}
open
runtimes={management.runtimes}
runtimesLoading={management.runtimesLoading}
runtimeCatalogStatus={management.runtimeCatalogStatus}
submitLabel="Save changes"
title="Edit agent"
/>
+14 -2
View File
@@ -319,7 +319,13 @@ export function AgentsView() {
}}
onSubmitDefinition={personas.handleSubmit}
runtimes={personas.acpRuntimesQuery.data ?? []}
runtimesLoading={personas.acpRuntimesQuery.isLoading}
runtimeCatalogStatus={
personas.acpRuntimesQuery.isLoading
? "loading"
: personas.acpRuntimesQuery.isError
? "error"
: "ready"
}
/>
) : null}
{agents.agentToAddToChannel ? (
@@ -368,7 +374,13 @@ export function AgentsView() {
isPending={personas.isPending}
mode="definition-edit"
runtimes={personas.acpRuntimesQuery.data ?? []}
runtimesLoading={personas.acpRuntimesQuery.isLoading}
runtimeCatalogStatus={
personas.acpRuntimesQuery.isLoading
? "loading"
: personas.acpRuntimesQuery.isError
? "error"
: "ready"
}
onOpenChange={(open) => {
if (!open) {
personas.setPersonaDialogState(null);
@@ -1,3 +1,4 @@
import * as React from "react";
import { cn } from "@/shared/lib/cn";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
@@ -9,9 +10,21 @@ import {
PERSONA_LABEL_OPTIONAL_CLASS,
} from "./agentConfigOptions";
import type { AgentPersona } from "@/shared/api/types";
import { BuzzAgentModelTuningFields } from "./buzzAgentModelTuningFields";
import { isBuzzAgentRuntime } from "./buzzAgentConfig";
import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
import {
BuzzAgentModelTuningFields,
NumericTuningFields,
} from "./buzzAgentModelTuningFields";
import {
isBuzzAgentRuntime,
BUZZ_AGENT_THINKING_EFFORT,
} from "./buzzAgentConfig";
import { EDIT_AGENT_PARALLELISM_HELP } from "../lib/agentParallelism";
import {
deriveNumericDescriptors,
structuredEnvKeys,
type RuntimeCatalogStatus,
} from "../lib/agentConfigCore";
export function EditAgentAdvancedFields({
acpCommand,
@@ -30,6 +43,8 @@ export function EditAgentAdvancedFields({
parallelism,
provider,
requiredEnvKeys,
catalogStatus = "ready",
selectedRuntime,
systemPrompt,
onAcpCommandChange,
onAgentArgsChange,
@@ -55,7 +70,7 @@ export function EditAgentAdvancedFields({
model?: string;
/**
* The actual/prospective runtime id used to decide whether to show the
* buzz-agent model-tuning fields. Uses `prospectiveRuntimeId` from
* buzz-agent effort-tuning field. Uses `prospectiveRuntimeId` from
* EditAgentDialog — the resolved runtime, not the "inherit"/"custom" sentinel.
*/
modelTuningRuntimeId: string;
@@ -63,6 +78,24 @@ export function EditAgentAdvancedFields({
/** Active LLM provider id — forwarded to BuzzAgentModelTuningFields for effort filtering. */
provider?: string;
requiredEnvKeys: readonly string[];
/**
* Lifecycle status of the runtime catalog query. Controls the numeric-tuning
* gate and hidden-key behaviour:
* - `loading` or `error`: no structured controls; keys not hidden — saved
* values stay visible as generic rows.
* - `ready`: descriptors derived from `selectedRuntime` (empty when the
* runtime has no numeric env-var fields).
*
* Defaults to `"ready"` so existing callers without the catalog query do not
* need to change.
*/
catalogStatus?: RuntimeCatalogStatus;
/**
* The catalog entry for the prospective runtime. Drives descriptor-based
* numeric tuning fields (max output tokens / context limit / max rounds).
* When undefined after the catalog has settled, no numeric controls render.
*/
selectedRuntime?: AcpRuntimeCatalogEntry;
systemPrompt: string;
onAcpCommandChange: (value: string) => void;
onAgentArgsChange: (value: string) => void;
@@ -72,6 +105,29 @@ export function EditAgentAdvancedFields({
onAutoRestartChange: (value: boolean) => void;
onSystemPromptChange: (value: string) => void;
}) {
// Numeric tuning descriptors — gate on catalog status so that loading/error
// never collapses to "no controls": keys stay visible as generic rows.
const numericDescriptors = React.useMemo(
() =>
catalogStatus === "ready"
? deriveNumericDescriptors(selectedRuntime)
: [],
[catalogStatus, selectedRuntime],
);
// Build the effective hidden-key list: caller's secrets + effort key (when
// rendered by BuzzAgentModelTuningFields) + numeric keys via structuredEnvKeys.
const effectiveHiddenKeys = React.useMemo(
() => [
...hiddenEnvKeys,
...(isBuzzAgentRuntime(modelTuningRuntimeId)
? [BUZZ_AGENT_THINKING_EFFORT]
: []),
...structuredEnvKeys(numericDescriptors),
],
[hiddenEnvKeys, modelTuningRuntimeId, numericDescriptors],
);
return (
<div className="space-y-5 pt-2">
{/* Inherit runtime from template */}
@@ -248,7 +304,7 @@ export function EditAgentAdvancedFields({
<EnvVarsEditor
disabled={disabled}
fileSatisfiedKeys={fileSatisfiedEnvKeys}
hiddenKeys={hiddenEnvKeys}
hiddenKeys={effectiveHiddenKeys}
focusKey={focusKey}
helperText="Per-agent env vars. Override the template's vars on collision."
inheritedFrom={inheritedEnvVars}
@@ -259,7 +315,26 @@ export function EditAgentAdvancedFields({
value={envVars}
/>
{/* Tier-1 buzz-agent model-tuning knobs — only shown for buzz-agent. */}
{/* Descriptor-driven numeric tuning knobs — shown when the catalog has settled
and the runtime exposes numeric env-var fields. */}
{numericDescriptors.length > 0 ? (
<NumericTuningFields
descriptors={numericDescriptors}
envVars={envVars}
inheritedEnvVars={inheritedEnvVars}
onEnvVarChange={(key, value) => {
const next = { ...envVars };
if (value === "") {
delete next[key];
} else {
next[key] = value;
}
onEnvVarsChange(next);
}}
/>
) : null}
{/* Effort-tuning knob — only shown for buzz-agent. */}
{isBuzzAgentRuntime(modelTuningRuntimeId) ? (
<BuzzAgentModelTuningFields
envVars={envVars}
@@ -24,8 +24,15 @@ import {
toRecord,
skipKeysEqual,
isRequiredKeyMissing,
buildRecord as buildRecordUtil,
} from "./EnvVarsEditor.tsx";
import { getBakedProviderInheritLabel } from "./bakedEnvHelpers.ts";
import {
deriveNumericDescriptors,
structuredEnvKeys,
filterBakedGenericRows,
numericTuningPlaceholder,
} from "../lib/agentConfigCore.ts";
// ── Invariant 1: toRows excludes skip keys ─────────────────────────────────
@@ -617,3 +624,304 @@ test("keyAnnotations_annotation_absent_for_non_matching_rows", () => {
"annotation must not appear when its key is not in the env map",
);
});
// ── buildRecord with hiddenKeys: structured-field preservation ────────────
//
// These tests exercise the exported buildRecord(nextRows, value, requiredKeys,
// hiddenKeys) using the real implementation. hiddenKeys are structured-field
// env vars (e.g. BUZZ_AGENT_MAX_ROUNDS) that are owned by first-class controls
// outside the editor — they must survive onChange cycles even though they
// never appear as generic rows.
//
// Four scenarios from rev 4:
// 1. Edit an unrelated generic row → hidden (tuning) key is unchanged.
// 2. Runtime switch then generic edit: after switching to a new runtime,
// the new runtime's hidden keys survive; old runtime keys appear as
// generic rows and survive via toRecord, not hidden-key preservation.
// 3. Baked numeric key excluded via real descriptor/helper path: uses the
// production deriveAgentConfigFieldModel + structuredEnvKeys helpers to
// derive the hidden set, then verifies toRows excludes the numeric key.
// 4. Clearing a structured override → placeholder returns: after the user
// clears a structured field (key absent from value), buildRecord must
// not reintroduce it, leaving the structured field free to show the
// Inherit placeholder.
test("buildRecord_hidden_tuning_key_unchanged_when_generic_row_edited", () => {
// Structured field set BUZZ_AGENT_MAX_ROUNDS to "50"; it lives in value
// as a hiddenKey. User then edits a generic env var via the row editor.
// The tuning key must survive the buildRecord emit cycle unchanged.
const value = { BUZZ_AGENT_MAX_ROUNDS: "50", MY_VAR: "old" };
const nextRows = [{ id: "r1", key: "MY_VAR", value: "new" }];
const record = buildRecordUtil(
nextRows,
value,
[],
["BUZZ_AGENT_MAX_ROUNDS"],
);
assert.equal(
record.BUZZ_AGENT_MAX_ROUNDS,
"50",
"hidden tuning key must survive when an unrelated generic row is edited",
);
assert.equal(record.MY_VAR, "new", "generic row edit applied");
});
test("buildRecord_runtime_switch_new_hiddenKeys_then_generic_edit", () => {
// Scenario 2: runtime switch then generic edit.
//
// Before switch: agent is buzz-agent with BUZZ_AGENT_MAX_ROUNDS = "50" stored
// in value (set via the numeric tuning control). After switching to Goose,
// the buzz-agent key is no longer hidden — it becomes a visible generic row.
// The test verifies:
// (a) After the switch, the old buzz-agent key appears as a generic row
// (toRows with the new Goose hidden set projects it).
// (b) After a generic-row edit, buildRecord preserves BOTH the old-runtime
// key (now a generic row) and the new-runtime hidden key.
// (c) An unset new-runtime hidden key is not introduced.
// Derive both descriptor sets from real runtime objects.
const buzzAgentRuntime = {
id: "buzz-agent",
label: "Buzz Agent",
avatarUrl: "",
availability: "available",
command: "buzz-agent",
binaryPath: "buzz-agent",
defaultArgs: [],
mcpCommand: null,
modelEnvVar: "BUZZ_AGENT_MODEL",
providerEnvVar: "BUZZ_AGENT_PROVIDER",
thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT",
maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS",
contextLimitEnvVar: "BUZZ_AGENT_CONTEXT_LIMIT",
maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS",
installHint: "",
installInstructionsUrl: "",
canAutoInstall: false,
underlyingCliPath: null,
nodeRequired: false,
authStatus: { status: "not_applicable" },
loginHint: null,
};
const gooseRuntime = {
id: "goose",
label: "Goose",
avatarUrl: "",
availability: "available",
command: "goose",
binaryPath: "goose",
defaultArgs: ["acp"],
mcpCommand: null,
modelEnvVar: null,
providerEnvVar: null,
thinkingEnvVar: null,
maxTokensEnvVar: "GOOSE_MAX_TOKENS",
contextLimitEnvVar: "GOOSE_CONTEXT_LIMIT",
maxRoundsEnvVar: null,
installHint: "",
installInstructionsUrl: "",
canAutoInstall: true,
underlyingCliPath: null,
nodeRequired: false,
authStatus: { status: "not_applicable" },
loginHint: null,
};
const buzzDescriptors = deriveNumericDescriptors(buzzAgentRuntime);
const gooseDescriptors = deriveNumericDescriptors(gooseRuntime);
const buzzHiddenKeys = structuredEnvKeys(buzzDescriptors);
const gooseHiddenKeys = structuredEnvKeys(gooseDescriptors);
// Sanity-check that BUZZ_AGENT_MAX_ROUNDS is hidden under buzz-agent but not
// under Goose — that contrast is what makes it become a generic row.
assert.ok(
buzzHiddenKeys.includes("BUZZ_AGENT_MAX_ROUNDS"),
"BUZZ_AGENT_MAX_ROUNDS must be hidden under buzz-agent descriptors",
);
assert.equal(
gooseHiddenKeys.includes("BUZZ_AGENT_MAX_ROUNDS"),
false,
"BUZZ_AGENT_MAX_ROUNDS must not be hidden under Goose descriptors",
);
// Pre-switch value: buzz-agent max-rounds was set, GOOSE_MAX_TOKENS was
// already set (e.g. user configured it before switching back), plus a
// generic user var. GOOSE_MAX_TOKENS is a hidden key under the Goose
// descriptor set, so it must survive buildRecord() via hiddenKeys.
const valueBeforeSwitch = {
BUZZ_AGENT_MAX_ROUNDS: "50",
GOOSE_MAX_TOKENS: "16384",
USER_VAR: "original",
};
// After the switch to Goose, toRows is reproj with the new (Goose) hidden
// set. BUZZ_AGENT_MAX_ROUNDS is no longer hidden → appears as a generic row.
// GOOSE_MAX_TOKENS IS hidden under Goose → must not appear in generic rows.
const rowsAfterSwitch = toRows(valueBeforeSwitch, new Set(gooseHiddenKeys));
assert.ok(
rowsAfterSwitch.some((r) => r.key === "BUZZ_AGENT_MAX_ROUNDS"),
"old-runtime key must become a generic row after the switch",
);
assert.equal(
rowsAfterSwitch.some((r) => r.key === "GOOSE_MAX_TOKENS"),
false,
"new-runtime hidden key must not appear as a generic row after the switch",
);
// User edits the generic USER_VAR row.
const editedRows = rowsAfterSwitch.map((r) =>
r.key === "USER_VAR" ? { ...r, value: "updated" } : r,
);
// buildRecord: old-runtime key survives via toRecord (it's now a generic
// row); new-runtime Goose hidden key survives via hiddenKeys (carried
// through from value). An unset Goose key must not be introduced.
const record = buildRecordUtil(
editedRows,
valueBeforeSwitch,
[],
gooseHiddenKeys,
);
assert.equal(
record.BUZZ_AGENT_MAX_ROUNDS,
"50",
"old-runtime key must survive as a generic row value after switch",
);
assert.equal(
record.GOOSE_MAX_TOKENS,
"16384",
"new-runtime hidden key must survive buildRecord via hiddenKeys",
);
assert.equal(record.USER_VAR, "updated", "generic row edit applied");
assert.equal(
"GOOSE_CONTEXT_LIMIT" in record,
false,
"unset new-runtime hidden key must not be introduced",
);
});
test("filterBakedGenericRows_numeric_baked_key_excluded_and_placeholder_shown", () => {
// Scenario 3: baked numeric key excluded via the real production helper.
//
// The global baked env contains BUZZ_AGENT_MAX_OUTPUT_TOKENS = "4096"
// (the baked value shipped with the agent). The production
// filterBakedGenericRows path must exclude this key from the generic
// baked-row display so it isn't editable twice, while the structured
// numeric input shows the inherited placeholder via numericTuningPlaceholder.
const buzzAgentRuntime = {
id: "buzz-agent",
label: "Buzz Agent",
avatarUrl: "",
availability: "available",
command: "buzz-agent",
binaryPath: "buzz-agent",
defaultArgs: [],
mcpCommand: null,
modelEnvVar: "BUZZ_AGENT_MODEL",
providerEnvVar: "BUZZ_AGENT_PROVIDER",
thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT",
maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS",
contextLimitEnvVar: null,
maxRoundsEnvVar: null,
installHint: "",
installInstructionsUrl: "",
canAutoInstall: false,
underlyingCliPath: null,
nodeRequired: false,
authStatus: { status: "not_applicable" },
loginHint: null,
};
const numericDescriptors = deriveNumericDescriptors(buzzAgentRuntime);
const numericStructuredKeys = structuredEnvKeys(numericDescriptors);
assert.ok(
numericStructuredKeys.includes("BUZZ_AGENT_MAX_OUTPUT_TOKENS"),
"numeric key must appear in structured keys via production helpers",
);
// Simulate the baked env: BUZZ_AGENT_MAX_OUTPUT_TOKENS is baked, plus a
// non-structured baked var.
const bakedEnv = [
{ key: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", value: "4096" },
{ key: "SOME_OTHER_BAKED_VAR", value: "hello" },
];
// filterBakedGenericRows must exclude the numeric key.
const genericRows = filterBakedGenericRows(bakedEnv, numericStructuredKeys);
assert.equal(
genericRows.some((r) => r.key === "BUZZ_AGENT_MAX_OUTPUT_TOKENS"),
false,
"baked numeric key must be excluded from generic baked rows",
);
assert.ok(
genericRows.some((r) => r.key === "SOME_OTHER_BAKED_VAR"),
"non-structured baked var must remain in generic rows",
);
// The structured numeric input shows the inherited placeholder for the
// baked value via numericTuningPlaceholder.
const bakedValue = "4096";
assert.equal(
numericTuningPlaceholder(bakedValue),
"Inherit (4096)",
"structured placeholder must reflect the baked value",
);
assert.equal(
numericTuningPlaceholder(undefined),
"Inherit (agent default)",
"structured placeholder without baked value shows agent-default text",
);
});
test("buildRecord_clearing_structured_field_allows_placeholder_to_return", () => {
// Scenario 4: clearing a structured override → placeholder returns.
//
// Step 1: value has BUZZ_AGENT_MAX_ROUNDS = "50" (user set it via the
// structured field). BUZZ_AGENT_MAX_ROUNDS is in hiddenKeys.
// Step 2: user clears the structured field → onEnvVarChange(key, "")
// removes the key from value (value no longer contains it).
// Step 3: after the clear, buildRecord must not reintroduce the key.
// Step 4: with the key absent from value, numericTuningPlaceholder over
// the (now-empty) inheritedEnvVars shows "Inherit (agent default)"
// — the numeric field's empty-state placeholder.
// After the clear, value no longer contains BUZZ_AGENT_MAX_ROUNDS.
const valueAfterClear = { MY_VAR: "foo" };
const nextRows = [{ id: "r1", key: "MY_VAR", value: "updated" }];
const record = buildRecordUtil(
nextRows,
valueAfterClear,
[],
["BUZZ_AGENT_MAX_ROUNDS"],
);
assert.equal(
"BUZZ_AGENT_MAX_ROUNDS" in record,
false,
"cleared structured key must not be reintroduced by buildRecord",
);
assert.equal(record.MY_VAR, "updated");
// With the key cleared, the inherited value is also absent (not set
// globally). numericTuningPlaceholder returns the agent-default text —
// the placeholder that renders in the structured input.
const inheritedAfterClear = undefined;
assert.equal(
numericTuningPlaceholder(inheritedAfterClear),
"Inherit (agent default)",
"numeric input must show Inherit (agent default) after clear when no global override",
);
// If a global override IS set, the placeholder shows that value instead.
const inheritedGlobal = "25";
assert.equal(
numericTuningPlaceholder(inheritedGlobal),
"Inherit (25)",
"numeric input must show Inherit (<global value>) when a global override exists",
);
});
@@ -44,7 +44,9 @@ export function toRows(
* Collapse an ordered row list back to a record, skipping rows with empty
* keys. Exported for unit tests.
*/
export function toRecord(rows: Row[]): EnvVarsValue {
export function toRecord(
rows: readonly { key: string; value: string }[],
): EnvVarsValue {
const out: EnvVarsValue = {};
for (const row of rows) {
// Empty key = user is mid-edit; skip it so we don't poison the record.
@@ -177,6 +179,27 @@ type EnvVarsEditorProps = {
type Row = { id: string; key: string; value: string };
/**
* Pure record builder: merges `toRecord(nextRows)` with the current values of
* `requiredKeys` and `hiddenKeys` from `value`. Required and hidden keys are
* excluded from the row state (`skipKeys`), so this merge is the only place
* their current values survive an `onChange` emit cycle.
*
* Exported for unit testing. `EnvVarsEditor` calls this internally.
*/
export function buildRecord(
nextRows: readonly { key: string; value: string }[],
value: EnvVarsValue,
requiredKeys: readonly string[],
hiddenKeys: readonly string[],
): EnvVarsValue {
const base: EnvVarsValue = {};
for (const key of [...requiredKeys, ...hiddenKeys]) {
if (key in value) base[key] = value[key];
}
return { ...base, ...toRecord(nextRows) };
}
/**
* A flat key/value editor for environment variables.
*
@@ -241,18 +264,6 @@ export function EnvVarsEditor({
}
}, [value, skipKeys]);
// Build the emitted record: normal rows + required-key values preserved
// from `value`. Required keys are never in `rows`, so `toRecord(rows)`
// would silently drop any required secret the user just typed unless we
// merge them back explicitly.
function buildRecord(nextRows: Row[]): EnvVarsValue {
const base: EnvVarsValue = {};
for (const key of [...requiredKeys, ...hiddenKeys]) {
if (key in value) base[key] = value[key];
}
return { ...base, ...toRecord(nextRows) };
}
// Ref map: key → required-value Input element. Populated via callback refs
// on each required-key row's value Input so focus can be dispatched directly
// without any DOM walking through presentation classes.
@@ -294,7 +305,7 @@ export function EnvVarsEditor({
function emit(next: Row[]) {
setRows(next);
const record = buildRecord(next);
const record = buildRecord(next, value, requiredKeys, hiddenKeys);
lastEmitted.current = record;
onChange(record);
}
@@ -1,20 +1,33 @@
import * as React from "react";
import { Input } from "@/shared/ui/input";
import { cn } from "@/shared/lib/cn";
import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor";
import { CreateAgentRespondToField } from "./RespondToField";
import type { PersonaBehaviorDraft } from "./personaBehaviorDraft";
import { isBuzzAgentRuntime } from "./buzzAgentConfig";
import {
isBuzzAgentRuntime,
BUZZ_AGENT_THINKING_EFFORT,
} from "./buzzAgentConfig";
import {
AGENT_PARALLELISM_HELP,
AGENT_PARALLELISM_PLACEHOLDER,
} from "../lib/agentParallelism";
import { BuzzAgentModelTuningFields } from "./buzzAgentModelTuningFields";
import {
BuzzAgentModelTuningFields,
NumericTuningFields,
} from "./buzzAgentModelTuningFields";
import {
CARD_MINT_KEY_ANNOTATIONS,
PERSONA_FIELD_CONTROL_CLASS,
PERSONA_FIELD_SHELL_CLASS,
PERSONA_LABEL_OPTIONAL_CLASS,
} from "./agentConfigOptions";
import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
import {
deriveNumericDescriptors,
structuredEnvKeys,
type RuntimeCatalogStatus,
} from "../lib/agentConfigCore";
export function PersonaAdvancedFields({
behaviorDraft,
@@ -31,6 +44,8 @@ export function PersonaAdvancedFields({
requiredEnvKeys = [],
fileSatisfiedEnvKeys = [],
hiddenEnvKeys = [],
catalogStatus = "ready" as RuntimeCatalogStatus,
selectedRuntime,
}: {
behaviorDraft: PersonaBehaviorDraft;
disabled: boolean;
@@ -40,7 +55,7 @@ export function PersonaAdvancedFields({
inheritedEnvVars?: EnvVarsValue;
/** Active LLM model — forwarded to BuzzAgentModelTuningFields for effort filtering. */
model?: string;
/** Runtime id for the buzz-agent tuning knobs visibility gate. */
/** Runtime id for the buzz-agent effort-tuning knob visibility gate. */
modelTuningRuntimeId?: string;
namePoolText: string;
onBehaviorDraftChange: (value: PersonaBehaviorDraft) => void;
@@ -51,7 +66,42 @@ export function PersonaAdvancedFields({
requiredEnvKeys?: readonly string[];
fileSatisfiedEnvKeys?: readonly string[];
hiddenEnvKeys?: readonly string[];
/**
* Lifecycle status of the runtime catalog query. Controls the numeric-tuning
* gate and hidden-key behaviour:
* - `loading` or `error`: no structured controls; keys not hidden — saved
* values stay visible as generic rows.
* - `ready`: descriptors derived from `selectedRuntime` (empty when the
* runtime has no numeric env-var fields).
*/
catalogStatus?: RuntimeCatalogStatus;
/**
* The catalog entry for the selected runtime. Drives descriptor-based
* numeric tuning fields. When undefined after the catalog has settled,
* no numeric controls render.
*/
selectedRuntime?: AcpRuntimeCatalogEntry;
}) {
// Numeric tuning descriptors — gate on catalog status so that loading/error
// never collapses to "no controls": keys stay visible as generic rows.
const numericDescriptors = React.useMemo(
() =>
catalogStatus === "ready"
? deriveNumericDescriptors(selectedRuntime)
: [],
[catalogStatus, selectedRuntime],
);
const effectiveHiddenKeys = React.useMemo(
() => [
...hiddenEnvKeys,
...(isBuzzAgentRuntime(modelTuningRuntimeId)
? [BUZZ_AGENT_THINKING_EFFORT]
: []),
...structuredEnvKeys(numericDescriptors),
],
[hiddenEnvKeys, modelTuningRuntimeId, numericDescriptors],
);
return (
<div className="space-y-5 pt-2">
<CreateAgentRespondToField
@@ -146,14 +196,33 @@ export function PersonaAdvancedFields({
<EnvVarsEditor
disabled={disabled}
fileSatisfiedKeys={fileSatisfiedEnvKeys}
hiddenKeys={hiddenEnvKeys}
hiddenKeys={effectiveHiddenKeys}
keyAnnotations={CARD_MINT_KEY_ANNOTATIONS}
onChange={onEnvVarsChange}
requiredKeys={requiredEnvKeys}
value={envVars}
/>
{/* Tier-1 buzz-agent model-tuning knobs — only shown for buzz-agent. */}
{/* Descriptor-driven numeric tuning knobs — shown when catalog has settled
and the runtime exposes numeric env-var fields. */}
{numericDescriptors.length > 0 ? (
<NumericTuningFields
descriptors={numericDescriptors}
envVars={envVars}
inheritedEnvVars={inheritedEnvVars}
onEnvVarChange={(key, value) => {
const next = { ...envVars };
if (value === "") {
delete next[key];
} else {
next[key] = value;
}
onEnvVarsChange(next);
}}
/>
) : null}
{/* Effort-tuning knob — only shown for buzz-agent. */}
{isBuzzAgentRuntime(modelTuningRuntimeId) ? (
<BuzzAgentModelTuningFields
envVars={envVars}
@@ -55,7 +55,13 @@ export function RequestedAgentCreateDialogs() {
personas.handleSubmit(input, intent, backendIntent, targetChannel)
}
runtimes={personas.acpRuntimesQuery.data ?? []}
runtimesLoading={personas.acpRuntimesQuery.isLoading}
runtimeCatalogStatus={
personas.acpRuntimesQuery.isLoading
? "loading"
: personas.acpRuntimesQuery.isError
? "error"
: "ready"
}
/>
) : null}
{personas.createdAgent ? (
@@ -9,14 +9,13 @@ import * as React from "react";
import { Input } from "@/shared/ui/input";
import { cn } from "@/shared/lib/cn";
import type { EnvVarsValue } from "./EnvVarsEditor";
import type { NumericDescriptor } from "../lib/agentConfigCore";
import { numericTuningPlaceholder } from "../lib/agentConfigCore";
import {
AgentDropdownSelect,
type AgentDropdownOption,
} from "./agentConfigControls";
import {
BUZZ_AGENT_MAX_CONTEXT_TOKENS,
BUZZ_AGENT_MAX_OUTPUT_TOKENS,
BUZZ_AGENT_MAX_ROUNDS,
BUZZ_AGENT_THINKING_EFFORT,
BUZZ_AGENT_THINKING_EFFORT_VALUES,
getProviderEffortConfig,
@@ -201,6 +200,98 @@ export function useEffortAutoClear({
}, [effortValid, currentEffort]);
}
export type { NumericDescriptor };
const NUMERIC_KIND_LABELS: Record<NumericDescriptor["kind"], string> = {
maxOutputTokens: "Max output tokens",
contextLimit: "Context limit",
maxRounds: "Max rounds",
};
const NUMERIC_KIND_DESCRIPTIONS: Record<NumericDescriptor["kind"], string> = {
maxOutputTokens:
"Maximum tokens the LLM may generate per response. Leave blank to inherit.",
contextLimit:
"Maximum context window tokens tracked before a handoff. Leave blank to inherit.",
maxRounds:
"Maximum LLM + tool-call rounds per turn. 0 = unlimited. Leave blank to inherit.",
};
const NUMERIC_KIND_TEST_IDS: Record<NumericDescriptor["kind"], string> = {
maxOutputTokens: "numeric-max-output-tokens-input",
contextLimit: "numeric-context-limit-input",
maxRounds: "numeric-max-rounds-input",
};
/**
* Input `min` attribute per numeric kind.
*
* - `maxOutputTokens` / `contextLimit`: minimum 1 — the buzz-agent runtime
* rejects 0 for these fields (crates/buzz-agent/src/config.rs:921-928).
* - `maxRounds`: 0 is valid (means unlimited).
*/
export const NUMERIC_KIND_MIN: Record<NumericDescriptor["kind"], number> = {
maxOutputTokens: 1,
contextLimit: 1,
maxRounds: 0,
};
/**
* Descriptor-driven numeric tuning inputs.
*
* Renders a grid of number inputs for every numeric descriptor in `descriptors`.
* Label and help text are keyed by descriptor kind — the same copy renders on
* both the global defaults surface and per-agent dialogs.
*/
export function NumericTuningFields({
descriptors,
envVars,
inheritedEnvVars,
onEnvVarChange,
}: {
/** Numeric descriptors to render. Empty array → renders nothing. */
descriptors: NumericDescriptor[];
envVars: EnvVarsValue;
inheritedEnvVars: EnvVarsValue;
onEnvVarChange: (key: string, value: string) => void;
}) {
if (descriptors.length === 0) return null;
return (
<div className="grid gap-4 md:grid-cols-2">
{descriptors.map((d) => {
const key = d.currentPersistence.key;
const label = NUMERIC_KIND_LABELS[d.kind];
const description = NUMERIC_KIND_DESCRIPTIONS[d.kind];
const testId = NUMERIC_KIND_TEST_IDS[d.kind];
const inheritedVal = inheritedEnvVars[key];
return (
<div className="space-y-1.5" key={key}>
<label className="text-sm font-medium" htmlFor={testId}>
{label}
</label>
<Input
aria-describedby={`help-${testId}`}
autoComplete="off"
data-testid={testId}
id={testId}
inputMode="numeric"
min={NUMERIC_KIND_MIN[d.kind]}
onChange={(event) => onEnvVarChange(key, event.target.value)}
placeholder={numericTuningPlaceholder(inheritedVal)}
step="1"
type="number"
value={envVars[key] ?? ""}
/>
<p className="text-xs text-muted-foreground" id={`help-${testId}`}>
{description}
</p>
</div>
);
})}
</div>
);
}
export function BuzzAgentModelTuningFields({
envVars,
inheritedEnvVars,
@@ -257,105 +348,6 @@ export function BuzzAgentModelTuningFields({
blank to inherit from the global or persona default.
</p>
</div>
{/* Max Rounds */}
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="ba-max-rounds">
Max rounds
</label>
<Input
aria-describedby="help-ba-max-rounds"
autoComplete="off"
data-testid="ba-max-rounds-input"
id="ba-max-rounds"
inputMode="numeric"
min="0"
onChange={(event) =>
onEnvVarChange(BUZZ_AGENT_MAX_ROUNDS, event.target.value)
}
placeholder={
inheritedEnvVars[BUZZ_AGENT_MAX_ROUNDS]
? `Inherit (${inheritedEnvVars[BUZZ_AGENT_MAX_ROUNDS]})`
: "Inherit (agent default)"
}
step="1"
type="number"
value={envVars[BUZZ_AGENT_MAX_ROUNDS] ?? ""}
/>
<p className="text-xs text-muted-foreground" id="help-ba-max-rounds">
Maximum LLM + tool-call rounds per turn. 0 = unlimited. Leave blank
to inherit.
</p>
</div>
{/* Max Output Tokens */}
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="ba-max-output-tokens">
Max output tokens
</label>
<Input
aria-describedby="help-ba-max-output-tokens"
autoComplete="off"
data-testid="ba-max-output-tokens-input"
id="ba-max-output-tokens"
inputMode="numeric"
min="1"
onChange={(event) =>
onEnvVarChange(BUZZ_AGENT_MAX_OUTPUT_TOKENS, event.target.value)
}
placeholder={
inheritedEnvVars[BUZZ_AGENT_MAX_OUTPUT_TOKENS]
? `Inherit (${inheritedEnvVars[BUZZ_AGENT_MAX_OUTPUT_TOKENS]})`
: "Inherit (agent default)"
}
step="1"
type="number"
value={envVars[BUZZ_AGENT_MAX_OUTPUT_TOKENS] ?? ""}
/>
<p
className="text-xs text-muted-foreground"
id="help-ba-max-output-tokens"
>
Maximum tokens the LLM may generate per response. Leave blank to
inherit.
</p>
</div>
{/* Context Limit */}
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="ba-max-context-tokens"
>
Context limit
</label>
<Input
aria-describedby="help-ba-max-context-tokens"
autoComplete="off"
data-testid="ba-max-context-tokens-input"
id="ba-max-context-tokens"
inputMode="numeric"
min="1"
onChange={(event) =>
onEnvVarChange(BUZZ_AGENT_MAX_CONTEXT_TOKENS, event.target.value)
}
placeholder={
inheritedEnvVars[BUZZ_AGENT_MAX_CONTEXT_TOKENS]
? `Inherit (${inheritedEnvVars[BUZZ_AGENT_MAX_CONTEXT_TOKENS]})`
: "Inherit (agent default)"
}
step="1"
type="number"
value={envVars[BUZZ_AGENT_MAX_CONTEXT_TOKENS] ?? ""}
/>
<p
className="text-xs text-muted-foreground"
id="help-ba-max-context-tokens"
>
Maximum context window tokens buzz-agent tracks before a handoff.
Leave blank to inherit.
</p>
</div>
</div>
</div>
);
@@ -301,7 +301,11 @@ export function useAgentManagement() {
...createdAgentAttachment,
isPending,
runtimes: runtimesQuery.data ?? [],
runtimesLoading: runtimesQuery.isLoading,
runtimeCatalogStatus: runtimesQuery.isLoading
? ("loading" as const)
: runtimesQuery.isError
? ("error" as const)
: ("ready" as const),
submitCreate,
submitUpdate,
dismiss,
@@ -957,6 +957,7 @@ export function UserProfilePanel({
personaToExportSnapshot={personaToExportSnapshot}
resolvedPersona={resolvedPersona}
runtimes={acpRuntimesQuery.data ?? []}
runtimesError={acpRuntimesQuery.isError}
runtimesLoading={acpRuntimesQuery.isLoading}
updateError={
updatePersonaMutation.error instanceof Error
@@ -29,6 +29,7 @@ export function UserProfilePersonaDialogs({
resolvedPersona,
runtimes,
runtimesLoading,
runtimesError = false,
updateError,
onCloseCardMint,
onCloseDelete,
@@ -50,6 +51,7 @@ export function UserProfilePersonaDialogs({
resolvedPersona: AgentPersona | undefined;
runtimes: AcpRuntimeCatalogEntry[];
runtimesLoading: boolean;
runtimesError?: boolean;
updateError: Error | null;
onCloseCardMint: () => void;
onCloseDelete: () => void;
@@ -59,6 +61,11 @@ export function UserProfilePersonaDialogs({
onExportSnapshot: (persona: AgentPersona) => void;
onSubmit: (input: CreatePersonaInput | UpdatePersonaInput) => Promise<void>;
}) {
const runtimeCatalogStatus = runtimesLoading
? "loading"
: runtimesError
? "error"
: ("ready" as const);
return (
<>
<AgentDialog
@@ -68,7 +75,7 @@ export function UserProfilePersonaDialogs({
isPending={isPending}
mode="definition-edit"
runtimes={runtimes}
runtimesLoading={runtimesLoading}
runtimeCatalogStatus={runtimeCatalogStatus}
onOpenChange={(open) => {
if (!open) {
onCloseDialog();