diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx index 50d2922813..45e6ae3046 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialog.tsx @@ -820,14 +820,18 @@ export function CreateAgentDialog({ acpCommand={acpCommand} agentArgs={agentArgs} agentCommand={agentCommand} + envVars={envVars} + inheritedEnvVars={globalConfig.env_vars} mcpCommand={mcpCommand} mcpToolsets={mcpToolsets} - onParallelismChange={setParallelism} + modelTuningRuntimeId={selectedRuntimeId} onAcpCommandChange={setAcpCommand} onAgentArgsChange={setAgentArgs} onAgentCommandChange={setAgentCommand} + onEnvVarsChange={setEnvVars} onMcpCommandChange={setMcpCommand} onMcpToolsetsChange={setMcpToolsets} + onParallelismChange={setParallelism} onRelayUrlChange={setRelayUrl} onSystemPromptChange={setSystemPrompt} onTurnTimeoutChange={setTurnTimeoutSeconds} diff --git a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx index f8be21c2ef..3bbf538dd3 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx @@ -3,6 +3,15 @@ import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; import { describeResolvedCommand } from "./agentUi"; +import { + BUZZ_AGENT_MAX_CONTEXT_TOKENS, + BUZZ_AGENT_MAX_OUTPUT_TOKENS, + BUZZ_AGENT_MAX_ROUNDS, + BUZZ_AGENT_THINKING_EFFORT, + BUZZ_AGENT_THINKING_EFFORT_VALUES, + isBuzzAgentRuntime, +} from "./buzzAgentConfig"; +import type { EnvVarsValue } from "./EnvVarsEditor"; export function CreateAgentBasicsFields({ name, @@ -105,8 +114,11 @@ export function CreateAgentRuntimeFields({ acpCommand, agentArgs, agentCommand, + envVars, + inheritedEnvVars, mcpCommand, mcpToolsets, + modelTuningRuntimeId, parallelism, relayUrl, selectedRuntimeId, @@ -115,6 +127,7 @@ export function CreateAgentRuntimeFields({ onAcpCommandChange, onAgentArgsChange, onAgentCommandChange, + onEnvVarsChange, onMcpCommandChange, onMcpToolsetsChange, onParallelismChange, @@ -125,16 +138,34 @@ export function CreateAgentRuntimeFields({ acpCommand: string; agentArgs: string; agentCommand: string; + /** Current agent env vars map — used to read/write tier-1 knob overrides. */ + envVars: EnvVarsValue; + /** Inherited baseline for tier-1 knobs (global → persona chain). Empty = no baseline. */ + inheritedEnvVars: EnvVarsValue; mcpCommand: string; mcpToolsets: string; + /** + * The actual/prospective runtime id used to decide whether to show the + * buzz-agent model-tuning fields. Kept separate from `selectedRuntimeId` + * because Edit collapses `selectedRuntimeId` to the "inherit"/"custom" + * sentinel that drives the custom-command input — that sentinel must NOT + * be used to gate the model-tuning section. + */ + modelTuningRuntimeId: string; parallelism: string; relayUrl: string; + /** + * Drives the custom-command input only: "custom" shows it, anything else + * hides it. In Edit this carries the "inherit"/"custom" sentinel. + */ selectedRuntimeId: string; systemPrompt: string; turnTimeoutSeconds: string; onAcpCommandChange: (value: string) => void; onAgentArgsChange: (value: string) => void; onAgentCommandChange: (value: string) => void; + /** Called when a tier-1 field change writes/deletes a key in envVars. */ + onEnvVarsChange: (updated: EnvVarsValue) => void; onMcpCommandChange: (value: string) => void; onMcpToolsetsChange: (value: string) => void; onParallelismChange: (value: string) => void; @@ -142,6 +173,19 @@ export function CreateAgentRuntimeFields({ onSystemPromptChange: (value: string) => void; onTurnTimeoutChange: (value: string) => void; }) { + const isBuzzAgent = isBuzzAgentRuntime(modelTuningRuntimeId); + + /** Write or delete a single env var key. Empty string = inherit (delete). */ + function handleEnvVarChange(key: string, value: string) { + const next = { ...envVars }; + if (value === "") { + delete next[key]; + } else { + next[key] = value; + } + onEnvVarsChange(next); + } + return ( <>
@@ -337,10 +381,179 @@ export function CreateAgentRuntimeFields({ Blank means no override. buzz-acp will not add a [System] prompt.

+ + {/* Tier-1 buzz-agent model-tuning knobs — only shown for buzz-agent. */} + {isBuzzAgent ? ( + + ) : null} ); } +/** + * Dedicated editable fields for the four buzz-agent model-tuning knobs. + * Renders only when the selected runtime is buzz-agent. + * + * Empty field = inherit (key absent from envVars; placeholder shows baseline). + * Non-empty = explicit override written into envVars. + */ +function BuzzAgentModelTuningFields({ + envVars, + inheritedEnvVars, + onEnvVarChange, +}: { + envVars: EnvVarsValue; + inheritedEnvVars: EnvVarsValue; + onEnvVarChange: (key: string, value: string) => void; +}) { + return ( +
+

+ buzz-agent model tuning +

+ +
+ {/* Thinking / Effort */} +
+ + +

+ Controls how much reasoning effort the LLM applies per turn. Leave + blank to inherit from the global or persona default. +

+
+ + {/* Max Rounds */} +
+ + + 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] ?? ""} + /> +

+ Maximum LLM + tool-call rounds per turn. 0 = unlimited. Leave blank + to inherit. +

+
+ + {/* Max Output Tokens */} +
+ + + 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] ?? ""} + /> +

+ Maximum tokens the LLM may generate per response. Leave blank to + inherit. +

+
+ + {/* Context Limit */} +
+ + + 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] ?? ""} + /> +

+ Maximum context window tokens buzz-agent tracks before a handoff. + Leave blank to inherit. +

+
+
+
+ ); +} + export function CreateAgentOptionToggles({ isSpawnSupported, prereqs, diff --git a/desktop/src/features/agents/ui/EditAgentDialog.tsx b/desktop/src/features/agents/ui/EditAgentDialog.tsx index d5273b82fa..ce0879e99e 100644 --- a/desktop/src/features/agents/ui/EditAgentDialog.tsx +++ b/desktop/src/features/agents/ui/EditAgentDialog.tsx @@ -765,11 +765,15 @@ export function EditAgentDialog({ acpCommand={acpCommand} agentArgs={agentArgs} agentCommand={agentCommand} + envVars={envVars} + inheritedEnvVars={inheritedWithGlobal} mcpCommand={mcpCommand} mcpToolsets={mcpToolsets} + modelTuningRuntimeId={prospectiveRuntimeId} onAcpCommandChange={setAcpCommand} onAgentArgsChange={setAgentArgs} onAgentCommandChange={setAgentCommand} + onEnvVarsChange={setEnvVars} onMcpCommandChange={setMcpCommand} onMcpToolsetsChange={setMcpToolsets} onParallelismChange={setParallelism} diff --git a/desktop/src/features/agents/ui/buzzAgentConfig.test.mjs b/desktop/src/features/agents/ui/buzzAgentConfig.test.mjs new file mode 100644 index 0000000000..4b86353b98 --- /dev/null +++ b/desktop/src/features/agents/ui/buzzAgentConfig.test.mjs @@ -0,0 +1,224 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + BUZZ_AGENT_MAX_CONTEXT_TOKENS, + BUZZ_AGENT_MAX_OUTPUT_TOKENS, + BUZZ_AGENT_MAX_ROUNDS, + BUZZ_AGENT_THINKING_EFFORT, + BUZZ_AGENT_THINKING_EFFORT_VALUES, + isBuzzAgentRuntime, +} from "./buzzAgentConfig.ts"; + +// --------------------------------------------------------------------------- +// Thinking effort values +// --------------------------------------------------------------------------- + +test("BUZZ_AGENT_THINKING_EFFORT_VALUES contains exactly the 7 accepted values", () => { + assert.deepEqual( + [...BUZZ_AGENT_THINKING_EFFORT_VALUES], + ["none", "minimal", "low", "medium", "high", "xhigh", "max"], + ); +}); + +test("BUZZ_AGENT_THINKING_EFFORT_VALUES has no duplicates", () => { + const set = new Set(BUZZ_AGENT_THINKING_EFFORT_VALUES); + assert.equal(set.size, BUZZ_AGENT_THINKING_EFFORT_VALUES.length); +}); + +// --------------------------------------------------------------------------- +// Env var key constants +// --------------------------------------------------------------------------- + +test("env var key constants match expected BUZZ_AGENT_* names", () => { + assert.equal(BUZZ_AGENT_THINKING_EFFORT, "BUZZ_AGENT_THINKING_EFFORT"); + assert.equal(BUZZ_AGENT_MAX_OUTPUT_TOKENS, "BUZZ_AGENT_MAX_OUTPUT_TOKENS"); + assert.equal(BUZZ_AGENT_MAX_CONTEXT_TOKENS, "BUZZ_AGENT_MAX_CONTEXT_TOKENS"); + assert.equal(BUZZ_AGENT_MAX_ROUNDS, "BUZZ_AGENT_MAX_ROUNDS"); +}); + +// --------------------------------------------------------------------------- +// isBuzzAgentRuntime +// --------------------------------------------------------------------------- + +test("isBuzzAgentRuntime returns true only for buzz-agent id", () => { + assert.equal(isBuzzAgentRuntime("buzz-agent"), true); +}); + +test("isBuzzAgentRuntime returns false for other runtimes", () => { + assert.equal(isBuzzAgentRuntime("goose"), false); + assert.equal(isBuzzAgentRuntime("custom"), false); + assert.equal(isBuzzAgentRuntime(""), false); + assert.equal(isBuzzAgentRuntime("buzz-agent-v2"), false); +}); + +// --------------------------------------------------------------------------- +// handleEnvVarChange logic (the field→envVars mapping) +// --------------------------------------------------------------------------- + +/** + * Mirrors the handleEnvVarChange helper in CreateAgentRuntimeFields. + * Tests this directly without rendering React. + */ +function applyEnvVarChange(envVars, key, value) { + const next = { ...envVars }; + if (value === "") { + delete next[key]; + } else { + next[key] = value; + } + return next; +} + +test("setting a thinking effort value writes the key into envVars", () => { + const initial = {}; + const result = applyEnvVarChange(initial, BUZZ_AGENT_THINKING_EFFORT, "high"); + assert.equal(result[BUZZ_AGENT_THINKING_EFFORT], "high"); +}); + +test("clearing thinking effort removes the key so the agent inherits", () => { + const initial = { [BUZZ_AGENT_THINKING_EFFORT]: "high" }; + const result = applyEnvVarChange(initial, BUZZ_AGENT_THINKING_EFFORT, ""); + assert.equal(Object.hasOwn(result, BUZZ_AGENT_THINKING_EFFORT), false); +}); + +test("setting max output tokens writes the exact BUZZ_AGENT_MAX_OUTPUT_TOKENS key", () => { + const initial = {}; + const result = applyEnvVarChange( + initial, + BUZZ_AGENT_MAX_OUTPUT_TOKENS, + "4096", + ); + assert.equal(result[BUZZ_AGENT_MAX_OUTPUT_TOKENS], "4096"); + // Must not affect other keys + assert.equal(Object.keys(result).length, 1); +}); + +test("clearing max output tokens removes the key", () => { + const initial = { [BUZZ_AGENT_MAX_OUTPUT_TOKENS]: "4096" }; + const result = applyEnvVarChange(initial, BUZZ_AGENT_MAX_OUTPUT_TOKENS, ""); + assert.equal(Object.hasOwn(result, BUZZ_AGENT_MAX_OUTPUT_TOKENS), false); +}); + +test("setting context limit writes the exact BUZZ_AGENT_MAX_CONTEXT_TOKENS key", () => { + const initial = {}; + const result = applyEnvVarChange( + initial, + BUZZ_AGENT_MAX_CONTEXT_TOKENS, + "100000", + ); + assert.equal(result[BUZZ_AGENT_MAX_CONTEXT_TOKENS], "100000"); +}); + +test("clearing context limit removes the key", () => { + const initial = { [BUZZ_AGENT_MAX_CONTEXT_TOKENS]: "100000" }; + const result = applyEnvVarChange(initial, BUZZ_AGENT_MAX_CONTEXT_TOKENS, ""); + assert.equal(Object.hasOwn(result, BUZZ_AGENT_MAX_CONTEXT_TOKENS), false); +}); + +test("setting max rounds writes the exact BUZZ_AGENT_MAX_ROUNDS key", () => { + const initial = {}; + const result = applyEnvVarChange(initial, BUZZ_AGENT_MAX_ROUNDS, "50"); + assert.equal(result[BUZZ_AGENT_MAX_ROUNDS], "50"); +}); + +test("clearing max rounds removes the key", () => { + const initial = { [BUZZ_AGENT_MAX_ROUNDS]: "50" }; + const result = applyEnvVarChange(initial, BUZZ_AGENT_MAX_ROUNDS, ""); + assert.equal(Object.hasOwn(result, BUZZ_AGENT_MAX_ROUNDS), false); +}); + +test("changing one field does not disturb other env vars", () => { + const initial = { + SOME_OTHER_KEY: "value", + [BUZZ_AGENT_MAX_OUTPUT_TOKENS]: "2048", + }; + const result = applyEnvVarChange(initial, BUZZ_AGENT_MAX_ROUNDS, "20"); + assert.equal(result.SOME_OTHER_KEY, "value"); + assert.equal(result[BUZZ_AGENT_MAX_OUTPUT_TOKENS], "2048"); + assert.equal(result[BUZZ_AGENT_MAX_ROUNDS], "20"); +}); + +test("clearing one field does not disturb other env vars", () => { + const initial = { + SOME_OTHER_KEY: "value", + [BUZZ_AGENT_MAX_OUTPUT_TOKENS]: "2048", + [BUZZ_AGENT_MAX_ROUNDS]: "20", + }; + const result = applyEnvVarChange(initial, BUZZ_AGENT_MAX_ROUNDS, ""); + assert.equal(Object.hasOwn(result, BUZZ_AGENT_MAX_ROUNDS), false); + assert.equal(result.SOME_OTHER_KEY, "value"); + assert.equal(result[BUZZ_AGENT_MAX_OUTPUT_TOKENS], "2048"); +}); + +test("thinking effort select is bounded: all 7 accepted values are present in the constant", () => { + const expected = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]; + for (const v of expected) { + assert.ok( + BUZZ_AGENT_THINKING_EFFORT_VALUES.includes(v), + `missing value: ${v}`, + ); + } + assert.equal(BUZZ_AGENT_THINKING_EFFORT_VALUES.length, expected.length); +}); + +test("non-numeric string is stored as-is (validation is at the backend)", () => { + // HTML type=number inputs enforce numeric in the browser; we verify the + // mapping function itself is not a validator — that's intentional. + const result = applyEnvVarChange( + {}, + BUZZ_AGENT_MAX_OUTPUT_TOKENS, + "not-a-number", + ); + assert.equal(result[BUZZ_AGENT_MAX_OUTPUT_TOKENS], "not-a-number"); +}); + +// --------------------------------------------------------------------------- +// modelTuningRuntimeId → visibility mapping (regression for Edit dialog path) +// --------------------------------------------------------------------------- + +// Mirrors the `isBuzzAgent` derivation in CreateAgentRuntimeFields. +// The point of modelTuningRuntimeId is that the Edit dialog can pass +// prospectiveRuntimeId (the real resolved runtime) while selectedRuntimeId +// carries the "inherit"/"custom" sentinel — the two must not be conflated. + +test("isBuzzAgentRuntime(prospectiveRuntimeId) shows fields when Edit resolves buzz-agent even though selectedRuntimeId sentinel is 'inherit'", () => { + // Simulates Edit dialog state: inheritHarness=true, persona is buzz-agent. + // selectedRuntimeId would be "inherit" (sentinel for custom-command hiding), + // but prospectiveRuntimeId correctly resolves to "buzz-agent". + const selectedRuntimeIdSentinel = "inherit"; // what Edit passes to selectedRuntimeId + const prospectiveRuntimeId = "buzz-agent"; // what Edit passes to modelTuningRuntimeId + + assert.equal( + isBuzzAgentRuntime(selectedRuntimeIdSentinel), + false, + "sentinel 'inherit' must NOT trigger model-tuning fields", + ); + assert.equal( + isBuzzAgentRuntime(prospectiveRuntimeId), + true, + "prospectiveRuntimeId 'buzz-agent' MUST trigger model-tuning fields", + ); +}); + +test("isBuzzAgentRuntime(prospectiveRuntimeId) shows fields when Edit has a pinned buzz-agent (selectedRuntimeId sentinel is also 'inherit')", () => { + // Simulates Edit dialog with a pinned non-custom runtime: + // selectedRuntimeId sentinel = "inherit" (non-custom known runtime), + // prospectiveRuntimeId = "buzz-agent" (selectedRuntime?.id). + const selectedRuntimeIdSentinel = "inherit"; + const prospectiveRuntimeId = "buzz-agent"; + + assert.equal(isBuzzAgentRuntime(prospectiveRuntimeId), true); + assert.equal(isBuzzAgentRuntime(selectedRuntimeIdSentinel), false); +}); + +test("isBuzzAgentRuntime(prospectiveRuntimeId) hides fields when Edit resolves to non-buzz-agent", () => { + // E.g. user switches from buzz-agent to goose in Edit — prospectiveRuntimeId = "goose" + const prospectiveRuntimeId = "goose"; + assert.equal(isBuzzAgentRuntime(prospectiveRuntimeId), false); +}); + +test("isBuzzAgentRuntime(prospectiveRuntimeId) hides fields when Edit has no resolved runtime (empty string)", () => { + // prospectiveRuntimeId falls back to "" when catalog hasn't loaded yet + assert.equal(isBuzzAgentRuntime(""), false); +}); diff --git a/desktop/src/features/agents/ui/buzzAgentConfig.ts b/desktop/src/features/agents/ui/buzzAgentConfig.ts new file mode 100644 index 0000000000..9e8ecf8cdb --- /dev/null +++ b/desktop/src/features/agents/ui/buzzAgentConfig.ts @@ -0,0 +1,43 @@ +/** + * Source-of-truth constants for buzz-agent model-tuning configuration knobs. + * + * Values must stay in sync with `crates/buzz-agent/src/config.rs` + * `parse_thinking_effort` — that function is the authoritative list. + */ + +/** Env var key for the thinking/effort level sent to the LLM. */ +export const BUZZ_AGENT_THINKING_EFFORT = "BUZZ_AGENT_THINKING_EFFORT"; + +/** Env var key for the maximum output token count per turn. */ +export const BUZZ_AGENT_MAX_OUTPUT_TOKENS = "BUZZ_AGENT_MAX_OUTPUT_TOKENS"; + +/** Env var key for the context window token limit. */ +export const BUZZ_AGENT_MAX_CONTEXT_TOKENS = "BUZZ_AGENT_MAX_CONTEXT_TOKENS"; + +/** Env var key for the maximum number of LLM/tool rounds per turn. */ +export const BUZZ_AGENT_MAX_ROUNDS = "BUZZ_AGENT_MAX_ROUNDS"; + +/** + * Ordered set of valid thinking-effort values accepted by buzz-agent. + * Mirrors `parse_thinking_effort` in `crates/buzz-agent/src/config.rs`. + */ +export const BUZZ_AGENT_THINKING_EFFORT_VALUES = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const; + +export type ThinkingEffortValue = + (typeof BUZZ_AGENT_THINKING_EFFORT_VALUES)[number]; + +/** + * Returns true when the given runtime id is buzz-agent, which is the only + * runtime that supports the tier-1 model-tuning knobs above. + */ +export function isBuzzAgentRuntime(runtimeId: string): boolean { + return runtimeId === "buzz-agent"; +}