From 7b3aa7a4ffa5fe4df4e6a4508e4ffff10f6bae5f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Dec 2025 13:24:09 +0100 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=A4=96=20feat:=20mode-scoped=20AI=20d?= =?UTF-8?q?efaults=20+=20per-mode=20overrides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I19d4bc5c5dd1e5b2a38a4a3e6021bf0b8543b839 Signed-off-by: Thomas Kosiewski --- src/browser/App.tsx | 30 +- src/browser/components/AIView.tsx | 2 + src/browser/components/ChatInput/index.tsx | 32 ++- .../ChatInput/useCreationWorkspace.ts | 60 +++- .../components/Settings/SettingsModal.tsx | 9 +- .../Settings/sections/ModesSection.tsx | 266 ++++++++++++++++++ .../components/WorkspaceModeAISync.tsx | 72 +++++ src/browser/contexts/ThinkingContext.tsx | 31 +- src/browser/contexts/WorkspaceContext.tsx | 89 ++++-- src/browser/hooks/usePersistedState.ts | 28 ++ src/browser/hooks/useTelemetry.ts | 3 +- .../messages/compactionModelPreference.ts | 31 +- .../utils/messages/compactionOptions.test.ts | 8 +- .../utils/messages/compactionOptions.ts | 20 +- src/common/constants/storage.ts | 15 + src/common/orpc/schemas/api.ts | 30 ++ src/common/orpc/schemas/message.ts | 3 +- src/common/orpc/schemas/project.ts | 5 +- src/common/orpc/schemas/stream.ts | 5 +- src/common/orpc/schemas/telemetry.ts | 7 +- src/common/orpc/schemas/workspace.ts | 5 +- .../orpc/schemas/workspaceAiSettings.ts | 14 + src/common/orpc/schemas/workspaceStats.ts | 11 +- src/common/telemetry/payload.ts | 8 +- src/common/telemetry/tracking.ts | 3 +- src/common/types/message.ts | 3 +- src/common/types/mode.ts | 13 +- src/common/types/modeAiDefaults.ts | 41 +++ src/common/types/project.ts | 3 + src/node/config.ts | 12 + src/node/orpc/router.ts | 25 ++ src/node/services/aiService.ts | 6 +- src/node/services/sessionTimingService.ts | 7 +- .../tools/code_execution.integration.test.ts | 144 +++++----- src/node/services/workspaceService.ts | 119 +++++++- 35 files changed, 1003 insertions(+), 157 deletions(-) create mode 100644 src/browser/components/Settings/sections/ModesSection.tsx create mode 100644 src/browser/components/WorkspaceModeAISync.tsx create mode 100644 src/common/types/modeAiDefaults.ts diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 3267245f95..0c4cc2858c 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -26,13 +26,16 @@ import { ThemeProvider, useTheme, type ThemeMode } from "./contexts/ThemeContext import { CommandPalette } from "./components/CommandPalette"; import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources"; +import type { UIMode } from "@/common/types/mode"; import type { ThinkingLevel } from "@/common/types/thinking"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import { isWorkspaceForkSwitchEvent } from "./utils/workspaceEvents"; import { + getModeKey, + getModelKey, getThinkingLevelByModelKey, getThinkingLevelKey, - getModelKey, + getWorkspaceAISettingsByModeKey, } from "@/common/constants/storage"; import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels"; import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; @@ -319,10 +322,33 @@ function AppInner() { // ThinkingProvider will pick this up via its listener updatePersistedState(key, effective); + type WorkspaceAISettingsByModeCache = Partial< + Record + >; + + const mode = readPersistedState(getModeKey(workspaceId), "exec"); + + updatePersistedState( + getWorkspaceAISettingsByModeKey(workspaceId), + (prev) => { + const record: WorkspaceAISettingsByModeCache = + prev && typeof prev === "object" ? prev : {}; + return { + ...record, + [mode]: { model, thinkingLevel: effective }, + }; + }, + {} + ); + // Persist to backend so the palette change follows the workspace across devices. if (api) { api.workspace - .updateAISettings({ workspaceId, aiSettings: { model, thinkingLevel: effective } }) + .updateModeAISettings({ + workspaceId, + mode, + aiSettings: { model, thinkingLevel: effective }, + }) .catch(() => { // Best-effort only. }); diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index e0fb846838..3ae07201d9 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -34,6 +34,7 @@ import { import { BashOutputCollapsedIndicator } from "./tools/BashOutputCollapsedIndicator"; import { hasInterruptedStream } from "@/browser/utils/messages/retryEligibility"; import { ThinkingProvider } from "@/browser/contexts/ThinkingContext"; +import { WorkspaceModeAISync } from "@/browser/components/WorkspaceModeAISync"; import { ModeProvider } from "@/browser/contexts/ModeContext"; import { ProviderOptionsProvider } from "@/browser/contexts/ProviderOptionsContext"; @@ -838,6 +839,7 @@ export const AIView: React.FC = (props) => { return ( + diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 65ecb3cc34..ea417f5c0c 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -26,6 +26,7 @@ import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; import { getModelKey, + getWorkspaceAISettingsByModeKey, getInputKey, getInputImagesKey, VIM_ENABLED_KEY, @@ -339,26 +340,49 @@ const ChatInputInner: React.FC = (props) => { const setPreferredModel = useCallback( (model: string) => { + type WorkspaceAISettingsByModeCache = Partial< + Record<"plan" | "exec", { model: string; thinkingLevel: ThinkingLevel }> + >; + const canonicalModel = migrateGatewayModel(model); ensureModelInSettings(canonicalModel); // Ensure model exists in Settings updatePersistedState(storageKeys.modelKey, canonicalModel); // Update workspace or project-specific - // Workspace variant: persist to backend for cross-device consistency. - if (!api || variant !== "workspace" || !workspaceId) { + if (variant !== "workspace" || !workspaceId) { return; } const effectiveThinkingLevel = enforceThinkingPolicy(canonicalModel, thinkingLevel); + + updatePersistedState( + getWorkspaceAISettingsByModeKey(workspaceId), + (prev) => { + const record: WorkspaceAISettingsByModeCache = + prev && typeof prev === "object" ? prev : {}; + return { + ...record, + [mode]: { model: canonicalModel, thinkingLevel: effectiveThinkingLevel }, + }; + }, + {} + ); + + // Workspace variant: persist to backend for cross-device consistency. + if (!api) { + return; + } + api.workspace - .updateAISettings({ + .updateModeAISettings({ workspaceId, + mode, aiSettings: { model: canonicalModel, thinkingLevel: effectiveThinkingLevel }, }) .catch(() => { // Best-effort only. If offline or backend is old, sendMessage will persist. }); }, - [api, storageKeys.modelKey, ensureModelInSettings, thinkingLevel, variant, workspaceId] + [api, mode, storageKeys.modelKey, ensureModelInSettings, thinkingLevel, variant, workspaceId] ); const deferredModel = useDeferredValue(preferredModel); const deferredInput = useDeferredValue(input); diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index 0d86fa700d..314b029889 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -13,6 +13,7 @@ import { getModelKey, getModeKey, getThinkingLevelKey, + getWorkspaceAISettingsByModeKey, getPendingScopeId, getProjectScopeId, } from "@/common/constants/storage"; @@ -54,6 +55,27 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void if (projectThinkingLevel !== null) { updatePersistedState(getThinkingLevelKey(workspaceId), projectThinkingLevel); } + + if (projectModel) { + const effectiveMode: UIMode = projectMode ?? "exec"; + const effectiveThinking: ThinkingLevel = projectThinkingLevel ?? "off"; + + updatePersistedState< + Partial> + >( + getWorkspaceAISettingsByModeKey(workspaceId), + (prev) => { + const record = prev && typeof prev === "object" ? prev : {}; + return { + ...(record as Partial< + Record<"plan" | "exec", { model: string; thinkingLevel: ThinkingLevel }> + >), + [effectiveMode]: { model: projectModel, thinkingLevel: effectiveThinking }, + }; + }, + {} + ); + } } interface UseCreationWorkspaceReturn { @@ -205,17 +227,32 @@ export function useCreationWorkspace({ // Best-effort: persist the initial AI settings to the backend immediately so this workspace // is portable across devices even before the first stream starts. - api.workspace - .updateAISettings({ - workspaceId: metadata.id, - aiSettings: { - model: settings.model, - thinkingLevel: settings.thinkingLevel, - }, - }) - .catch(() => { - // Ignore (offline / older backend). sendMessage will persist as a fallback. - }); + try { + api.workspace + .updateModeAISettings({ + workspaceId: metadata.id, + mode: settings.mode, + aiSettings: { + model: settings.model, + thinkingLevel: settings.thinkingLevel, + }, + }) + .catch(() => { + // Ignore (offline / older backend). sendMessage will persist as a fallback. + }); + } catch { + api.workspace + .updateAISettings({ + workspaceId: metadata.id, + aiSettings: { + model: settings.model, + thinkingLevel: settings.thinkingLevel, + }, + }) + .catch(() => { + // Ignore (offline / older backend). sendMessage will persist as a fallback. + }); + } // Sync preferences immediately (before switching) syncCreationPreferences(projectPath, metadata.id); if (projectPath) { @@ -259,6 +296,7 @@ export function useCreationWorkspace({ projectScopeId, onWorkspaceCreated, getRuntimeString, + settings.mode, settings.model, settings.thinkingLevel, settings.trunkBranch, diff --git a/src/browser/components/Settings/SettingsModal.tsx b/src/browser/components/Settings/SettingsModal.tsx index 757e7361f6..236215c0aa 100644 --- a/src/browser/components/Settings/SettingsModal.tsx +++ b/src/browser/components/Settings/SettingsModal.tsx @@ -1,10 +1,11 @@ import React from "react"; -import { Settings, Key, Cpu, X, Briefcase, FlaskConical, Bot } from "lucide-react"; +import { Settings, Key, Cpu, X, Briefcase, FlaskConical, Bot, Layers } from "lucide-react"; import { useSettings } from "@/browser/contexts/SettingsContext"; import { Dialog, DialogContent, DialogTitle, VisuallyHidden } from "@/browser/components/ui/dialog"; import { GeneralSection } from "./sections/GeneralSection"; import { TasksSection } from "./sections/TasksSection"; import { ProvidersSection } from "./sections/ProvidersSection"; +import { ModesSection } from "./sections/ModesSection"; import { ModelsSection } from "./sections/ModelsSection"; import { Button } from "@/browser/components/ui/button"; import { ProjectSettingsSection } from "./sections/ProjectSettingsSection"; @@ -36,6 +37,12 @@ const SECTIONS: SettingsSection[] = [ icon: , component: ProjectSettingsSection, }, + { + id: "modes", + label: "Modes", + icon: , + component: ModesSection, + }, { id: "models", label: "Models", diff --git a/src/browser/components/Settings/sections/ModesSection.tsx b/src/browser/components/Settings/sections/ModesSection.tsx new file mode 100644 index 0000000000..834c8847ff --- /dev/null +++ b/src/browser/components/Settings/sections/ModesSection.tsx @@ -0,0 +1,266 @@ +import React, { useEffect, useRef, useState } from "react"; +import { useAPI } from "@/browser/contexts/API"; +import { ModelSelector } from "@/browser/components/ModelSelector"; +import { useModelsFromSettings } from "@/browser/hooks/useModelsFromSettings"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/browser/components/ui/select"; +import type { ThinkingLevel } from "@/common/types/thinking"; +import { + normalizeModeAiDefaults, + type ModeAiDefaults, + type ModeAiDefaultsEntry, +} from "@/common/types/modeAiDefaults"; +import { enforceThinkingPolicy, getThinkingPolicyForModel } from "@/common/utils/thinking/policy"; +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { MODE_AI_DEFAULTS_KEY } from "@/common/constants/storage"; + +const INHERIT = "__inherit__"; +const ALL_THINKING_LEVELS = ["off", "low", "medium", "high", "xhigh"] as const; + +const MODE_ORDER = [ + { id: "plan", label: "Plan" }, + { id: "exec", label: "Exec" }, + { id: "compact", label: "Compact" }, +] as const; + +type ModeId = (typeof MODE_ORDER)[number]["id"]; + +function updateModeDefaultEntry( + previous: ModeAiDefaults, + mode: ModeId, + update: (entry: ModeAiDefaultsEntry) => void +): ModeAiDefaults { + const next = { ...previous }; + const existing = next[mode] ?? {}; + const updated: ModeAiDefaultsEntry = { ...existing }; + update(updated); + + if (updated.modelString && updated.thinkingLevel) { + updated.thinkingLevel = enforceThinkingPolicy(updated.modelString, updated.thinkingLevel); + } + + if (!updated.modelString && !updated.thinkingLevel) { + delete next[mode]; + } else { + next[mode] = updated; + } + + return next; +} + +export function ModesSection() { + const { api } = useAPI(); + const { models, hiddenModels } = useModelsFromSettings(); + + const [modeAiDefaults, setModeAiDefaults] = useState({}); + const [loaded, setLoaded] = useState(false); + const [loadFailed, setLoadFailed] = useState(false); + const [saveError, setSaveError] = useState(null); + + const saveTimerRef = useRef | null>(null); + const savingRef = useRef(false); + const pendingSaveRef = useRef(null); + + useEffect(() => { + if (!api) return; + + setLoaded(false); + setLoadFailed(false); + setSaveError(null); + + void api.config + .getConfig() + .then((cfg) => { + const normalized = normalizeModeAiDefaults(cfg.modeAiDefaults ?? {}); + setModeAiDefaults(normalized); + // Keep a local cache for non-react readers (compaction handler, sync, etc.) + updatePersistedState(MODE_AI_DEFAULTS_KEY, normalized); + setLoadFailed(false); + setLoaded(true); + }) + .catch((error: unknown) => { + setSaveError(error instanceof Error ? error.message : String(error)); + setLoadFailed(true); + setLoaded(true); + }); + }, [api]); + + useEffect(() => { + if (!api) return; + if (!loaded) return; + if (loadFailed) return; + + pendingSaveRef.current = modeAiDefaults; + updatePersistedState(MODE_AI_DEFAULTS_KEY, modeAiDefaults); + + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + + saveTimerRef.current = setTimeout(() => { + const flush = () => { + if (savingRef.current) return; + if (!api) return; + + const payload = pendingSaveRef.current; + if (!payload) return; + + pendingSaveRef.current = null; + savingRef.current = true; + void api.config + .updateModeAiDefaults({ modeAiDefaults: payload }) + .catch((error: unknown) => { + setSaveError(error instanceof Error ? error.message : String(error)); + }) + .finally(() => { + savingRef.current = false; + flush(); + }); + }; + + flush(); + }, 400); + + return () => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + }; + }, [api, loaded, loadFailed, modeAiDefaults]); + + // Flush any pending debounced save on unmount so changes aren't lost. + useEffect(() => { + if (!api) return; + if (!loaded) return; + if (loadFailed) return; + + return () => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + + if (savingRef.current) return; + const payload = pendingSaveRef.current; + if (!payload) return; + + pendingSaveRef.current = null; + savingRef.current = true; + void api.config + .updateModeAiDefaults({ modeAiDefaults: payload }) + .catch(() => undefined) + .finally(() => { + savingRef.current = false; + }); + }; + }, [api, loaded, loadFailed]); + + const setModeModel = (mode: ModeId, value: string) => { + setModeAiDefaults((prev) => + updateModeDefaultEntry(prev, mode, (updated) => { + if (value === INHERIT) { + delete updated.modelString; + } else { + updated.modelString = value; + } + }) + ); + }; + + const setModeThinking = (mode: ModeId, value: string) => { + setModeAiDefaults((prev) => + updateModeDefaultEntry(prev, mode, (updated) => { + if (value === INHERIT) { + delete updated.thinkingLevel; + return; + } + + updated.thinkingLevel = value as ThinkingLevel; + }) + ); + }; + + return ( +
+
+

Mode Defaults

+
+ Defaults apply globally. Changing model/reasoning in a workspace creates a workspace + override. +
+ + {saveError &&
{saveError}
} +
+ +
+ {MODE_ORDER.map((m) => { + const entry = modeAiDefaults[m.id]; + const modelValue = entry?.modelString ?? INHERIT; + const thinkingValue = entry?.thinkingLevel ?? INHERIT; + const allowedThinkingLevels = + modelValue !== INHERIT ? getThinkingPolicyForModel(modelValue) : ALL_THINKING_LEVELS; + + return ( +
+
{m.label}
+ +
+
+
Model
+
+ setModeModel(m.id, value)} + models={models} + hiddenModels={hiddenModels} + /> + {modelValue !== INHERIT ? ( + + ) : null} +
+
+ +
+
Reasoning
+ +
+
+
+ ); + })} +
+
+ ); +} diff --git a/src/browser/components/WorkspaceModeAISync.tsx b/src/browser/components/WorkspaceModeAISync.tsx new file mode 100644 index 0000000000..7180f8d735 --- /dev/null +++ b/src/browser/components/WorkspaceModeAISync.tsx @@ -0,0 +1,72 @@ +import { useEffect } from "react"; +import { useMode } from "@/browser/contexts/ModeContext"; +import { updatePersistedState, usePersistedState } from "@/browser/hooks/usePersistedState"; +import { + getModelKey, + getThinkingLevelKey, + getWorkspaceAISettingsByModeKey, + MODE_AI_DEFAULTS_KEY, +} from "@/common/constants/storage"; +import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; +import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; +import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking"; +import type { ModeAiDefaults } from "@/common/types/modeAiDefaults"; + +type WorkspaceAISettingsByModeCache = Partial< + Record<"plan" | "exec", { model: string; thinkingLevel: ThinkingLevel }> +>; + +export function WorkspaceModeAISync(props: { workspaceId: string }): null { + const workspaceId = props.workspaceId; + const [mode] = useMode(); + + const [modeAiDefaults] = usePersistedState( + MODE_AI_DEFAULTS_KEY, + {}, + { + listener: true, + } + ); + const [workspaceByMode] = usePersistedState( + getWorkspaceAISettingsByModeKey(workspaceId), + {}, + { listener: true } + ); + + useEffect(() => { + const fallbackModel = getDefaultModel(); + + const candidateModel = workspaceByMode[mode]?.model ?? modeAiDefaults[mode]?.modelString; + const resolvedModel = + typeof candidateModel === "string" && candidateModel.trim().length > 0 + ? candidateModel + : fallbackModel; + + const candidateThinking = + workspaceByMode[mode]?.thinkingLevel ?? modeAiDefaults[mode]?.thinkingLevel ?? "off"; + const resolvedThinking = coerceThinkingLevel(candidateThinking) ?? "off"; + + const effectiveThinking = enforceThinkingPolicy(resolvedModel, resolvedThinking); + + const modelKey = getModelKey(workspaceId); + const thinkingKey = getThinkingLevelKey(workspaceId); + + updatePersistedState( + modelKey, + (prev) => { + return prev === resolvedModel ? prev : resolvedModel; + }, + fallbackModel + ); + + updatePersistedState( + thinkingKey, + (prev) => { + return prev === effectiveThinking ? prev : effectiveThinking; + }, + "off" + ); + }, [mode, modeAiDefaults, workspaceByMode, workspaceId]); + + return null; +} diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index 90f5ee5821..ce700a25bc 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import React, { createContext, useContext, useEffect, useMemo, useCallback } from "react"; +import type { UIMode } from "@/common/types/mode"; import type { ThinkingLevel } from "@/common/types/thinking"; import { readPersistedState, @@ -9,8 +10,10 @@ import { import { getModelKey, getProjectScopeId, + getModeKey, getThinkingLevelByModelKey, getThinkingLevelKey, + getWorkspaceAISettingsByModeKey, GLOBAL_SCOPE_ID, } from "@/common/constants/storage"; import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; @@ -79,13 +82,37 @@ export const ThinkingProvider: React.FC = (props) => { setThinkingLevelInternal(effective); // Workspace variant: persist to backend so settings follow the workspace across devices. - if (!props.workspaceId || !api) { + if (!props.workspaceId) { + return; + } + + type WorkspaceAISettingsByModeCache = Partial< + Record + >; + + const mode = readPersistedState(getModeKey(scopeId), "exec"); + + updatePersistedState( + getWorkspaceAISettingsByModeKey(props.workspaceId), + (prev) => { + const record: WorkspaceAISettingsByModeCache = + prev && typeof prev === "object" ? prev : {}; + return { + ...record, + [mode]: { model, thinkingLevel: effective }, + }; + }, + {} + ); + + if (!api) { return; } api.workspace - .updateAISettings({ + .updateModeAISettings({ workspaceId: props.workspaceId, + mode, aiSettings: { model, thinkingLevel: effective }, }) .catch(() => { diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index f32fd45fe5..5a4c196d3f 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -10,13 +10,17 @@ import { type SetStateAction, } from "react"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { UIMode } from "@/common/types/mode"; import type { ThinkingLevel } from "@/common/types/thinking"; import type { WorkspaceSelection } from "@/browser/components/ProjectSidebar"; import type { RuntimeConfig } from "@/common/types/runtime"; import { deleteWorkspaceStorage, + getModeKey, getModelKey, getThinkingLevelKey, + getWorkspaceAISettingsByModeKey, + MODE_AI_DEFAULTS_KEY, SELECTED_WORKSPACE_KEY, } from "@/common/constants/storage"; import { useAPI } from "@/browser/contexts/API"; @@ -29,6 +33,7 @@ import { useProjectContext } from "@/browser/contexts/ProjectContext"; import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; import { isExperimentEnabled } from "@/browser/hooks/useExperiments"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; +import { normalizeModeAiDefaults } from "@/common/types/modeAiDefaults"; import { isWorkspaceArchived } from "@/common/utils/archive"; /** @@ -37,27 +42,62 @@ import { isWorkspaceArchived } from "@/common/utils/archive"; * This keeps a workspace's model/thinking consistent across devices/browsers. */ function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadata): void { - const ai = metadata.aiSettings; - if (!ai) { + type WorkspaceAISettingsByModeCache = Partial< + Record + >; + + const workspaceId = metadata.id; + + const aiByMode = + metadata.aiSettingsByMode ?? + (metadata.aiSettings + ? { + plan: metadata.aiSettings, + exec: metadata.aiSettings, + } + : undefined); + + if (!aiByMode) { return; } - // Seed model selection. - if (typeof ai.model === "string" && ai.model.length > 0) { - const modelKey = getModelKey(metadata.id); - const existingModel = readPersistedState(modelKey, undefined); - if (existingModel !== ai.model) { - updatePersistedState(modelKey, ai.model); - } + // Merge backend values into a per-workspace per-mode cache. + const byModeKey = getWorkspaceAISettingsByModeKey(workspaceId); + const existingByMode = readPersistedState(byModeKey, {}); + const nextByMode: WorkspaceAISettingsByModeCache = { ...existingByMode }; + + for (const mode of ["plan", "exec"] as const) { + const entry = aiByMode[mode]; + if (!entry) continue; + if (typeof entry.model !== "string" || entry.model.length === 0) continue; + + nextByMode[mode] = { + model: entry.model, + thinkingLevel: entry.thinkingLevel, + }; } - // Seed thinking level. - if (ai.thinkingLevel) { - const thinkingKey = getThinkingLevelKey(metadata.id); - const existingThinking = readPersistedState(thinkingKey, undefined); - if (existingThinking !== ai.thinkingLevel) { - updatePersistedState(thinkingKey, ai.thinkingLevel); - } + if (JSON.stringify(existingByMode) !== JSON.stringify(nextByMode)) { + updatePersistedState(byModeKey, nextByMode); + } + + // Seed the active mode into the existing keys to avoid UI flash. + const activeMode = readPersistedState(getModeKey(workspaceId), "exec"); + const active = nextByMode[activeMode] ?? nextByMode.exec ?? nextByMode.plan; + if (!active) { + return; + } + + const modelKey = getModelKey(workspaceId); + const existingModel = readPersistedState(modelKey, undefined); + if (existingModel !== active.model) { + updatePersistedState(modelKey, active.model); + } + + const thinkingKey = getThinkingLevelKey(workspaceId); + const existingThinking = readPersistedState(thinkingKey, undefined); + if (existingThinking !== active.thinkingLevel) { + updatePersistedState(thinkingKey, active.thinkingLevel); } } @@ -137,6 +177,23 @@ interface WorkspaceProviderProps { export function WorkspaceProvider(props: WorkspaceProviderProps) { const { api } = useAPI(); + + // Cache global mode defaults so non-react code paths (compaction, etc.) can read them. + useEffect(() => { + if (!api?.config?.getConfig) return; + + void api.config + .getConfig() + .then((cfg) => { + updatePersistedState( + MODE_AI_DEFAULTS_KEY, + normalizeModeAiDefaults(cfg.modeAiDefaults ?? {}) + ); + }) + .catch(() => { + // Best-effort only. + }); + }, [api]); // Get project refresh function from ProjectContext const { refreshProjects } = useProjectContext(); diff --git a/src/browser/hooks/usePersistedState.ts b/src/browser/hooks/usePersistedState.ts index 97878389a1..b9d85194e6 100644 --- a/src/browser/hooks/usePersistedState.ts +++ b/src/browser/hooks/usePersistedState.ts @@ -78,6 +78,34 @@ export function readPersistedState(key: string, defaultValue: T): T { } } +/** + * Read a persisted string value from localStorage. + * + * Unlike readPersistedState(), this tolerates values that were written as raw + * strings (not JSON) by legacy code. + */ +export function readPersistedString(key: string): string | undefined { + if (typeof window === "undefined" || !window.localStorage) { + return undefined; + } + + const storedValue = window.localStorage.getItem(key); + if (storedValue === null || storedValue === "undefined") { + return undefined; + } + + try { + const parsed: unknown = JSON.parse(storedValue); + if (typeof parsed === "string") { + return parsed; + } + } catch { + // Fall through to raw string. + } + + return storedValue; +} + /** * Update a persisted state value from outside the hook. * This is useful when you need to update state from a different component/context diff --git a/src/browser/hooks/useTelemetry.ts b/src/browser/hooks/useTelemetry.ts index 139f66f234..a29910761a 100644 --- a/src/browser/hooks/useTelemetry.ts +++ b/src/browser/hooks/useTelemetry.ts @@ -11,6 +11,7 @@ import { trackErrorOccurred, trackExperimentOverridden, } from "@/common/telemetry"; +import type { AgentMode } from "@/common/types/mode"; import type { ErrorContext, TelemetryRuntimeType, @@ -53,7 +54,7 @@ export function useTelemetry() { ( workspaceId: string, model: string, - mode: string, + mode: AgentMode, messageLength: number, runtimeType: TelemetryRuntimeType, thinkingLevel: TelemetryThinkingLevel diff --git a/src/browser/utils/messages/compactionModelPreference.ts b/src/browser/utils/messages/compactionModelPreference.ts index e35fc15125..83922eb9ec 100644 --- a/src/browser/utils/messages/compactionModelPreference.ts +++ b/src/browser/utils/messages/compactionModelPreference.ts @@ -1,31 +1,38 @@ /** * Compaction model preference management - * - * Handles the sticky global preference for which model to use during compaction. */ -import { PREFERRED_COMPACTION_MODEL_KEY } from "@/common/constants/storage"; +import { readPersistedState, readPersistedString } from "@/browser/hooks/usePersistedState"; +import { MODE_AI_DEFAULTS_KEY, PREFERRED_COMPACTION_MODEL_KEY } from "@/common/constants/storage"; +import type { ModeAiDefaults } from "@/common/types/modeAiDefaults"; // Re-export for convenience - validation used in /compact handler export { isValidModelFormat } from "@/common/utils/ai/models"; /** - * Resolve the effective compaction model to use for compaction. + * Resolve the effective compaction model to use. * - * @param requestedModel - Model specified in /compact -m flag (if any) - * @returns The model to use for compaction, or undefined to use workspace default + * Priority: + * 1) /compact -m flag + * 2) Global mode default for compact + * 3) Legacy global preference (preferredCompactionModel) + * 4) undefined (caller falls back to workspace default) */ export function resolveCompactionModel(requestedModel: string | undefined): string | undefined { - if (requestedModel) { + if (typeof requestedModel === "string" && requestedModel.trim().length > 0) { return requestedModel; } - // No model specified, check if user has a saved preference - const savedModel = localStorage.getItem(PREFERRED_COMPACTION_MODEL_KEY); - if (savedModel) { - return savedModel; + const modeAiDefaults = readPersistedState(MODE_AI_DEFAULTS_KEY, {}); + const compactModel = modeAiDefaults.compact?.modelString; + if (typeof compactModel === "string" && compactModel.trim().length > 0) { + return compactModel; + } + + const legacyModel = readPersistedString(PREFERRED_COMPACTION_MODEL_KEY); + if (typeof legacyModel === "string" && legacyModel.trim().length > 0) { + return legacyModel; } - // No preference saved, return undefined to use workspace default return undefined; } diff --git a/src/browser/utils/messages/compactionOptions.test.ts b/src/browser/utils/messages/compactionOptions.test.ts index a6a257ec35..f6273af4fb 100644 --- a/src/browser/utils/messages/compactionOptions.test.ts +++ b/src/browser/utils/messages/compactionOptions.test.ts @@ -32,20 +32,20 @@ describe("applyCompactionOverrides", () => { expect(result.model).toBe(KNOWN_MODELS.HAIKU.id); }); - it("preserves workspace thinking level for all models", () => { - // Test Anthropic model + it("enforces thinking policy for the compaction model", () => { + // Test Anthropic model (supports medium) const anthropicData: CompactionRequestData = { model: KNOWN_MODELS.HAIKU.id, }; const anthropicResult = applyCompactionOverrides(baseOptions, anthropicData); expect(anthropicResult.thinkingLevel).toBe("medium"); - // Test OpenAI model + // Test OpenAI model (gpt-5-pro only supports high) const openaiData: CompactionRequestData = { model: "openai:gpt-5-pro", }; const openaiResult = applyCompactionOverrides(baseOptions, openaiData); - expect(openaiResult.thinkingLevel).toBe("medium"); + expect(openaiResult.thinkingLevel).toBe("high"); }); it("applies maxOutputTokens override", () => { diff --git a/src/browser/utils/messages/compactionOptions.ts b/src/browser/utils/messages/compactionOptions.ts index b7efdbf77c..7e0f544516 100644 --- a/src/browser/utils/messages/compactionOptions.ts +++ b/src/browser/utils/messages/compactionOptions.ts @@ -5,9 +5,14 @@ * Used by both ChatInput (initial send) and useResumeManager (resume after interruption). */ +import { readPersistedState } from "@/browser/hooks/usePersistedState"; +import { toGatewayModel } from "@/browser/hooks/useGatewayModels"; +import { MODE_AI_DEFAULTS_KEY } from "@/common/constants/storage"; import type { SendMessageOptions } from "@/common/orpc/types"; import type { CompactionRequestData } from "@/common/types/message"; -import { toGatewayModel } from "@/browser/hooks/useGatewayModels"; +import type { ModeAiDefaults } from "@/common/types/modeAiDefaults"; +import { coerceThinkingLevel } from "@/common/types/thinking"; +import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; /** * Apply compaction-specific option overrides to base options. @@ -24,15 +29,20 @@ export function applyCompactionOverrides( baseOptions: SendMessageOptions, compactData: CompactionRequestData ): SendMessageOptions { - // Use custom model if specified, otherwise use workspace default - // Apply gateway transformation - compactData.model is raw, baseOptions.model is already transformed + // Apply gateway transformation - compactData.model is raw, baseOptions.model is already transformed. const compactionModel = compactData.model ? toGatewayModel(compactData.model) : baseOptions.model; + const modeAiDefaults = readPersistedState(MODE_AI_DEFAULTS_KEY, {}); + const preferredThinking = modeAiDefaults.compact?.thinkingLevel; + + const requestedThinking = + coerceThinkingLevel(preferredThinking ?? baseOptions.thinkingLevel) ?? "off"; + const thinkingLevel = enforceThinkingPolicy(compactionModel, requestedThinking); + return { ...baseOptions, model: compactionModel, - // Keep workspace default thinking level - all models support thinking now that tools are disabled - thinkingLevel: baseOptions.thinkingLevel, + thinkingLevel, maxOutputTokens: compactData.maxOutputTokens, mode: "compact" as const, // Disable all tools during compaction - regex .* matches all tool names diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 6c9591f2ce..cafd5d85ec 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -65,6 +65,14 @@ export function getThinkingLevelKey(scopeId: string): string { return `thinkingLevel:${scopeId}`; } +/** + * Get the localStorage key for per-mode workspace AI overrides cache. + * Format: "workspaceAiSettingsByMode:{workspaceId}" + */ +export function getWorkspaceAISettingsByModeKey(workspaceId: string): string { + return `workspaceAiSettingsByMode:${workspaceId}`; +} + /** * LEGACY: Get the localStorage key for thinking level preference per model (global). * Format: "thinkingLevel:model:{modelName}" @@ -165,6 +173,12 @@ export function getLastSshHostKey(projectPath: string): string { */ export const PREFERRED_COMPACTION_MODEL_KEY = "preferredCompactionModel"; +/** + * Get the localStorage key for cached mode AI defaults (global). + * Format: "modeAiDefaults" + */ +export const MODE_AI_DEFAULTS_KEY = "modeAiDefaults"; + /** * Get the localStorage key for vim mode preference (global) * Format: "vimEnabled" @@ -322,6 +336,7 @@ export function getAutoCompactionThresholdKey(model: string): string { * List of workspace-scoped key functions that should be copied on fork and deleted on removal */ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> = [ + getWorkspaceAISettingsByModeKey, getModelKey, getInputKey, getInputImagesKey, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index c4ec20ed83..0d748cb4fb 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1,4 +1,5 @@ import { eventIterator } from "@orpc/server"; +import { UIModeSchema } from "../../types/mode"; import { z } from "zod"; import { ChatStatsSchema, SessionUsageFileSchema } from "./chatStats"; import { SendMessageErrorSchema } from "./errors"; @@ -255,6 +256,14 @@ export const workspace = { input: z.object({ workspaceId: z.string(), title: z.string() }), output: ResultSchema(z.void(), z.string()), }, + updateModeAISettings: { + input: z.object({ + workspaceId: z.string(), + mode: UIModeSchema, + aiSettings: WorkspaceAISettingsSchema, + }), + output: ResultSchema(z.void(), z.string()), + }, updateAISettings: { input: z.object({ workspaceId: z.string(), @@ -650,6 +659,20 @@ const SubagentAiDefaultsEntrySchema = z }) .strict(); +const ModeAiDefaultsEntrySchema = z + .object({ + modelString: z.string().min(1).optional(), + thinkingLevel: z.enum(["off", "low", "medium", "high", "xhigh"]).optional(), + }) + .strict(); + +const ModeAiDefaultsSchema = z + .object({ + plan: ModeAiDefaultsEntrySchema.optional(), + exec: ModeAiDefaultsEntrySchema.optional(), + compact: ModeAiDefaultsEntrySchema.optional(), + }) + .strict(); const SubagentAiDefaultsSchema = z.record(z.string().min(1), SubagentAiDefaultsEntrySchema); export const config = { @@ -661,6 +684,7 @@ export const config = { maxTaskNestingDepth: z.number().int(), }), subagentAiDefaults: SubagentAiDefaultsSchema, + modeAiDefaults: ModeAiDefaultsSchema, }), }, saveConfig: { @@ -673,6 +697,12 @@ export const config = { }), output: z.void(), }, + updateModeAiDefaults: { + input: z.object({ + modeAiDefaults: ModeAiDefaultsSchema, + }), + output: z.void(), + }, }; // Splash screens diff --git a/src/common/orpc/schemas/message.ts b/src/common/orpc/schemas/message.ts index 930d503caa..764c8f6e8a 100644 --- a/src/common/orpc/schemas/message.ts +++ b/src/common/orpc/schemas/message.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { AgentModeSchema } from "../../types/mode"; import { StreamErrorTypeSchema } from "./errors"; export const ImagePartSchema = z.object({ @@ -110,7 +111,7 @@ export const MuxMessageSchema = z.object({ // Compaction source: "user" (manual), "idle" (auto), or legacy boolean (true) compacted: z.union([z.literal("user"), z.literal("idle"), z.boolean()]).optional(), toolPolicy: z.any().optional(), - mode: z.string().optional(), + mode: AgentModeSchema.optional().catch(undefined), partial: z.boolean().optional(), synthetic: z.boolean().optional(), error: z.string().optional(), diff --git a/src/common/orpc/schemas/project.ts b/src/common/orpc/schemas/project.ts index 82281a71dc..b712b57305 100644 --- a/src/common/orpc/schemas/project.ts +++ b/src/common/orpc/schemas/project.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { RuntimeConfigSchema } from "./runtime"; import { WorkspaceMCPOverridesSchema } from "./mcp"; -import { WorkspaceAISettingsSchema } from "./workspaceAiSettings"; +import { WorkspaceAISettingsByModeSchema, WorkspaceAISettingsSchema } from "./workspaceAiSettings"; const ThinkingLevelSchema = z.enum(["off", "low", "medium", "high", "xhigh"]); @@ -26,6 +26,9 @@ export const WorkspaceConfigSchema = z.object({ runtimeConfig: RuntimeConfigSchema.optional().meta({ description: "Runtime configuration (local vs SSH) - optional, defaults to local", }), + aiSettingsByMode: WorkspaceAISettingsByModeSchema.optional().meta({ + description: "Per-mode workspace-scoped AI settings (plan/exec)", + }), aiSettings: WorkspaceAISettingsSchema.optional().meta({ description: "Workspace-scoped AI settings (model + thinking level)", }), diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index c7db956103..bd2f428de9 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { AgentModeSchema } from "../../types/mode"; import { ChatUsageDisplaySchema } from "./chatStats"; import { StreamErrorTypeSchema } from "./errors"; import { @@ -43,7 +44,7 @@ export const StreamStartEventSchema = z.object({ startTime: z.number().meta({ description: "Backend timestamp when stream started (Date.now())", }), - mode: z.string().optional().meta({ + mode: AgentModeSchema.optional().catch(undefined).meta({ description: "Agent mode for this stream", }), }); @@ -372,7 +373,7 @@ export const SendMessageOptionsSchema = z.object({ additionalSystemInstructions: z.string().optional(), maxOutputTokens: z.number().optional(), providerOptions: MuxProviderOptionsSchema.optional(), - mode: z.string().optional(), + mode: AgentModeSchema.optional().catch(undefined), muxMetadata: z.any().optional(), // Black box experiments: ExperimentsSchema.optional(), }); diff --git a/src/common/orpc/schemas/telemetry.ts b/src/common/orpc/schemas/telemetry.ts index 903d03b5ea..f635fcc723 100644 --- a/src/common/orpc/schemas/telemetry.ts +++ b/src/common/orpc/schemas/telemetry.ts @@ -6,6 +6,7 @@ */ import { z } from "zod"; +import { AgentModeSchema } from "../../types/mode"; // Error context enum (matches payload.ts) const ErrorContextSchema = z.enum([ @@ -64,7 +65,7 @@ const WorkspaceSwitchedPropertiesSchema = z.object({ const MessageSentPropertiesSchema = z.object({ workspaceId: z.string(), model: z.string(), - mode: z.string(), + mode: AgentModeSchema.catch("exec"), message_length_b2: z.number(), runtimeType: TelemetryRuntimeTypeSchema, frontendPlatform: FrontendPlatformInfoSchema, @@ -83,7 +84,7 @@ const TelemetryMCPTransportModeSchema = z.enum([ const MCPContextInjectedPropertiesSchema = z.object({ workspaceId: z.string(), model: z.string(), - mode: z.string(), + mode: AgentModeSchema.catch("exec"), runtimeType: TelemetryRuntimeTypeSchema, mcp_server_enabled_count: z.number(), @@ -134,7 +135,7 @@ const StatsTabOpenedPropertiesSchema = z.object({ const StreamTimingComputedPropertiesSchema = z.object({ model: z.string(), - mode: z.string(), + mode: AgentModeSchema.catch("exec"), duration_b2: z.number(), ttft_ms_b2: z.number(), tool_ms_b2: z.number(), diff --git a/src/common/orpc/schemas/workspace.ts b/src/common/orpc/schemas/workspace.ts index d6dfdbb13c..19f47e0700 100644 --- a/src/common/orpc/schemas/workspace.ts +++ b/src/common/orpc/schemas/workspace.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { RuntimeConfigSchema } from "./runtime"; -import { WorkspaceAISettingsSchema } from "./workspaceAiSettings"; +import { WorkspaceAISettingsByModeSchema, WorkspaceAISettingsSchema } from "./workspaceAiSettings"; const ThinkingLevelSchema = z.enum(["off", "low", "medium", "high", "xhigh"]); @@ -26,6 +26,9 @@ export const WorkspaceMetadataSchema = z.object({ description: "ISO 8601 timestamp of when workspace was created (optional for backward compatibility)", }), + aiSettingsByMode: WorkspaceAISettingsByModeSchema.optional().meta({ + description: "Per-mode AI settings (plan/exec) persisted in config", + }), runtimeConfig: RuntimeConfigSchema.meta({ description: "Runtime configuration for this workspace (always set, defaults to local on load)", }), diff --git a/src/common/orpc/schemas/workspaceAiSettings.ts b/src/common/orpc/schemas/workspaceAiSettings.ts index 275c7734ea..8b7695813d 100644 --- a/src/common/orpc/schemas/workspaceAiSettings.ts +++ b/src/common/orpc/schemas/workspaceAiSettings.ts @@ -7,9 +7,23 @@ import { z } from "zod"; * - `model` must be canonical "provider:model" (NOT mux-gateway:provider/model). * - `thinkingLevel` is workspace-scoped (saved per workspace, not per-model). */ + export const WorkspaceAISettingsSchema = z.object({ model: z.string().meta({ description: 'Canonical model id in the form "provider:model"' }), thinkingLevel: z.enum(["off", "low", "medium", "high", "xhigh"]).meta({ description: "Thinking/reasoning effort level", }), }); + +/** + * Per-mode workspace AI overrides. + * + * Notes: + * - Only includes UI modes (plan/exec). Compact is intentionally excluded. + */ +export const WorkspaceAISettingsByModeSchema = z + .object({ + plan: WorkspaceAISettingsSchema.optional(), + exec: WorkspaceAISettingsSchema.optional(), + }) + .strict(); diff --git a/src/common/orpc/schemas/workspaceStats.ts b/src/common/orpc/schemas/workspaceStats.ts index 37e7efb368..221c890161 100644 --- a/src/common/orpc/schemas/workspaceStats.ts +++ b/src/common/orpc/schemas/workspaceStats.ts @@ -1,7 +1,8 @@ import { z } from "zod"; +import { AgentModeSchema } from "../../types/mode"; -// Mode is a string to support any mode value (plan, exec, compact, etc.) -const ModeSchema = z.string(); +// Mode is an enum, but we defensively drop unknown values when replaying old history. +const ModeSchema = AgentModeSchema.optional().catch(undefined); export const TimingAnomalySchema = z.enum([ "negative_duration", @@ -14,7 +15,7 @@ export const TimingAnomalySchema = z.enum([ export const ActiveStreamStatsSchema = z.object({ messageId: z.string(), model: z.string(), - mode: ModeSchema.optional(), + mode: ModeSchema, elapsedMs: z.number(), ttftMs: z.number().nullable(), @@ -37,7 +38,7 @@ export const ActiveStreamStatsSchema = z.object({ export const CompletedStreamStatsSchema = z.object({ messageId: z.string(), model: z.string(), - mode: ModeSchema.optional(), + mode: ModeSchema, totalDurationMs: z.number(), ttftMs: z.number().nullable(), @@ -54,7 +55,7 @@ export const CompletedStreamStatsSchema = z.object({ export const ModelTimingStatsSchema = z.object({ model: z.string(), - mode: ModeSchema.optional(), + mode: ModeSchema, totalDurationMs: z.number(), totalToolExecutionMs: z.number(), diff --git a/src/common/telemetry/payload.ts b/src/common/telemetry/payload.ts index ae188504e5..f2865cef86 100644 --- a/src/common/telemetry/payload.ts +++ b/src/common/telemetry/payload.ts @@ -20,6 +20,8 @@ * code only needs to provide event-specific properties. */ +import type { AgentMode } from "@/common/types/mode"; + /** * Base properties included with all telemetry events * These are added by the backend, not the frontend @@ -97,7 +99,7 @@ export interface MessageSentPayload { /** Full model identifier (e.g., 'anthropic/claude-3-5-sonnet-20241022') */ model: string; /** UI mode (e.g., 'plan', 'exec', 'edit') */ - mode: string; + mode: AgentMode; /** Message length rounded to nearest power of 2 (e.g., 128, 256, 512, 1024) */ message_length_b2: number; /** Runtime type for the workspace */ @@ -119,7 +121,7 @@ export interface MCPContextInjectedPayload { /** Full model identifier */ model: string; /** UI mode */ - mode: string; + mode: AgentMode; /** Runtime type for the workspace */ runtimeType: TelemetryRuntimeType; @@ -196,7 +198,7 @@ export interface StatsTabOpenedPayload { */ export interface StreamTimingComputedPayload { model: string; - mode: string; + mode: AgentMode; duration_b2: number; ttft_ms_b2: number; tool_ms_b2: number; diff --git a/src/common/telemetry/tracking.ts b/src/common/telemetry/tracking.ts index fa0c44e446..2cddba8b6a 100644 --- a/src/common/telemetry/tracking.ts +++ b/src/common/telemetry/tracking.ts @@ -8,6 +8,7 @@ import { trackEvent } from "./client"; import { roundToBase2 } from "./utils"; +import type { AgentMode } from "@/common/types/mode"; import type { TelemetryRuntimeType, TelemetryThinkingLevel, @@ -67,7 +68,7 @@ export function trackWorkspaceSwitched(fromWorkspaceId: string, toWorkspaceId: s export function trackMessageSent( workspaceId: string, model: string, - mode: string, + mode: AgentMode, messageLength: number, runtimeType: TelemetryRuntimeType, thinkingLevel: TelemetryThinkingLevel diff --git a/src/common/types/message.ts b/src/common/types/message.ts index 996a24eb02..c4f1f4af39 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -3,6 +3,7 @@ import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import type { StreamErrorType } from "./errors"; import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; import type { ImagePart, MuxToolPartSchema } from "@/common/orpc/schemas"; +import type { AgentMode } from "@/common/types/mode"; import type { z } from "zod"; import { type ReviewNoteData, formatReviewForModel } from "./review"; @@ -126,7 +127,7 @@ export interface MuxMetadata { // Readers should use helper: isCompacted = compacted !== undefined && compacted !== false compacted?: "user" | "idle" | boolean; toolPolicy?: ToolPolicy; // Tool policy active when this message was sent (user messages only) - mode?: string; // The mode active when this message was sent (assistant messages only) - plan/exec today, custom modes in future + mode?: AgentMode; // The mode active when this message was sent (assistant messages only) cmuxMetadata?: MuxFrontendMetadata; // Frontend-defined metadata, backend treats as black-box muxMetadata?: MuxFrontendMetadata; // Frontend-defined metadata, backend treats as black-box } diff --git a/src/common/types/mode.ts b/src/common/types/mode.ts index 85fb8d7980..95fbd4b9d2 100644 --- a/src/common/types/mode.ts +++ b/src/common/types/mode.ts @@ -4,5 +4,16 @@ import { z } from "zod"; * UI Mode types */ -export const UIModeSchema = z.enum(["plan", "exec"]); +export const UI_MODE_VALUES = ["plan", "exec"] as const; +export const UIModeSchema = z.enum(UI_MODE_VALUES); export type UIMode = z.infer; + +/** + * Agent mode types + * + * Includes non-UI modes like "compact" used for history compaction. + */ + +export const AGENT_MODE_VALUES = [...UI_MODE_VALUES, "compact"] as const; +export const AgentModeSchema = z.enum(AGENT_MODE_VALUES); +export type AgentMode = z.infer; diff --git a/src/common/types/modeAiDefaults.ts b/src/common/types/modeAiDefaults.ts new file mode 100644 index 0000000000..745fb13f12 --- /dev/null +++ b/src/common/types/modeAiDefaults.ts @@ -0,0 +1,41 @@ +import { AGENT_MODE_VALUES, type AgentMode } from "./mode"; +import { coerceThinkingLevel, type ThinkingLevel } from "./thinking"; + +export interface ModeAiDefaultsEntry { + modelString?: string; + thinkingLevel?: ThinkingLevel; +} + +export type ModeAiDefaults = Partial>; + +const AGENT_MODE_SET = new Set(AGENT_MODE_VALUES); + +export function normalizeModeAiDefaults(raw: unknown): ModeAiDefaults { + const record = raw && typeof raw === "object" ? (raw as Record) : ({} as const); + + const result: ModeAiDefaults = {}; + + for (const [modeRaw, entryRaw] of Object.entries(record)) { + const mode = modeRaw.trim().toLowerCase(); + if (!mode) continue; + if (!AGENT_MODE_SET.has(mode)) continue; + if (!entryRaw || typeof entryRaw !== "object") continue; + + const entry = entryRaw as Record; + + const modelString = + typeof entry.modelString === "string" && entry.modelString.trim().length > 0 + ? entry.modelString.trim() + : undefined; + + const thinkingLevel = coerceThinkingLevel(entry.thinkingLevel); + + if (!modelString && !thinkingLevel) { + continue; + } + + result[mode as AgentMode] = { modelString, thinkingLevel }; + } + + return result; +} diff --git a/src/common/types/project.ts b/src/common/types/project.ts index 87b72fba70..418aacec46 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -6,6 +6,7 @@ import type { z } from "zod"; import type { ProjectConfigSchema, WorkspaceConfigSchema } from "../orpc/schemas"; import type { TaskSettings, SubagentAiDefaults } from "./tasks"; +import type { ModeAiDefaults } from "./modeAiDefaults"; export type Workspace = z.infer; @@ -44,4 +45,6 @@ export interface ProjectsConfig { taskSettings?: TaskSettings; /** Per-subagent default model + thinking overrides. Missing values inherit from the parent workspace. */ subagentAiDefaults?: SubagentAiDefaults; + /** Default model + thinking overrides per mode (plan/exec/compact). */ + modeAiDefaults?: ModeAiDefaults; } diff --git a/src/node/config.ts b/src/node/config.ts index 84cf38cd7b..cefe599e0a 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -17,6 +17,7 @@ import { normalizeSubagentAiDefaults, normalizeTaskSettings, } from "@/common/types/tasks"; +import { normalizeModeAiDefaults } from "@/common/types/modeAiDefaults"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility"; import { getMuxHome } from "@/common/constants/paths"; @@ -97,6 +98,7 @@ export class Config { featureFlagOverrides?: Record; taskSettings?: unknown; subagentAiDefaults?: unknown; + modeAiDefaults?: unknown; }; // Config is stored as array of [path, config] pairs @@ -119,6 +121,7 @@ export class Config { viewedSplashScreens: parsed.viewedSplashScreens, taskSettings: normalizeTaskSettings(parsed.taskSettings), subagentAiDefaults: normalizeSubagentAiDefaults(parsed.subagentAiDefaults), + modeAiDefaults: normalizeModeAiDefaults(parsed.modeAiDefaults), featureFlagOverrides: parsed.featureFlagOverrides, }; } @@ -132,6 +135,7 @@ export class Config { projects: new Map(), taskSettings: DEFAULT_TASK_SETTINGS, subagentAiDefaults: {}, + modeAiDefaults: {}, }; } @@ -151,6 +155,7 @@ export class Config { featureFlagOverrides?: ProjectsConfig["featureFlagOverrides"]; taskSettings?: ProjectsConfig["taskSettings"]; subagentAiDefaults?: ProjectsConfig["subagentAiDefaults"]; + modeAiDefaults?: ProjectsConfig["modeAiDefaults"]; } = { projects: Array.from(config.projects.entries()), taskSettings: config.taskSettings ?? DEFAULT_TASK_SETTINGS, @@ -179,6 +184,9 @@ export class Config { if (config.viewedSplashScreens) { data.viewedSplashScreens = config.viewedSplashScreens; } + if (config.modeAiDefaults && Object.keys(config.modeAiDefaults).length > 0) { + data.modeAiDefaults = config.modeAiDefaults; + } if (config.subagentAiDefaults && Object.keys(config.subagentAiDefaults).length > 0) { data.subagentAiDefaults = config.subagentAiDefaults; } @@ -406,6 +414,7 @@ export class Config { // GUARANTEE: All workspaces must have runtimeConfig (apply default if missing) runtimeConfig: workspace.runtimeConfig ?? DEFAULT_RUNTIME_CONFIG, aiSettings: workspace.aiSettings, + aiSettingsByMode: workspace.aiSettingsByMode, parentWorkspaceId: workspace.parentWorkspaceId, agentType: workspace.agentType, taskStatus: workspace.taskStatus, @@ -456,6 +465,7 @@ export class Config { metadata.runtimeConfig ??= DEFAULT_RUNTIME_CONFIG; // Preserve any config-only fields that may not exist in legacy metadata.json + metadata.aiSettingsByMode ??= workspace.aiSettingsByMode; metadata.aiSettings ??= workspace.aiSettings; // Preserve tree/task metadata when present in config (metadata.json won't have it) @@ -494,6 +504,7 @@ export class Config { // GUARANTEE: All workspaces must have runtimeConfig runtimeConfig: DEFAULT_RUNTIME_CONFIG, aiSettings: workspace.aiSettings, + aiSettingsByMode: workspace.aiSettingsByMode, parentWorkspaceId: workspace.parentWorkspaceId, agentType: workspace.agentType, taskStatus: workspace.taskStatus, @@ -529,6 +540,7 @@ export class Config { // GUARANTEE: All workspaces must have runtimeConfig (even in error cases) runtimeConfig: DEFAULT_RUNTIME_CONFIG, aiSettings: workspace.aiSettings, + aiSettingsByMode: workspace.aiSettingsByMode, parentWorkspaceId: workspace.parentWorkspaceId, agentType: workspace.agentType, taskStatus: workspace.taskStatus, diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index d21075d0ec..49d83ac223 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -20,6 +20,7 @@ import { readPlanFile } from "@/node/utils/runtime/helpers"; import { secretsToRecord } from "@/common/types/secrets"; import { roundToBase2 } from "@/common/telemetry/utils"; import { createAsyncEventQueue } from "@/common/utils/asyncEventIterator"; +import { normalizeModeAiDefaults } from "@/common/types/modeAiDefaults"; import { DEFAULT_TASK_SETTINGS, normalizeSubagentAiDefaults, @@ -251,8 +252,22 @@ export const router = (authToken?: string) => { return { taskSettings: config.taskSettings ?? DEFAULT_TASK_SETTINGS, subagentAiDefaults: config.subagentAiDefaults ?? {}, + modeAiDefaults: config.modeAiDefaults ?? {}, }; }), + updateModeAiDefaults: t + .input(schemas.config.updateModeAiDefaults.input) + .output(schemas.config.updateModeAiDefaults.output) + .handler(async ({ context, input }) => { + await context.config.editConfig((config) => { + const normalizedDefaults = normalizeModeAiDefaults(input.modeAiDefaults); + return { + ...config, + modeAiDefaults: + Object.keys(normalizedDefaults).length > 0 ? normalizedDefaults : undefined, + }; + }); + }), saveConfig: t .input(schemas.config.saveConfig.input) .output(schemas.config.saveConfig.output) @@ -752,6 +767,16 @@ export const router = (authToken?: string) => { .handler(async ({ context, input }) => { return context.workspaceService.rename(input.workspaceId, input.newName); }), + updateModeAISettings: t + .input(schemas.workspace.updateModeAISettings.input) + .output(schemas.workspace.updateModeAISettings.output) + .handler(async ({ context, input }) => { + return context.workspaceService.updateModeAISettings( + input.workspaceId, + input.mode, + input.aiSettings + ); + }), updateTitle: t .input(schemas.workspace.updateTitle.input) .output(schemas.workspace.updateTitle.output) diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 125da82186..38f508c9fd 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -70,7 +70,7 @@ import { MockScenarioPlayer } from "./mock/mockScenarioPlayer"; import { EnvHttpProxyAgent, type Dispatcher } from "undici"; import { getPlanFilePath } from "@/common/utils/planStorage"; import { getPlanModeInstruction } from "@/common/utils/ui/modeUtils"; -import type { UIMode } from "@/common/types/mode"; +import type { AgentMode, UIMode } from "@/common/types/mode"; import { MUX_APP_ATTRIBUTION_TITLE, MUX_APP_ATTRIBUTION_URL } from "@/constants/appAttribution"; import { readPlanFile } from "@/node/utils/runtime/helpers"; import { getAgentPreset } from "@/node/services/agentPresets"; @@ -1006,7 +1006,7 @@ export class AIService extends EventEmitter { additionalSystemInstructions?: string, maxOutputTokens?: number, muxProviderOptions?: MuxProviderOptions, - mode?: string, + mode?: AgentMode, recordFileState?: (filePath: string, state: FileState) => void, changedFileAttachments?: EditedFileAttachment[], postCompactionAttachments?: PostCompactionAttachment[] | null, @@ -1455,7 +1455,7 @@ export class AIService extends EventEmitter { properties: { workspaceId, model: modelString, - mode: mode ?? uiMode ?? "unknown", + mode: mode ?? uiMode ?? "exec", runtimeType: getRuntimeTypeForTelemetry(metadata.runtimeConfig), mcp_server_enabled_count: effectiveMcpStats.enabledServerCount, diff --git a/src/node/services/sessionTimingService.ts b/src/node/services/sessionTimingService.ts index 6ebcdf510a..3dc7504576 100644 --- a/src/node/services/sessionTimingService.ts +++ b/src/node/services/sessionTimingService.ts @@ -6,6 +6,7 @@ import writeFileAtomic from "write-file-atomic"; import type { Config } from "@/node/config"; import { workspaceFileLocks } from "@/node/utils/concurrency/workspaceFileLocks"; import { normalizeGatewayModel } from "@/common/utils/ai/models"; +import type { AgentMode } from "@/common/types/mode"; import { ActiveStreamStatsSchema, CompletedStreamStatsSchema, @@ -48,7 +49,7 @@ interface ActiveStreamState { workspaceId: string; messageId: string; model: string; - mode?: string; + mode?: AgentMode; startTimeMs: number; firstTokenTimeMs: number | null; @@ -64,7 +65,7 @@ interface ActiveStreamState { lastEventTimestampMs: number; } -function getModelKey(model: string, mode: string | undefined): string { +function getModelKey(model: string, mode: AgentMode | undefined): string { return mode ? `${model}:${mode}` : model; } @@ -354,7 +355,7 @@ export class SessionTimingService { event: "stream_timing_computed", properties: { model: completed.model, - mode: completed.mode ?? "unknown", + mode: completed.mode ?? "exec", duration_b2: roundToBase2(durationSecs), ttft_ms_b2: completed.ttftMs !== null ? roundToBase2(completed.ttftMs) : 0, tool_ms_b2: roundToBase2(completed.toolExecutionMs), diff --git a/src/node/services/tools/code_execution.integration.test.ts b/src/node/services/tools/code_execution.integration.test.ts index 52ff96d6c6..c0d259db4f 100644 --- a/src/node/services/tools/code_execution.integration.test.ts +++ b/src/node/services/tools/code_execution.integration.test.ts @@ -42,84 +42,92 @@ describe("code_execution integration tests", () => { }); describe("file_read through sandbox", () => { - it("reads a real file via mux.file_read()", async () => { - // Create a real file - const testContent = "hello from integration test\nline two\nline three"; - await fs.writeFile(path.join(testDir, "test.txt"), testContent); - - // Create real file_read tool - const fileReadTool = createFileReadTool(toolConfig); - const tools: Record = { file_read: fileReadTool }; - - // Track events - const events: PTCEvent[] = []; - const codeExecutionTool = await createCodeExecutionTool( - runtimeFactory, - new ToolBridge(tools), - (e) => events.push(e) - ); - - // Execute code that reads the file - const code = ` + it( + "reads a real file via mux.file_read()", + async () => { + // Create a real file + const testContent = "hello from integration test\nline two\nline three"; + await fs.writeFile(path.join(testDir, "test.txt"), testContent); + + // Create real file_read tool + const fileReadTool = createFileReadTool(toolConfig); + const tools: Record = { file_read: fileReadTool }; + + // Track events + const events: PTCEvent[] = []; + const codeExecutionTool = await createCodeExecutionTool( + runtimeFactory, + new ToolBridge(tools), + (e) => events.push(e) + ); + + // Execute code that reads the file + const code = ` const result = mux.file_read({ filePath: "test.txt" }); return result; `; - const result = (await codeExecutionTool.execute!( - { code }, - mockToolCallOptions - )) as PTCExecutionResult; + const result = (await codeExecutionTool.execute!( + { code }, + mockToolCallOptions + )) as PTCExecutionResult; - // Verify the result contains the file content - expect(result).toBeDefined(); - expect(result.success).toBe(true); + // Verify the result contains the file content + expect(result).toBeDefined(); + expect(result.success).toBe(true); - // The result should be the file_read response - const fileReadResult = result.result as { - success: boolean; - content?: string; - lines_read?: number; - }; - expect(fileReadResult.success).toBe(true); - expect(fileReadResult.content).toContain("hello from integration test"); - expect(fileReadResult.lines_read).toBe(3); - - // Verify tool call event was emitted (toolName includes mux. prefix from registerObject) - const toolCallEndEvents = events.filter( - (e): e is PTCToolCallEndEvent => e.type === "tool-call-end" - ); - expect(toolCallEndEvents.length).toBe(1); - expect(toolCallEndEvents[0].toolName).toBe("mux.file_read"); - expect(toolCallEndEvents[0].error).toBeUndefined(); - }); - - it("handles file not found gracefully", async () => { - const fileReadTool = createFileReadTool(toolConfig); - const tools: Record = { file_read: fileReadTool }; - - const codeExecutionTool = await createCodeExecutionTool( - runtimeFactory, - new ToolBridge(tools) - ); - - const code = ` + // The result should be the file_read response + const fileReadResult = result.result as { + success: boolean; + content?: string; + lines_read?: number; + }; + expect(fileReadResult.success).toBe(true); + expect(fileReadResult.content).toContain("hello from integration test"); + expect(fileReadResult.lines_read).toBe(3); + + // Verify tool call event was emitted (toolName includes mux. prefix from registerObject) + const toolCallEndEvents = events.filter( + (e): e is PTCToolCallEndEvent => e.type === "tool-call-end" + ); + expect(toolCallEndEvents.length).toBe(1); + expect(toolCallEndEvents[0].toolName).toBe("mux.file_read"); + expect(toolCallEndEvents[0].error).toBeUndefined(); + }, + { timeout: 20_000 } + ); + + it( + "handles file not found gracefully", + async () => { + const fileReadTool = createFileReadTool(toolConfig); + const tools: Record = { file_read: fileReadTool }; + + const codeExecutionTool = await createCodeExecutionTool( + runtimeFactory, + new ToolBridge(tools) + ); + + const code = ` const result = mux.file_read({ filePath: "nonexistent.txt" }); return result; `; - const result = (await codeExecutionTool.execute!( - { code }, - mockToolCallOptions - )) as PTCExecutionResult; - - expect(result.success).toBe(true); - - // file_read returns success: false for missing files, not an exception - const fileReadResult = result.result as { success: boolean; error?: string }; - expect(fileReadResult.success).toBe(false); - // Error contains ENOENT or stat failure message - expect(fileReadResult.error).toMatch(/ENOENT|stat/i); - }); + const result = (await codeExecutionTool.execute!( + { code }, + mockToolCallOptions + )) as PTCExecutionResult; + + expect(result.success).toBe(true); + + // file_read returns success: false for missing files, not an exception + const fileReadResult = result.result as { success: boolean; error?: string }; + expect(fileReadResult.success).toBe(false); + // Error contains ENOENT or stat failure message + expect(fileReadResult.error).toMatch(/ENOENT|stat/i); + }, + { timeout: 20_000 } + ); }); describe("bash through sandbox", () => { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 7c03a579f5..c245c219ac 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -44,6 +44,7 @@ import { AskUserQuestionToolArgsSchema, AskUserQuestionToolResultSchema, } from "@/common/utils/tools/toolDefinitions"; +import type { UIMode } from "@/common/types/mode"; import type { MuxMessage } from "@/common/types/message"; import type { RuntimeConfig } from "@/common/types/runtime"; import { hasSrcBaseDir, getSrcBaseDir, isSSHRuntime } from "@/common/types/runtime"; @@ -1071,16 +1072,23 @@ export class WorkspaceService extends EventEmitter { options: SendMessageOptions | undefined, context: "send" | "resume" ): Promise { - // Skip for compaction - it may use a different model and shouldn't override user preference + // Skip for compaction - it may use a different model and shouldn't override user preference. const isCompaction = options?.mode === "compact"; if (isCompaction) return; const extractedSettings = this.extractWorkspaceAISettingsFromSendOptions(options); if (!extractedSettings) return; - const persistResult = await this.persistWorkspaceAISettings(workspaceId, extractedSettings, { - emitMetadata: false, - }); + const mode: UIMode = options?.mode === "plan" ? "plan" : "exec"; + + const persistResult = await this.persistWorkspaceAISettingsForMode( + workspaceId, + mode, + extractedSettings, + { + emitMetadata: false, + } + ); if (!persistResult.success) { log.debug(`Failed to persist workspace AI settings from ${context} options`, { workspaceId, @@ -1089,8 +1097,9 @@ export class WorkspaceService extends EventEmitter { } } - private async persistWorkspaceAISettings( + private async persistWorkspaceAISettingsForMode( workspaceId: string, + mode: UIMode, aiSettings: WorkspaceAISettings, options?: { emitMetadata?: boolean } ): Promise> { @@ -1114,14 +1123,82 @@ export class WorkspaceService extends EventEmitter { return Err("Workspace not found"); } - const prev = workspaceEntryWithFallback.aiSettings; + const prev = workspaceEntryWithFallback.aiSettingsByMode?.[mode]; const changed = prev?.model !== aiSettings.model || prev?.thinkingLevel !== aiSettings.thinkingLevel; if (!changed) { return Ok(false); } + workspaceEntryWithFallback.aiSettingsByMode = { + ...(workspaceEntryWithFallback.aiSettingsByMode ?? {}), + [mode]: aiSettings, + }; + + // Keep the legacy field in sync for older clients (prefer exec). + workspaceEntryWithFallback.aiSettings = + workspaceEntryWithFallback.aiSettingsByMode.exec ?? + workspaceEntryWithFallback.aiSettingsByMode.plan ?? + aiSettings; + + await this.config.saveConfig(config); + + if (options?.emitMetadata !== false) { + const allMetadata = await this.config.getAllWorkspaceMetadata(); + const updatedMetadata = allMetadata.find((m) => m.id === workspaceId) ?? null; + + const session = this.sessions.get(workspaceId); + if (session) { + session.emitMetadata(updatedMetadata); + } else { + this.emit("metadata", { workspaceId, metadata: updatedMetadata }); + } + } + + return Ok(true); + } + + private async persistWorkspaceAISettings( + workspaceId: string, + aiSettings: WorkspaceAISettings, + options?: { emitMetadata?: boolean } + ): Promise> { + const found = this.config.findWorkspace(workspaceId); + if (!found) { + return Err("Workspace not found"); + } + + const { projectPath, workspacePath } = found; + + const config = this.config.loadConfigOrDefault(); + const projectConfig = config.projects.get(projectPath); + if (!projectConfig) { + return Err(`Project not found: ${projectPath}`); + } + + const workspaceEntry = projectConfig.workspaces.find((w) => w.id === workspaceId); + const workspaceEntryWithFallback = + workspaceEntry ?? projectConfig.workspaces.find((w) => w.path === workspacePath); + if (!workspaceEntryWithFallback) { + return Err("Workspace not found"); + } + + const prevLegacy = workspaceEntryWithFallback.aiSettings; + const prevByMode = workspaceEntryWithFallback.aiSettingsByMode; + + const changed = + prevLegacy?.model !== aiSettings.model || + prevLegacy?.thinkingLevel !== aiSettings.thinkingLevel || + prevByMode?.plan?.model !== aiSettings.model || + prevByMode?.plan?.thinkingLevel !== aiSettings.thinkingLevel || + prevByMode?.exec?.model !== aiSettings.model || + prevByMode?.exec?.thinkingLevel !== aiSettings.thinkingLevel; + if (!changed) { + return Ok(false); + } + workspaceEntryWithFallback.aiSettings = aiSettings; + workspaceEntryWithFallback.aiSettingsByMode = { plan: aiSettings, exec: aiSettings }; await this.config.saveConfig(config); if (options?.emitMetadata !== false) { @@ -1163,6 +1240,36 @@ export class WorkspaceService extends EventEmitter { } } + async updateModeAISettings( + workspaceId: string, + mode: UIMode, + aiSettings: WorkspaceAISettings + ): Promise> { + try { + const normalized = this.normalizeWorkspaceAISettings(aiSettings); + if (!normalized.success) { + return Err(normalized.error); + } + + const persistResult = await this.persistWorkspaceAISettingsForMode( + workspaceId, + mode, + normalized.data, + { + emitMetadata: true, + } + ); + if (!persistResult.success) { + return Err(persistResult.error); + } + + return Ok(undefined); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(`Failed to update workspace AI settings: ${message}`); + } + } + async fork( sourceWorkspaceId: string, newName: string From 83db64d3ed377b09e3d2961bd2bee06e62cf604c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Dec 2025 13:42:28 +0100 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=A4=96=20fix:=20preserve=20existing?= =?UTF-8?q?=20model/thinking=20in=20mode=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I1a5ba1d32ff0a15abae85af904d89074e36be101 Signed-off-by: Thomas Kosiewski --- .../components/WorkspaceModeAISync.tsx | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/src/browser/components/WorkspaceModeAISync.tsx b/src/browser/components/WorkspaceModeAISync.tsx index 7180f8d735..fc52ee2af0 100644 --- a/src/browser/components/WorkspaceModeAISync.tsx +++ b/src/browser/components/WorkspaceModeAISync.tsx @@ -1,6 +1,10 @@ import { useEffect } from "react"; import { useMode } from "@/browser/contexts/ModeContext"; -import { updatePersistedState, usePersistedState } from "@/browser/hooks/usePersistedState"; +import { + readPersistedState, + updatePersistedState, + usePersistedState, +} from "@/browser/hooks/usePersistedState"; import { getModelKey, getThinkingLevelKey, @@ -35,37 +39,34 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null { useEffect(() => { const fallbackModel = getDefaultModel(); + const modelKey = getModelKey(workspaceId); + const thinkingKey = getThinkingLevelKey(workspaceId); - const candidateModel = workspaceByMode[mode]?.model ?? modeAiDefaults[mode]?.modelString; + const existingModel = readPersistedState(modelKey, fallbackModel); + const candidateModel = + workspaceByMode[mode]?.model ?? modeAiDefaults[mode]?.modelString ?? existingModel; const resolvedModel = typeof candidateModel === "string" && candidateModel.trim().length > 0 ? candidateModel : fallbackModel; + const existingThinking = readPersistedState(thinkingKey, "off"); const candidateThinking = - workspaceByMode[mode]?.thinkingLevel ?? modeAiDefaults[mode]?.thinkingLevel ?? "off"; + workspaceByMode[mode]?.thinkingLevel ?? + modeAiDefaults[mode]?.thinkingLevel ?? + existingThinking ?? + "off"; const resolvedThinking = coerceThinkingLevel(candidateThinking) ?? "off"; const effectiveThinking = enforceThinkingPolicy(resolvedModel, resolvedThinking); - const modelKey = getModelKey(workspaceId); - const thinkingKey = getThinkingLevelKey(workspaceId); - - updatePersistedState( - modelKey, - (prev) => { - return prev === resolvedModel ? prev : resolvedModel; - }, - fallbackModel - ); + if (existingModel !== resolvedModel) { + updatePersistedState(modelKey, resolvedModel); + } - updatePersistedState( - thinkingKey, - (prev) => { - return prev === effectiveThinking ? prev : effectiveThinking; - }, - "off" - ); + if (existingThinking !== effectiveThinking) { + updatePersistedState(thinkingKey, effectiveThinking); + } }, [mode, modeAiDefaults, workspaceByMode, workspaceId]); return null; From 975b09b1863ed0e0f94f12e3e94a52c92a49b187 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Dec 2025 16:27:05 +0100 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=A4=96=20tests:=20add=20Settings=20Mo?= =?UTF-8?q?des=20story=20for=20Chromatic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I8d55fb7ca4c3173706e390846b77416f7540af59 Signed-off-by: Thomas Kosiewski --- .storybook/mocks/orpc.ts | 14 +++++++- src/browser/stories/App.settings.stories.tsx | 34 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/.storybook/mocks/orpc.ts b/.storybook/mocks/orpc.ts index db27e94eae..7e52732465 100644 --- a/.storybook/mocks/orpc.ts +++ b/.storybook/mocks/orpc.ts @@ -21,6 +21,10 @@ import { type SubagentAiDefaults, type TaskSettings, } from "@/common/types/tasks"; +import { + normalizeModeAiDefaults, + type ModeAiDefaults, +} from "@/common/types/modeAiDefaults"; import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue"; import { isWorkspaceArchived } from "@/common/utils/archive"; @@ -57,6 +61,8 @@ export interface MockORPCClientOptions { workspaces?: FrontendWorkspaceMetadata[]; /** Initial task settings for config.getConfig (e.g., Settings → Tasks section) */ taskSettings?: Partial; + /** Initial mode AI defaults for config.getConfig (e.g., Settings → Modes section) */ + modeAiDefaults?: ModeAiDefaults; /** Initial per-subagent AI defaults for config.getConfig (e.g., Settings → Tasks section) */ subagentAiDefaults?: SubagentAiDefaults; /** Per-workspace chat callback. Return messages to emit, or use the callback for streaming. */ @@ -140,6 +146,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl mcpOverrides = new Map(), mcpTestResults = new Map(), taskSettings: initialTaskSettings, + modeAiDefaults: initialModeAiDefaults, subagentAiDefaults: initialSubagentAiDefaults, } = options; @@ -158,6 +165,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl }; const workspaceMap = new Map(workspaces.map((w) => [w.id, w])); + let modeAiDefaults = normalizeModeAiDefaults(initialModeAiDefaults ?? {}); let taskSettings = normalizeTaskSettings(initialTaskSettings ?? DEFAULT_TASK_SETTINGS); let subagentAiDefaults = normalizeSubagentAiDefaults(initialSubagentAiDefaults ?? {}); @@ -193,7 +201,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl setSshHost: async () => undefined, }, config: { - getConfig: async () => ({ taskSettings, subagentAiDefaults }), + getConfig: async () => ({ taskSettings, subagentAiDefaults, modeAiDefaults }), saveConfig: async (input: { taskSettings: unknown; subagentAiDefaults?: unknown }) => { taskSettings = normalizeTaskSettings(input.taskSettings); if (input.subagentAiDefaults !== undefined) { @@ -201,6 +209,10 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl } return undefined; }, + updateModeAiDefaults: async (input: { modeAiDefaults: unknown }) => { + modeAiDefaults = normalizeModeAiDefaults(input.modeAiDefaults); + return undefined; + }, }, providers: { list: async () => providersList, diff --git a/src/browser/stories/App.settings.stories.tsx b/src/browser/stories/App.settings.stories.tsx index e41321aef4..67f0d0416d 100644 --- a/src/browser/stories/App.settings.stories.tsx +++ b/src/browser/stories/App.settings.stories.tsx @@ -3,8 +3,10 @@ * * Shows different sections and states of the Settings modal: * - General (theme toggle) + * - Agents (task parallelism / nesting) * - Providers (API key configuration) * - Models (custom model management) + * - Modes (per-mode default model / reasoning) * - Experiments * * NOTE: Projects/MCP stories live in App.mcp.stories.tsx @@ -19,6 +21,7 @@ import { selectWorkspace } from "./storyHelpers"; import { createMockORPCClient } from "../../../.storybook/mocks/orpc"; import { within, userEvent, waitFor } from "@storybook/test"; import { getExperimentKey, EXPERIMENT_IDS } from "@/common/constants/experiments"; +import type { ModeAiDefaults } from "@/common/types/modeAiDefaults"; import type { TaskSettings } from "@/common/types/tasks"; export default { @@ -34,6 +37,7 @@ export default { function setupSettingsStory(options: { providersConfig?: Record; providersList?: string[]; + modeAiDefaults?: ModeAiDefaults; taskSettings?: Partial; /** Pre-set experiment states in localStorage before render */ experiments?: Partial>; @@ -54,6 +58,7 @@ function setupSettingsStory(options: { projects: groupWorkspacesByProject(workspaces), workspaces, providersConfig: options.providersConfig ?? {}, + modeAiDefaults: options.modeAiDefaults, providersList: options.providersList ?? ["anthropic", "openai", "xai"], taskSettings: options.taskSettings, }); @@ -229,6 +234,35 @@ export const ModelsConfigured: AppStory = { }, }; +/** Modes section - global default model/reasoning per mode */ +export const Modes: AppStory = { + render: () => ( + + setupSettingsStory({ + modeAiDefaults: { + plan: { modelString: "anthropic:claude-sonnet-4-5", thinkingLevel: "medium" }, + exec: { modelString: "openai:gpt-5.2", thinkingLevel: "xhigh" }, + compact: { modelString: "openai:gpt-5.2-pro", thinkingLevel: "high" }, + }, + }) + } + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await openSettingsToSection(canvasElement, "modes"); + + const body = within(canvasElement.ownerDocument.body); + const dialog = await body.findByRole("dialog"); + const modal = within(dialog); + + await modal.findByText(/Mode Defaults/i); + await modal.findByText(/^Plan$/i); + await modal.findByText(/^Exec$/i); + await modal.findByText(/^Compact$/i); + }, +}; + /** Experiments section - shows available experiments */ export const Experiments: AppStory = { render: () => setupSettingsStory({})} />, From c21b71f38c8949ebac275e1ea4a6e44df1540c67 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Dec 2025 22:53:25 +0100 Subject: [PATCH 4/7] fix: apply mode defaults in creation chat input Change-Id: Ic00fe3e1cd68818771ac324787461a0427fcfb05 Signed-off-by: Thomas Kosiewski --- src/browser/components/ChatInput/index.tsx | 67 ++++++++++++++++------ 1 file changed, 50 insertions(+), 17 deletions(-) diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index ea417f5c0c..c953140174 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -13,7 +13,11 @@ import type { Toast } from "../ChatInputToast"; import { ChatInputToast } from "../ChatInputToast"; import { createCommandToast, createErrorToast } from "../ChatInputToasts"; import { parseCommand } from "@/browser/utils/slashCommands/parser"; -import { usePersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { + readPersistedState, + usePersistedState, + updatePersistedState, +} from "@/browser/hooks/usePersistedState"; import { useSettings } from "@/browser/contexts/SettingsContext"; import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { useMode } from "@/browser/contexts/ModeContext"; @@ -26,9 +30,11 @@ import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; import { getModelKey, + getThinkingLevelKey, getWorkspaceAISettingsByModeKey, getInputKey, getInputImagesKey, + MODE_AI_DEFAULTS_KEY, VIM_ENABLED_KEY, getProjectScopeId, getPendingScopeId, @@ -74,7 +80,8 @@ import { processImageFiles, } from "@/browser/utils/imageHandling"; -import type { ThinkingLevel } from "@/common/types/thinking"; +import type { ModeAiDefaults } from "@/common/types/modeAiDefaults"; +import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking"; import type { MuxFrontendMetadata } from "@/common/types/message"; import { prepareUserMessageForSend } from "@/common/types/message"; import { MODEL_ABBREVIATION_EXAMPLES } from "@/common/constants/knownModels"; @@ -270,6 +277,14 @@ const ChatInputInner: React.FC = (props) => { defaultModel, setDefaultModel, } = useModelsFromSettings(); + + const [modeAiDefaults] = usePersistedState( + MODE_AI_DEFAULTS_KEY, + {}, + { + listener: true, + } + ); const commandListId = useId(); const telemetry = useTelemetry(); const [vimEnabled, setVimEnabled] = usePersistedState(VIM_ENABLED_KEY, false, { @@ -445,23 +460,41 @@ const ChatInputInner: React.FC = (props) => { const hasReviews = attachedReviews.length > 0; const canSend = (hasTypedText || hasImages || hasReviews) && !disabled && !isSendInFlight; - // When entering creation mode, initialize the project-scoped model to the - // default so previous manual picks don't bleed into new creation flows. - // Only runs once per creation session (not when defaultModel changes, which - // would clobber the user's intentional model selection). - const creationModelInitialized = useRef(null); + const creationProjectPath = variant === "creation" ? props.projectPath : ""; + + // Creation variant: keep the project-scoped model/thinking in sync with global per-mode defaults + // so switching Plan/Exec uses the configured defaults (and respects "inherit" semantics). useEffect(() => { - if (variant === "creation" && defaultModel) { - // Only initialize once per project scope - if (creationModelInitialized.current !== storageKeys.modelKey) { - creationModelInitialized.current = storageKeys.modelKey; - updatePersistedState(storageKeys.modelKey, defaultModel); - } - } else if (variant !== "creation") { - // Reset when leaving creation mode so re-entering triggers initialization - creationModelInitialized.current = null; + if (variant !== "creation") { + return; + } + + const scopeId = getProjectScopeId(creationProjectPath); + const modelKey = getModelKey(scopeId); + const thinkingKey = getThinkingLevelKey(scopeId); + + const fallbackModel = defaultModel; + + const existingModel = readPersistedState(modelKey, fallbackModel); + const candidateModel = modeAiDefaults[mode]?.modelString ?? existingModel; + const resolvedModel = + typeof candidateModel === "string" && candidateModel.trim().length > 0 + ? candidateModel + : fallbackModel; + + const existingThinking = readPersistedState(thinkingKey, "off"); + const candidateThinking = modeAiDefaults[mode]?.thinkingLevel ?? existingThinking ?? "off"; + const resolvedThinking = coerceThinkingLevel(candidateThinking) ?? "off"; + const effectiveThinking = enforceThinkingPolicy(resolvedModel, resolvedThinking); + + if (existingModel !== resolvedModel) { + updatePersistedState(modelKey, resolvedModel); + } + + if (existingThinking !== effectiveThinking) { + updatePersistedState(thinkingKey, effectiveThinking); } - }, [variant, defaultModel, storageKeys.modelKey]); + }, [creationProjectPath, defaultModel, mode, modeAiDefaults, variant]); // Expose ChatInput auto-focus completion for Storybook/tests. const chatInputSectionRef = useRef(null); From 292de463329926c53940031ba79d620186a3a1bd Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 23 Dec 2025 21:09:28 +0100 Subject: [PATCH 5/7] feat: user-defined agents Change-Id: I9daeab5067c65855a32f44c9626b8f855072fe9d Signed-off-by: Thomas Kosiewski --- .storybook/mocks/orpc.ts | 135 ++++- src/browser/components/AgentSelector.tsx | 71 +++ src/browser/components/ChatInput/index.tsx | 6 +- .../ChatInput/useCreationWorkspace.ts | 5 + .../components/Settings/SettingsModal.tsx | 9 +- .../Settings/sections/TasksSection.tsx | 480 +++++++++++++----- src/browser/components/tools/TaskToolCall.tsx | 2 +- src/browser/contexts/AgentContext.tsx | 26 + src/browser/contexts/ModeContext.tsx | 169 +++++- src/browser/contexts/WorkspaceContext.tsx | 13 + src/browser/hooks/useSendMessageOptions.ts | 5 + src/browser/stories/App.settings.stories.tsx | 56 +- .../utils/messages/compactionOptions.ts | 1 + src/browser/utils/messages/sendOptions.ts | 9 + src/common/constants/storage.ts | 8 + src/common/orpc/schemas.ts | 10 + src/common/orpc/schemas/agentDefinition.ts | 100 ++++ src/common/orpc/schemas/api.ts | 62 ++- src/common/orpc/schemas/project.ts | 4 + src/common/orpc/schemas/stream.ts | 1 + src/common/orpc/schemas/telemetry.ts | 1 + src/common/orpc/schemas/workspace.ts | 4 + src/common/telemetry/payload.ts | 4 +- src/common/types/agentAiDefaults.ts | 39 ++ src/common/types/agentDefinition.ts | 18 + src/common/types/project.ts | 7 +- src/common/utils/tools/toolDefinitions.ts | 35 +- src/node/config.ts | 62 ++- src/node/orpc/router.ts | 164 ++++++ .../agentDefinitionsService.test.ts | 71 +++ .../agentDefinitionsService.ts | 312 ++++++++++++ .../builtInAgentDefinitions.ts | 107 ++++ .../parseAgentDefinitionMarkdown.test.ts | 62 +++ .../parseAgentDefinitionMarkdown.ts | 106 ++++ .../agentDefinitions/resolveToolPolicy.ts | 87 ++++ src/node/services/agentSession.ts | 1 + src/node/services/aiService.ts | 167 +++--- src/node/services/systemMessage.ts | 32 +- src/node/services/taskService.ts | 46 +- src/node/services/tools/task.ts | 16 +- src/node/utils/main/markdown.ts | 14 + 41 files changed, 2229 insertions(+), 298 deletions(-) create mode 100644 src/browser/components/AgentSelector.tsx create mode 100644 src/browser/contexts/AgentContext.tsx create mode 100644 src/common/orpc/schemas/agentDefinition.ts create mode 100644 src/common/types/agentAiDefaults.ts create mode 100644 src/common/types/agentDefinition.ts create mode 100644 src/node/services/agentDefinitions/agentDefinitionsService.test.ts create mode 100644 src/node/services/agentDefinitions/agentDefinitionsService.ts create mode 100644 src/node/services/agentDefinitions/builtInAgentDefinitions.ts create mode 100644 src/node/services/agentDefinitions/parseAgentDefinitionMarkdown.test.ts create mode 100644 src/node/services/agentDefinitions/parseAgentDefinitionMarkdown.ts create mode 100644 src/node/services/agentDefinitions/resolveToolPolicy.ts diff --git a/.storybook/mocks/orpc.ts b/.storybook/mocks/orpc.ts index 7e52732465..be6e691612 100644 --- a/.storybook/mocks/orpc.ts +++ b/.storybook/mocks/orpc.ts @@ -4,6 +4,7 @@ * Creates a client that matches the AppRouter interface with configurable mock data. */ import type { APIClient } from "@/browser/contexts/API"; +import type { AgentDefinitionDescriptor, AgentDefinitionPackage } from "@/common/types/agentDefinition"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { ProjectConfig } from "@/node/config"; import type { @@ -25,6 +26,7 @@ import { normalizeModeAiDefaults, type ModeAiDefaults, } from "@/common/types/modeAiDefaults"; +import { normalizeAgentAiDefaults, type AgentAiDefaults } from "@/common/types/agentAiDefaults"; import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue"; import { isWorkspaceArchived } from "@/common/utils/archive"; @@ -63,6 +65,10 @@ export interface MockORPCClientOptions { taskSettings?: Partial; /** Initial mode AI defaults for config.getConfig (e.g., Settings → Modes section) */ modeAiDefaults?: ModeAiDefaults; + /** Initial unified AI defaults for agents (plan/exec/compact + subagents) */ + agentAiDefaults?: AgentAiDefaults; + /** Agent definitions to expose via agents.list */ + agentDefinitions?: AgentDefinitionDescriptor[]; /** Initial per-subagent AI defaults for config.getConfig (e.g., Settings → Tasks section) */ subagentAiDefaults?: SubagentAiDefaults; /** Per-workspace chat callback. Return messages to emit, or use the callback for streaming. */ @@ -148,6 +154,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl taskSettings: initialTaskSettings, modeAiDefaults: initialModeAiDefaults, subagentAiDefaults: initialSubagentAiDefaults, + agentAiDefaults: initialAgentAiDefaults, + agentDefinitions: initialAgentDefinitions, } = options; // Feature flags @@ -165,9 +173,78 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl }; const workspaceMap = new Map(workspaces.map((w) => [w.id, w])); - let modeAiDefaults = normalizeModeAiDefaults(initialModeAiDefaults ?? {}); + + const agentDefinitions: AgentDefinitionDescriptor[] = + initialAgentDefinitions ?? + ([ + { + id: "plan", + scope: "built-in", + name: "Plan", + description: "Create a plan before coding", + uiSelectable: true, + subagentRunnable: false, + policyBase: "plan", + }, + { + id: "exec", + scope: "built-in", + name: "Exec", + description: "Implement changes in the repository", + uiSelectable: true, + subagentRunnable: true, + policyBase: "exec", + }, + { + id: "compact", + scope: "built-in", + name: "Compact", + description: "History compaction (internal)", + uiSelectable: false, + subagentRunnable: false, + policyBase: "compact", + }, + { + id: "explore", + scope: "built-in", + name: "Explore", + description: "Read-only repository exploration", + uiSelectable: false, + subagentRunnable: true, + policyBase: "exec", + }, + ] satisfies AgentDefinitionDescriptor[]); + let taskSettings = normalizeTaskSettings(initialTaskSettings ?? DEFAULT_TASK_SETTINGS); - let subagentAiDefaults = normalizeSubagentAiDefaults(initialSubagentAiDefaults ?? {}); + + let agentAiDefaults = normalizeAgentAiDefaults( + initialAgentAiDefaults ?? + ({ + ...(initialSubagentAiDefaults ?? {}), + ...(initialModeAiDefaults ?? {}), + } as const) + ); + + const deriveModeAiDefaults = () => + normalizeModeAiDefaults({ + plan: agentAiDefaults.plan, + exec: agentAiDefaults.exec, + compact: agentAiDefaults.compact, + }); + + const deriveSubagentAiDefaults = () => { + const raw: Record = {}; + for (const [agentId, entry] of Object.entries(agentAiDefaults)) { + if (agentId === "plan" || agentId === "exec" || agentId === "compact") { + continue; + } + raw[agentId] = entry; + } + return normalizeSubagentAiDefaults(raw); + }; + + let modeAiDefaults = deriveModeAiDefaults(); + let subagentAiDefaults = deriveSubagentAiDefaults(); const mockStats: ChatStats = { consumers: [], @@ -201,19 +278,69 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl setSshHost: async () => undefined, }, config: { - getConfig: async () => ({ taskSettings, subagentAiDefaults, modeAiDefaults }), - saveConfig: async (input: { taskSettings: unknown; subagentAiDefaults?: unknown }) => { + getConfig: async () => ({ taskSettings, agentAiDefaults, subagentAiDefaults, modeAiDefaults }), + saveConfig: async (input: { + taskSettings: unknown; + agentAiDefaults?: unknown; + subagentAiDefaults?: unknown; + }) => { taskSettings = normalizeTaskSettings(input.taskSettings); + + if (input.agentAiDefaults !== undefined) { + agentAiDefaults = normalizeAgentAiDefaults(input.agentAiDefaults); + modeAiDefaults = deriveModeAiDefaults(); + subagentAiDefaults = deriveSubagentAiDefaults(); + } + if (input.subagentAiDefaults !== undefined) { subagentAiDefaults = normalizeSubagentAiDefaults(input.subagentAiDefaults); + + const nextAgentAiDefaults: Record = { ...agentAiDefaults }; + for (const [agentType, entry] of Object.entries(subagentAiDefaults)) { + nextAgentAiDefaults[agentType] = entry; + } + + agentAiDefaults = normalizeAgentAiDefaults(nextAgentAiDefaults); + modeAiDefaults = deriveModeAiDefaults(); } + + return undefined; + }, + updateAgentAiDefaults: async (input: { agentAiDefaults: unknown }) => { + agentAiDefaults = normalizeAgentAiDefaults(input.agentAiDefaults); + modeAiDefaults = deriveModeAiDefaults(); + subagentAiDefaults = deriveSubagentAiDefaults(); return undefined; }, updateModeAiDefaults: async (input: { modeAiDefaults: unknown }) => { modeAiDefaults = normalizeModeAiDefaults(input.modeAiDefaults); + agentAiDefaults = normalizeAgentAiDefaults({ ...agentAiDefaults, ...modeAiDefaults }); + modeAiDefaults = deriveModeAiDefaults(); + subagentAiDefaults = deriveSubagentAiDefaults(); return undefined; }, }, + agents: { + list: async (_input: { workspaceId: string }) => agentDefinitions, + get: async (input: { workspaceId: string; agentId: string }) => { + const descriptor = + agentDefinitions.find((agent) => agent.id === input.agentId) ?? agentDefinitions[0]; + + return { + id: descriptor.id, + scope: descriptor.scope, + frontmatter: { + name: descriptor.name, + description: descriptor.description, + ui: { selectable: descriptor.uiSelectable }, + subagent: { runnable: descriptor.subagentRunnable }, + ai: descriptor.aiDefaults, + policy: { base: descriptor.policyBase, tools: descriptor.toolFilter }, + }, + body: "", + } satisfies AgentDefinitionPackage; + }, + }, providers: { list: async () => providersList, getConfig: async () => providersConfig, diff --git a/src/browser/components/AgentSelector.tsx b/src/browser/components/AgentSelector.tsx new file mode 100644 index 0000000000..9a4e2d508b --- /dev/null +++ b/src/browser/components/AgentSelector.tsx @@ -0,0 +1,71 @@ +import React from "react"; + +import { useAgent } from "@/browser/contexts/AgentContext"; +import { + HelpIndicator, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/browser/components/ui/tooltip"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/browser/components/ui/select"; +import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; +import { cn } from "@/common/lib/utils"; + +interface AgentSelectorProps { + className?: string; +} + +const AgentHelpTooltip: React.FC = () => ( + + + ? + + + Selects an agent definition (system prompt + tool policy). +
+
+ Toggle Plan/Exec with: {formatKeybind(KEYBINDS.TOGGLE_MODE)} +
+
+); + +export const AgentSelector: React.FC = (props) => { + const { agentId, setAgentId, agents, loaded } = useAgent(); + + const selectable = agents.filter((entry) => entry.uiSelectable); + + const options = + selectable.length > 0 + ? selectable + : [ + { id: "exec", name: "Exec" }, + { id: "plan", name: "Plan" }, + ]; + + const selectedLabel = + options.find((option) => option.id === agentId)?.name ?? (loaded ? agentId : "Agent"); + + return ( +
+ + +
+ ); +}; diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index c953140174..0625145d3e 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -56,7 +56,7 @@ import { type SlashSuggestion, } from "@/browser/utils/slashCommands/suggestions"; import { Tooltip, TooltipTrigger, TooltipContent, HelpIndicator } from "../ui/tooltip"; -import { ModeSelector } from "../ModeSelector"; +import { AgentSelector } from "../AgentSelector"; import { ContextUsageIndicatorButton } from "../ContextUsageIndicatorButton"; import { useWorkspaceUsage } from "@/browser/stores/WorkspaceStore"; import { useProviderOptions } from "@/browser/hooks/useProviderOptions"; @@ -266,7 +266,7 @@ const ChatInputInner: React.FC = (props) => { const preEditDraftRef = useRef({ text: "", images: [] }); const { open } = useSettings(); const { selectedWorkspace } = useWorkspaceContext(); - const [mode, setMode] = useMode(); + const [mode] = useMode(); const { models, customModels, @@ -1826,7 +1826,7 @@ const ChatInputInner: React.FC = (props) => { autoCompaction={autoCompactionProps} /> )} - + + ) : null} + + + +
+
Reasoning
+ +
+ + + ); + }; + + const renderUnknownAgentDefaults = (agentId: string) => { + const entry = agentAiDefaults[agentId]; + const modelValue = entry?.modelString ?? INHERIT; + const thinkingValue = entry?.thinkingLevel ?? INHERIT; + const allowedThinkingLevels = + modelValue !== INHERIT ? getThinkingPolicyForModel(modelValue) : ALL_THINKING_LEVELS; + + return ( +
+
{agentId}
+
Not discovered in the current workspace
+ +
+
+
Model
+
+ setAgentModel(agentId, value)} + models={models} + hiddenModels={hiddenModels} + /> + {modelValue !== INHERIT ? ( + + ) : null} +
+
+ +
+
Reasoning
+ +
+
+
+ ); + }; + return (
-

Agents

+

Task Settings

@@ -255,75 +532,50 @@ export function TasksSection() {
- {saveError &&
{saveError}
} + {saveError ?
{saveError}
: null}
-

Sub-agents

-
- {BUILT_IN_SUBAGENTS.map((preset) => { - const agentType = preset.agentType; - const entry = subagentAiDefaults[agentType]; - const modelValue = entry?.modelString ?? INHERIT; - const thinkingValue = entry?.thinkingLevel ?? INHERIT; - const allowedThinkingLevels = - modelValue !== INHERIT ? getThinkingPolicyForModel(modelValue) : ALL_THINKING_LEVELS; - - return ( -
-
{preset.label}
- -
-
-
Model
-
- setSubagentModel(agentType, value)} - models={models} - hiddenModels={hiddenModels} - /> - {modelValue !== INHERIT ? ( - - ) : null} -
-
- -
-
Reasoning
- -
-
-
- ); - })} +

Agent Defaults

+
+ Defaults apply globally. Changing model/reasoning in a workspace creates a workspace + override.
+ {agentsLoadFailed ? ( +
+ Failed to load agent definitions for this workspace. +
+ ) : null} + {!agentsLoaded ?
Loading agents…
: null}
+ + {uiAgents.length > 0 ? ( +
+

UI agents

+
{uiAgents.map(renderAgentDefaults)}
+
+ ) : null} + + {subagents.length > 0 ? ( +
+

Sub-agents

+
{subagents.map(renderAgentDefaults)}
+
+ ) : null} + + {internalAgents.length > 0 ? ( +
+

Internal

+
{internalAgents.map(renderAgentDefaults)}
+
+ ) : null} + + {unknownAgentIds.length > 0 ? ( +
+

Unknown agents

+
{unknownAgentIds.map(renderUnknownAgentDefaults)}
+
+ ) : null}
); } diff --git a/src/browser/components/tools/TaskToolCall.tsx b/src/browser/components/tools/TaskToolCall.tsx index 0519a81713..d05c85276a 100644 --- a/src/browser/components/tools/TaskToolCall.tsx +++ b/src/browser/components/tools/TaskToolCall.tsx @@ -141,7 +141,7 @@ export const TaskToolCall: React.FC = ({ args, result, status const { expanded, toggleExpanded } = useToolExpansion(hasReport); const isBackground = args.run_in_background ?? false; - const agentType = args.subagent_type; + const agentType = args.agentId ?? args.subagent_type ?? "unknown"; const prompt = args.prompt; const title = args.title; diff --git a/src/browser/contexts/AgentContext.tsx b/src/browser/contexts/AgentContext.tsx new file mode 100644 index 0000000000..deb6d42713 --- /dev/null +++ b/src/browser/contexts/AgentContext.tsx @@ -0,0 +1,26 @@ +import type { Dispatch, ReactNode, SetStateAction } from "react"; +import React, { createContext, useContext } from "react"; + +import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; + +export interface AgentContextValue { + agentId: string; + setAgentId: Dispatch>; + agents: AgentDefinitionDescriptor[]; + loaded: boolean; + loadFailed: boolean; +} + +const AgentContext = createContext(undefined); + +export function AgentProvider(props: { value: AgentContextValue; children: ReactNode }) { + return {props.children}; +} + +export function useAgent(): AgentContextValue { + const ctx = useContext(AgentContext); + if (!ctx) { + throw new Error("useAgent must be used within an AgentProvider"); + } + return ctx; +} diff --git a/src/browser/contexts/ModeContext.tsx b/src/browser/contexts/ModeContext.tsx index 58fab29db2..c0258dee45 100644 --- a/src/browser/contexts/ModeContext.tsx +++ b/src/browser/contexts/ModeContext.tsx @@ -1,9 +1,30 @@ -import type { ReactNode } from "react"; -import React, { createContext, useContext, useEffect } from "react"; -import type { UIMode } from "@/common/types/mode"; -import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import type { Dispatch, ReactNode, SetStateAction } from "react"; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { useAPI } from "@/browser/contexts/API"; +import { AgentProvider } from "@/browser/contexts/AgentContext"; +import { + readPersistedState, + updatePersistedState, + usePersistedState, +} from "@/browser/hooks/usePersistedState"; import { matchesKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; -import { getModeKey, getProjectScopeId, GLOBAL_SCOPE_ID } from "@/common/constants/storage"; +import { + getAgentIdKey, + getModeKey, + getProjectScopeId, + GLOBAL_SCOPE_ID, +} from "@/common/constants/storage"; +import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; +import type { UIMode } from "@/common/types/mode"; type ModeContextType = [UIMode, (mode: UIMode) => void]; @@ -15,34 +36,144 @@ interface ModeProviderProps { children: ReactNode; } -export const ModeProvider: React.FC = ({ - workspaceId, - projectPath, - children, -}) => { +function getScopeId(workspaceId: string | undefined, projectPath: string | undefined): string { + return workspaceId ?? (projectPath ? getProjectScopeId(projectPath) : GLOBAL_SCOPE_ID); +} + +function coerceAgentId(value: unknown): string { + return typeof value === "string" && value.trim().length > 0 ? value.trim().toLowerCase() : "exec"; +} + +function resolveModeFromAgentId(agentId: string, agents: AgentDefinitionDescriptor[]): UIMode { + const normalizedAgentId = coerceAgentId(agentId); + const descriptor = agents.find((entry) => entry.id === normalizedAgentId); + const base = descriptor?.policyBase ?? (normalizedAgentId === "plan" ? "plan" : "exec"); + return base === "plan" ? "plan" : "exec"; +} + +export const ModeProvider: React.FC = (props) => { + const { api } = useAPI(); + // Priority: workspace-scoped > project-scoped > global - const scopeId = workspaceId ?? (projectPath ? getProjectScopeId(projectPath) : GLOBAL_SCOPE_ID); - const modeKey = getModeKey(scopeId); - const [mode, setMode] = usePersistedState(modeKey, "exec", { - listener: true, // Listen for changes from command palette and other sources + const scopeId = getScopeId(props.workspaceId, props.projectPath); + + const legacyMode = readPersistedState(getModeKey(scopeId), "exec"); + + const [agentId, setAgentIdRaw] = usePersistedState(getAgentIdKey(scopeId), legacyMode, { + listener: true, }); - // Set up global keybind handler + const setAgentId: Dispatch> = useCallback( + (value) => { + setAgentIdRaw((prev) => { + const next = typeof value === "function" ? value(prev) : value; + return coerceAgentId(next); + }); + }, + [setAgentIdRaw] + ); + + const [agents, setAgents] = useState([]); + const [loaded, setLoaded] = useState(false); + + // Track the last two UI-selectable agents so TOGGLE_MODE can swap between them. + const uiAgentHistoryRef = useRef([]); + const [loadFailed, setLoadFailed] = useState(false); + + useEffect(() => { + if (!api) return; + + // Only discover agents in the context of an existing workspace. + if (!props.workspaceId) { + setAgents([]); + setLoaded(true); + setLoadFailed(false); + return; + } + + setLoaded(false); + setLoadFailed(false); + + void api.agents + .list({ workspaceId: props.workspaceId }) + .then((result) => { + setAgents(result); + setLoadFailed(false); + setLoaded(true); + }) + .catch(() => { + setAgents([]); + setLoadFailed(true); + setLoaded(true); + }); + }, [api, props.workspaceId]); + + useEffect(() => { + const normalizedAgentId = coerceAgentId(agentId); + const descriptor = agents.find((entry) => entry.id === normalizedAgentId); + const isUiSelectable = + descriptor?.uiSelectable ?? (normalizedAgentId === "plan" || normalizedAgentId === "exec"); + + if (!isUiSelectable) { + return; + } + + uiAgentHistoryRef.current = [ + normalizedAgentId, + ...uiAgentHistoryRef.current.filter((id) => id !== normalizedAgentId), + ].slice(0, 2); + }, [agentId, agents]); + const mode = useMemo(() => resolveModeFromAgentId(agentId, agents), [agentId, agents]); + + // Keep legacy mode key in sync so older code paths (and downgrade clients) behave consistently. + useEffect(() => { + const modeKey = getModeKey(scopeId); + const existing = readPersistedState(modeKey, "exec"); + if (existing !== mode) { + updatePersistedState(modeKey, mode); + } + }, [mode, scopeId]); + + const setMode = useCallback( + (nextMode: UIMode) => { + setAgentId(nextMode); + }, + [setAgentId] + ); + + // Global keybind handler useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (matchesKeybind(e, KEYBINDS.TOGGLE_MODE)) { e.preventDefault(); - setMode((currentMode) => (currentMode === "plan" ? "exec" : "plan")); + const previousUiAgentId = uiAgentHistoryRef.current[1]; + const fallback = mode === "plan" ? "exec" : "plan"; + setAgentId(previousUiAgentId ?? fallback); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [setMode]); + }, [mode, setAgentId]); + + const agentContextValue = useMemo( + () => ({ + agentId: coerceAgentId(agentId), + setAgentId, + agents, + loaded, + loadFailed, + }), + [agentId, agents, loaded, loadFailed, setAgentId] + ); - const value: ModeContextType = [mode, setMode]; + const modeContextValue: ModeContextType = [mode, setMode]; - return {children}; + return ( + + {props.children} + + ); }; export const useMode = (): ModeContextType => { diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index 5a4c196d3f..103741a405 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -16,6 +16,7 @@ import type { WorkspaceSelection } from "@/browser/components/ProjectSidebar"; import type { RuntimeConfig } from "@/common/types/runtime"; import { deleteWorkspaceStorage, + getAgentIdKey, getModeKey, getModelKey, getThinkingLevelKey, @@ -48,6 +49,18 @@ function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadat const workspaceId = metadata.id; + // Seed the workspace agentId (tasks/subagents) so the UI renders correctly on reload. + // Main workspaces default to the locally-selected agentId (stored in localStorage). + const metadataAgentId = metadata.agentId ?? metadata.agentType; + if (typeof metadataAgentId === "string" && metadataAgentId.trim().length > 0) { + const key = getAgentIdKey(workspaceId); + const normalized = metadataAgentId.trim().toLowerCase(); + const existing = readPersistedState(key, undefined); + if (existing !== normalized) { + updatePersistedState(key, normalized); + } + } + const aiByMode = metadata.aiSettingsByMode ?? (metadata.aiSettings diff --git a/src/browser/hooks/useSendMessageOptions.ts b/src/browser/hooks/useSendMessageOptions.ts index 7a3534cb1f..4a1e784fca 100644 --- a/src/browser/hooks/useSendMessageOptions.ts +++ b/src/browser/hooks/useSendMessageOptions.ts @@ -1,5 +1,6 @@ import { useThinkingLevel } from "./useThinkingLevel"; import { useMode } from "@/browser/contexts/ModeContext"; +import { useAgent } from "@/browser/contexts/AgentContext"; import { usePersistedState } from "./usePersistedState"; import { getDefaultModel } from "./useModelsFromSettings"; import { migrateGatewayModel, useGateway, isProviderSupported } from "./useGatewayModels"; @@ -48,6 +49,7 @@ interface ExperimentValues { */ function constructSendMessageOptions( mode: UIMode, + agentId: string, thinkingLevel: ThinkingLevel, preferredModel: string | null | undefined, providerOptions: MuxProviderOptions, @@ -71,6 +73,7 @@ function constructSendMessageOptions( return { thinkingLevel: uiThinking, model, + agentId, mode: mode === "exec" || mode === "plan" ? mode : "exec", // Only pass exec/plan to backend toolPolicy: modeToToolPolicy(mode), providerOptions, @@ -107,6 +110,7 @@ export interface SendMessageOptionsWithBase extends SendMessageOptions { export function useSendMessageOptions(workspaceId: string): SendMessageOptionsWithBase { const [thinkingLevel] = useThinkingLevel(); const [mode] = useMode(); + const { agentId } = useAgent(); const { options: providerOptions } = useProviderOptions(); const defaultModel = getDefaultModel(); const [preferredModel] = usePersistedState( @@ -135,6 +139,7 @@ export function useSendMessageOptions(workspaceId: string): SendMessageOptionsWi const options = constructSendMessageOptions( mode, + agentId, thinkingLevel, preferredModel, providerOptions, diff --git a/src/browser/stories/App.settings.stories.tsx b/src/browser/stories/App.settings.stories.tsx index 67f0d0416d..2d9463051a 100644 --- a/src/browser/stories/App.settings.stories.tsx +++ b/src/browser/stories/App.settings.stories.tsx @@ -21,7 +21,7 @@ import { selectWorkspace } from "./storyHelpers"; import { createMockORPCClient } from "../../../.storybook/mocks/orpc"; import { within, userEvent, waitFor } from "@storybook/test"; import { getExperimentKey, EXPERIMENT_IDS } from "@/common/constants/experiments"; -import type { ModeAiDefaults } from "@/common/types/modeAiDefaults"; +import type { AgentAiDefaults } from "@/common/types/agentAiDefaults"; import type { TaskSettings } from "@/common/types/tasks"; export default { @@ -37,7 +37,7 @@ export default { function setupSettingsStory(options: { providersConfig?: Record; providersList?: string[]; - modeAiDefaults?: ModeAiDefaults; + agentAiDefaults?: AgentAiDefaults; taskSettings?: Partial; /** Pre-set experiment states in localStorage before render */ experiments?: Partial>; @@ -58,7 +58,7 @@ function setupSettingsStory(options: { projects: groupWorkspacesByProject(workspaces), workspaces, providersConfig: options.providersConfig ?? {}, - modeAiDefaults: options.modeAiDefaults, + agentAiDefaults: options.agentAiDefaults, providersList: options.providersList ?? ["anthropic", "openai", "xai"], taskSettings: options.taskSettings, }); @@ -118,20 +118,15 @@ export const Tasks: AppStory = { await body.findByText(/Max Parallel Agent Tasks/i); await body.findByText(/Max Task Nesting Depth/i); - const subagentsHeading = await body.findByRole("heading", { name: /Sub-agents/i }); - const subagentsSection = subagentsHeading.parentElement; - if (!subagentsSection) { - throw new Error("Expected Sub-agents section container to exist"); - } - - const subagents = within(subagentsSection); + await body.findByText(/Agent Defaults/i); + await body.findByRole("heading", { name: /UI agents/i }); + await body.findByRole("heading", { name: /Sub-agents/i }); + await body.findByRole("heading", { name: /Internal/i }); - await subagents.findByText(/^Explore$/i); - - const execMatches = subagents.queryAllByText(/^Exec$/i); - if (execMatches.length > 0) { - throw new Error("Expected Exec sub-agent settings to be hidden (always inherits)"); - } + await body.findByText(/^Plan$/i); + await body.findByText(/^Exec$/i); + await body.findByText(/^Explore$/i); + await body.findByText(/^Compact$/i); const inputs = await body.findAllByRole("spinbutton"); if (inputs.length !== 2) { @@ -234,35 +229,6 @@ export const ModelsConfigured: AppStory = { }, }; -/** Modes section - global default model/reasoning per mode */ -export const Modes: AppStory = { - render: () => ( - - setupSettingsStory({ - modeAiDefaults: { - plan: { modelString: "anthropic:claude-sonnet-4-5", thinkingLevel: "medium" }, - exec: { modelString: "openai:gpt-5.2", thinkingLevel: "xhigh" }, - compact: { modelString: "openai:gpt-5.2-pro", thinkingLevel: "high" }, - }, - }) - } - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - await openSettingsToSection(canvasElement, "modes"); - - const body = within(canvasElement.ownerDocument.body); - const dialog = await body.findByRole("dialog"); - const modal = within(dialog); - - await modal.findByText(/Mode Defaults/i); - await modal.findByText(/^Plan$/i); - await modal.findByText(/^Exec$/i); - await modal.findByText(/^Compact$/i); - }, -}; - /** Experiments section - shows available experiments */ export const Experiments: AppStory = { render: () => setupSettingsStory({})} />, diff --git a/src/browser/utils/messages/compactionOptions.ts b/src/browser/utils/messages/compactionOptions.ts index 7e0f544516..c5d18ac0ba 100644 --- a/src/browser/utils/messages/compactionOptions.ts +++ b/src/browser/utils/messages/compactionOptions.ts @@ -41,6 +41,7 @@ export function applyCompactionOverrides( return { ...baseOptions, + agentId: "compact", model: compactionModel, thinkingLevel, maxOutputTokens: compactData.maxOutputTokens, diff --git a/src/browser/utils/messages/sendOptions.ts b/src/browser/utils/messages/sendOptions.ts index 2cb360ed10..7f7a753051 100644 --- a/src/browser/utils/messages/sendOptions.ts +++ b/src/browser/utils/messages/sendOptions.ts @@ -1,4 +1,5 @@ import { + getAgentIdKey, getModelKey, getThinkingLevelByModelKey, getThinkingLevelKey, @@ -71,6 +72,13 @@ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptio // Read mode (workspace-specific) const mode = readPersistedState(getModeKey(workspaceId), WORKSPACE_DEFAULTS.mode); + // Read selected agent id (workspace-specific). If missing, fall back to the legacy mode key. + const rawAgentId = readPersistedState(getAgentIdKey(workspaceId), undefined); + const agentId = + typeof rawAgentId === "string" && rawAgentId.trim().length > 0 + ? rawAgentId.trim().toLowerCase() + : mode; + // Get provider options const providerOptions = getProviderOptions(); @@ -81,6 +89,7 @@ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptio return { model, + agentId, mode: mode === "exec" || mode === "plan" ? mode : "exec", // Only pass exec/plan to backend thinkingLevel: effectiveThinkingLevel, toolPolicy: modeToToolPolicy(mode), diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index cafd5d85ec..66d4b5e631 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -131,6 +131,13 @@ export function getCancelledCompactionKey(workspaceId: string): string { return `workspace:${workspaceId}:cancelled-compaction`; } +/** + * Get the localStorage key for the selected agent definition id for a scope. + * Format: "agentId:{scopeId}" + */ +export function getAgentIdKey(scopeId: string): string { + return `agentId:${scopeId}`; +} /** * Get the localStorage key for the UI mode for a workspace * Format: "mode:{workspaceId}" @@ -340,6 +347,7 @@ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> getModelKey, getInputKey, getInputImagesKey, + getAgentIdKey, getModeKey, getThinkingLevelKey, getAutoRetryKey, diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index 2ebcf2ceca..7b67fe8d5f 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -40,6 +40,15 @@ export { } from "./schemas/chatStats"; // Error schemas +// Agent Definition schemas +export { + AgentDefinitionDescriptorSchema, + AgentDefinitionFrontmatterSchema, + AgentDefinitionPackageSchema, + AgentDefinitionScopeSchema, + AgentIdSchema, +} from "./schemas/agentDefinition"; + export { SendMessageErrorSchema, StreamErrorTypeSchema } from "./schemas/errors"; // Tool schemas @@ -120,6 +129,7 @@ export { features, general, menu, + agents, nameGeneration, projects, ProviderConfigInfoSchema, diff --git a/src/common/orpc/schemas/agentDefinition.ts b/src/common/orpc/schemas/agentDefinition.ts new file mode 100644 index 0000000000..98c06f0ba6 --- /dev/null +++ b/src/common/orpc/schemas/agentDefinition.ts @@ -0,0 +1,100 @@ +import { z } from "zod"; +import { AgentModeSchema } from "@/common/types/mode"; + +export const AgentDefinitionScopeSchema = z.enum(["built-in", "project", "global"]); + +// Agent IDs come from filenames (.md). +// Keep constraints conservative so IDs are safe to use in storage keys, URLs, etc. +export const AgentIdSchema = z + .string() + .min(1) + .max(64) + .regex(/^[a-z0-9]+(?:[a-z0-9_-]*[a-z0-9])?$/); + +const AgentPolicyBaseSchema = z.preprocess( + (value) => (typeof value === "string" ? value.trim().toLowerCase() : value), + AgentModeSchema +); + +const ThinkingLevelSchema = z.enum(["off", "low", "medium", "high", "xhigh"]); + +const AgentDefinitionUiSchema = z + .object({ + selectable: z.boolean().optional(), + }) + .strict(); + +const AgentDefinitionSubagentSchema = z + .object({ + runnable: z.boolean().optional(), + }) + .strict(); + +const AgentDefinitionAiDefaultsSchema = z + .object({ + modelString: z.string().min(1).optional(), + thinkingLevel: ThinkingLevelSchema.optional(), + }) + .strict(); + +const AgentDefinitionToolFilterSchema = z + .object({ + deny: z.array(z.string().min(1)).optional(), + only: z.array(z.string().min(1)).optional(), + }) + .strict() + .superRefine((value, ctx) => { + const hasDeny = Array.isArray(value.deny) && value.deny.length > 0; + const hasOnly = Array.isArray(value.only) && value.only.length > 0; + + if (hasDeny && hasOnly) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "policy.tools must specify exactly one of deny or only", + path: ["deny"], + }); + return; + } + }); + +const AgentDefinitionPolicySchema = z + .object({ + base: AgentPolicyBaseSchema.optional(), + tools: AgentDefinitionToolFilterSchema.optional(), + }) + .strict(); + +export const AgentDefinitionFrontmatterSchema = z + .object({ + name: z.string().min(1).max(128), + description: z.string().min(1).max(1024).optional(), + ui: AgentDefinitionUiSchema.optional(), + subagent: AgentDefinitionSubagentSchema.optional(), + ai: AgentDefinitionAiDefaultsSchema.optional(), + policy: AgentDefinitionPolicySchema.optional(), + }) + .strict(); + +export const AgentDefinitionDescriptorSchema = z + .object({ + id: AgentIdSchema, + scope: AgentDefinitionScopeSchema, + name: z.string().min(1).max(128), + description: z.string().min(1).max(1024).optional(), + uiSelectable: z.boolean(), + subagentRunnable: z.boolean(), + policyBase: AgentModeSchema, + aiDefaults: AgentDefinitionAiDefaultsSchema.optional(), + // Raw tool filter metadata (for UI display). Runtime validates tool names. + toolFilter: AgentDefinitionToolFilterSchema.optional(), + }) + .strict(); + +export const AgentDefinitionPackageSchema = z + .object({ + id: AgentIdSchema, + scope: AgentDefinitionScopeSchema, + frontmatter: AgentDefinitionFrontmatterSchema, + body: z.string(), + }) + .strict(); diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 0d748cb4fb..f2f304464a 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -18,6 +18,11 @@ import { BashToolResultSchema, FileTreeNodeSchema } from "./tools"; import { WorkspaceStatsSnapshotSchema } from "./workspaceStats"; import { FrontendWorkspaceMetadataSchema, WorkspaceActivitySnapshotSchema } from "./workspace"; import { WorkspaceAISettingsSchema } from "./workspaceAiSettings"; +import { + AgentDefinitionDescriptorSchema, + AgentDefinitionPackageSchema, + AgentIdSchema, +} from "./agentDefinition"; import { MCPAddParamsSchema, MCPRemoveParamsSchema, @@ -507,15 +512,31 @@ export type WorkspaceSendMessageOutput = z.infer { + const hasAgentId = typeof value.agentId === "string" && value.agentId.trim().length > 0; + const hasAgentType = + typeof value.agentType === "string" && value.agentType.trim().length > 0; + + if (hasAgentId === hasAgentType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "tasks.create: exactly one of agentId or agentType is required", + path: ["agentId"], + }); + } + }), output: ResultSchema( z.object({ taskId: z.string(), @@ -527,6 +548,18 @@ export const tasks = { }, }; +// Agent definitions (unifies UI modes + subagents) +export const agents = { + list: { + input: z.object({ workspaceId: z.string() }), + output: z.array(AgentDefinitionDescriptorSchema), + }, + get: { + input: z.object({ workspaceId: z.string(), agentId: AgentIdSchema }), + output: AgentDefinitionPackageSchema, + }, +}; + // Name generation for new workspaces (decoupled from workspace creation) export const nameGeneration = { generate: { @@ -673,6 +706,7 @@ const ModeAiDefaultsSchema = z compact: ModeAiDefaultsEntrySchema.optional(), }) .strict(); +const AgentAiDefaultsSchema = z.record(z.string().min(1), SubagentAiDefaultsEntrySchema); const SubagentAiDefaultsSchema = z.record(z.string().min(1), SubagentAiDefaultsEntrySchema); export const config = { @@ -683,6 +717,8 @@ export const config = { maxParallelAgentTasks: z.number().int(), maxTaskNestingDepth: z.number().int(), }), + agentAiDefaults: AgentAiDefaultsSchema, + // Legacy fields (downgrade compatibility) subagentAiDefaults: SubagentAiDefaultsSchema, modeAiDefaults: ModeAiDefaultsSchema, }), @@ -693,10 +729,18 @@ export const config = { maxParallelAgentTasks: z.number().int(), maxTaskNestingDepth: z.number().int(), }), + agentAiDefaults: AgentAiDefaultsSchema.optional(), + // Legacy field (downgrade compatibility) subagentAiDefaults: SubagentAiDefaultsSchema.optional(), }), output: z.void(), }, + updateAgentAiDefaults: { + input: z.object({ + agentAiDefaults: AgentAiDefaultsSchema, + }), + output: z.void(), + }, updateModeAiDefaults: { input: z.object({ modeAiDefaults: ModeAiDefaultsSchema, diff --git a/src/common/orpc/schemas/project.ts b/src/common/orpc/schemas/project.ts index b712b57305..e1c320be5a 100644 --- a/src/common/orpc/schemas/project.ts +++ b/src/common/orpc/schemas/project.ts @@ -39,6 +39,10 @@ export const WorkspaceConfigSchema = z.object({ agentType: z.string().optional().meta({ description: 'If set, selects an agent preset for this workspace (e.g., "explore" or "exec").', }), + agentId: z.string().optional().meta({ + description: + 'If set, selects an agent definition for this workspace (e.g., "explore" or "exec").', + }), taskStatus: z.enum(["queued", "running", "awaiting_report", "reported"]).optional().meta({ description: "Agent task lifecycle status for child workspaces (queued|running|awaiting_report|reported).", diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index bd2f428de9..8209aafd9b 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -372,6 +372,7 @@ export const SendMessageOptionsSchema = z.object({ toolPolicy: ToolPolicySchema.optional(), additionalSystemInstructions: z.string().optional(), maxOutputTokens: z.number().optional(), + agentId: z.string().optional().catch(undefined), providerOptions: MuxProviderOptionsSchema.optional(), mode: AgentModeSchema.optional().catch(undefined), muxMetadata: z.any().optional(), // Black box diff --git a/src/common/orpc/schemas/telemetry.ts b/src/common/orpc/schemas/telemetry.ts index f635fcc723..161086f569 100644 --- a/src/common/orpc/schemas/telemetry.ts +++ b/src/common/orpc/schemas/telemetry.ts @@ -85,6 +85,7 @@ const MCPContextInjectedPropertiesSchema = z.object({ workspaceId: z.string(), model: z.string(), mode: AgentModeSchema.catch("exec"), + agentId: z.string().min(1).optional().catch(undefined), runtimeType: TelemetryRuntimeTypeSchema, mcp_server_enabled_count: z.number(), diff --git a/src/common/orpc/schemas/workspace.ts b/src/common/orpc/schemas/workspace.ts index 19f47e0700..807b8abdc0 100644 --- a/src/common/orpc/schemas/workspace.ts +++ b/src/common/orpc/schemas/workspace.ts @@ -42,6 +42,10 @@ export const WorkspaceMetadataSchema = z.object({ agentType: z.string().optional().meta({ description: 'If set, selects an agent preset for this workspace (e.g., "explore" or "exec").', }), + agentId: z.string().optional().meta({ + description: + 'If set, selects an agent definition for this workspace (e.g., "explore" or "exec").', + }), taskStatus: z.enum(["queued", "running", "awaiting_report", "reported"]).optional().meta({ description: "Agent task lifecycle status for child workspaces (queued|running|awaiting_report|reported).", diff --git a/src/common/telemetry/payload.ts b/src/common/telemetry/payload.ts index f2865cef86..8e6dba4e90 100644 --- a/src/common/telemetry/payload.ts +++ b/src/common/telemetry/payload.ts @@ -120,8 +120,10 @@ export interface MCPContextInjectedPayload { workspaceId: string; /** Full model identifier */ model: string; - /** UI mode */ + /** UI mode (plan|exec|compact) derived from the selected agent definition */ mode: AgentMode; + /** Active agent definition id (e.g. "plan", "exec", "explore"). Optional for backwards compatibility. */ + agentId?: string; /** Runtime type for the workspace */ runtimeType: TelemetryRuntimeType; diff --git a/src/common/types/agentAiDefaults.ts b/src/common/types/agentAiDefaults.ts new file mode 100644 index 0000000000..7c2094098c --- /dev/null +++ b/src/common/types/agentAiDefaults.ts @@ -0,0 +1,39 @@ +import { AgentIdSchema } from "@/common/orpc/schemas"; +import { coerceThinkingLevel, type ThinkingLevel } from "./thinking"; + +export interface AgentAiDefaultsEntry { + modelString?: string; + thinkingLevel?: ThinkingLevel; +} + +export type AgentAiDefaults = Record; + +export function normalizeAgentAiDefaults(raw: unknown): AgentAiDefaults { + const record = raw && typeof raw === "object" ? (raw as Record) : ({} as const); + + const result: AgentAiDefaults = {}; + + for (const [agentIdRaw, entryRaw] of Object.entries(record)) { + const agentId = agentIdRaw.trim().toLowerCase(); + if (!agentId) continue; + if (!AgentIdSchema.safeParse(agentId).success) continue; + if (!entryRaw || typeof entryRaw !== "object") continue; + + const entry = entryRaw as Record; + + const modelString = + typeof entry.modelString === "string" && entry.modelString.trim().length > 0 + ? entry.modelString.trim() + : undefined; + + const thinkingLevel = coerceThinkingLevel(entry.thinkingLevel); + + if (!modelString && !thinkingLevel) { + continue; + } + + result[agentId] = { modelString, thinkingLevel }; + } + + return result; +} diff --git a/src/common/types/agentDefinition.ts b/src/common/types/agentDefinition.ts new file mode 100644 index 0000000000..8a54d79a94 --- /dev/null +++ b/src/common/types/agentDefinition.ts @@ -0,0 +1,18 @@ +import type { z } from "zod"; +import type { + AgentDefinitionDescriptorSchema, + AgentDefinitionFrontmatterSchema, + AgentDefinitionPackageSchema, + AgentDefinitionScopeSchema, + AgentIdSchema, +} from "@/common/orpc/schemas"; + +export type AgentId = z.infer; + +export type AgentDefinitionScope = z.infer; + +export type AgentDefinitionFrontmatter = z.infer; + +export type AgentDefinitionDescriptor = z.infer; + +export type AgentDefinitionPackage = z.infer; diff --git a/src/common/types/project.ts b/src/common/types/project.ts index 418aacec46..bba610df6a 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -7,6 +7,7 @@ import type { z } from "zod"; import type { ProjectConfigSchema, WorkspaceConfigSchema } from "../orpc/schemas"; import type { TaskSettings, SubagentAiDefaults } from "./tasks"; import type { ModeAiDefaults } from "./modeAiDefaults"; +import type { AgentAiDefaults } from "./agentAiDefaults"; export type Workspace = z.infer; @@ -43,8 +44,10 @@ export interface ProjectsConfig { featureFlagOverrides?: Record; /** Global task settings (agent sub-workspaces, queue limits, nesting depth) */ taskSettings?: TaskSettings; - /** Per-subagent default model + thinking overrides. Missing values inherit from the parent workspace. */ + /** Default model + thinking overrides per agentId (applies to UI agents and subagents). */ + agentAiDefaults?: AgentAiDefaults; + /** @deprecated Legacy per-subagent default model + thinking overrides. */ subagentAiDefaults?: SubagentAiDefaults; - /** Default model + thinking overrides per mode (plan/exec/compact). */ + /** @deprecated Legacy per-mode (plan/exec/compact) default model + thinking overrides. */ modeAiDefaults?: ModeAiDefaults; } diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index c52ddeaa93..1a93ae237c 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -6,6 +6,7 @@ */ import { z } from "zod"; +import { AgentIdSchema, AgentSkillPackageSchema, SkillNameSchema } from "@/common/orpc/schemas"; import { BASH_HARD_MAX_LINES, BASH_MAX_LINE_BYTES, @@ -93,14 +94,43 @@ const SubagentTypeSchema = z.preprocess( z.enum(BUILT_IN_SUBAGENT_TYPES) ); +const TaskAgentIdSchema = z.preprocess( + (value) => (typeof value === "string" ? value.trim().toLowerCase() : value), + AgentIdSchema +); + export const TaskToolArgsSchema = z .object({ - subagent_type: SubagentTypeSchema, + // Prefer agentId. subagent_type is a deprecated alias for backwards compatibility. + agentId: TaskAgentIdSchema.optional(), + subagent_type: SubagentTypeSchema.optional(), prompt: z.string().min(1), title: z.string().min(1), run_in_background: z.boolean().default(false), }) - .strict(); + .strict() + .superRefine((args, ctx) => { + const hasAgentId = typeof args.agentId === "string" && args.agentId.length > 0; + const hasSubagentType = typeof args.subagent_type === "string" && args.subagent_type.length > 0; + + if (!hasAgentId && !hasSubagentType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Provide agentId (preferred) or subagent_type", + path: ["agentId"], + }); + return; + } + + if (hasAgentId && hasSubagentType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Provide only one of agentId or subagent_type (not both)", + path: ["agentId"], + }); + return; + } + }); export const TaskToolQueuedResultSchema = z .object({ @@ -115,6 +145,7 @@ export const TaskToolCompletedResultSchema = z taskId: z.string().optional(), reportMarkdown: z.string(), title: z.string().optional(), + agentId: z.string().optional(), agentType: z.string().optional(), }) .strict(); diff --git a/src/node/config.ts b/src/node/config.ts index cefe599e0a..22548de599 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -18,6 +18,7 @@ import { normalizeTaskSettings, } from "@/common/types/tasks"; import { normalizeModeAiDefaults } from "@/common/types/modeAiDefaults"; +import { normalizeAgentAiDefaults } from "@/common/types/agentAiDefaults"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility"; import { getMuxHome } from "@/common/constants/paths"; @@ -97,6 +98,7 @@ export class Config { viewedSplashScreens?: string[]; featureFlagOverrides?: Record; taskSettings?: unknown; + agentAiDefaults?: unknown; subagentAiDefaults?: unknown; modeAiDefaults?: unknown; }; @@ -110,6 +112,19 @@ export class Config { return [stripTrailingSlashes(projectPath), projectConfig] as [string, ProjectConfig]; }); const projectsMap = new Map(normalizedPairs); + + const taskSettings = normalizeTaskSettings(parsed.taskSettings); + const legacySubagentAiDefaults = normalizeSubagentAiDefaults(parsed.subagentAiDefaults); + const legacyModeAiDefaults = normalizeModeAiDefaults(parsed.modeAiDefaults); + + const agentAiDefaults = + parsed.agentAiDefaults !== undefined + ? normalizeAgentAiDefaults(parsed.agentAiDefaults) + : normalizeAgentAiDefaults({ + ...legacySubagentAiDefaults, + ...(legacyModeAiDefaults as Record), + }); + return { projects: projectsMap, apiServerBindHost: parseOptionalNonEmptyString(parsed.apiServerBindHost), @@ -119,9 +134,11 @@ export class Config { apiServerPort: parseOptionalPort(parsed.apiServerPort), serverSshHost: parsed.serverSshHost, viewedSplashScreens: parsed.viewedSplashScreens, - taskSettings: normalizeTaskSettings(parsed.taskSettings), - subagentAiDefaults: normalizeSubagentAiDefaults(parsed.subagentAiDefaults), - modeAiDefaults: normalizeModeAiDefaults(parsed.modeAiDefaults), + taskSettings, + agentAiDefaults, + // Legacy fields are still parsed and returned for downgrade compatibility. + subagentAiDefaults: legacySubagentAiDefaults, + modeAiDefaults: legacyModeAiDefaults, featureFlagOverrides: parsed.featureFlagOverrides, }; } @@ -134,6 +151,7 @@ export class Config { return { projects: new Map(), taskSettings: DEFAULT_TASK_SETTINGS, + agentAiDefaults: {}, subagentAiDefaults: {}, modeAiDefaults: {}, }; @@ -154,6 +172,7 @@ export class Config { viewedSplashScreens?: string[]; featureFlagOverrides?: ProjectsConfig["featureFlagOverrides"]; taskSettings?: ProjectsConfig["taskSettings"]; + agentAiDefaults?: ProjectsConfig["agentAiDefaults"]; subagentAiDefaults?: ProjectsConfig["subagentAiDefaults"]; modeAiDefaults?: ProjectsConfig["modeAiDefaults"]; } = { @@ -184,11 +203,38 @@ export class Config { if (config.viewedSplashScreens) { data.viewedSplashScreens = config.viewedSplashScreens; } - if (config.modeAiDefaults && Object.keys(config.modeAiDefaults).length > 0) { - data.modeAiDefaults = config.modeAiDefaults; - } - if (config.subagentAiDefaults && Object.keys(config.subagentAiDefaults).length > 0) { - data.subagentAiDefaults = config.subagentAiDefaults; + if (config.agentAiDefaults && Object.keys(config.agentAiDefaults).length > 0) { + data.agentAiDefaults = config.agentAiDefaults; + + // Downgrade compatibility: also write legacy modeAiDefaults + subagentAiDefaults. + // Older clients ignore unknown keys, so this is safe. + const legacyMode: Record = {}; + for (const id of ["plan", "exec", "compact"] as const) { + const entry = config.agentAiDefaults[id]; + if (entry) { + legacyMode[id] = entry; + } + } + if (Object.keys(legacyMode).length > 0) { + data.modeAiDefaults = legacyMode as ProjectsConfig["modeAiDefaults"]; + } + + const legacySubagent: Record = {}; + for (const [id, entry] of Object.entries(config.agentAiDefaults)) { + if (id === "plan" || id === "exec" || id === "compact") continue; + legacySubagent[id] = entry; + } + if (Object.keys(legacySubagent).length > 0) { + data.subagentAiDefaults = legacySubagent as ProjectsConfig["subagentAiDefaults"]; + } + } else { + // Legacy only. + if (config.modeAiDefaults && Object.keys(config.modeAiDefaults).length > 0) { + data.modeAiDefaults = config.modeAiDefaults; + } + if (config.subagentAiDefaults && Object.keys(config.subagentAiDefaults).length > 0) { + data.subagentAiDefaults = config.subagentAiDefaults; + } } await writeFileAtomic(this.configFile, JSON.stringify(data, null, 2), "utf-8"); diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 49d83ac223..e54c192da1 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -20,12 +20,17 @@ import { readPlanFile } from "@/node/utils/runtime/helpers"; import { secretsToRecord } from "@/common/types/secrets"; import { roundToBase2 } from "@/common/telemetry/utils"; import { createAsyncEventQueue } from "@/common/utils/asyncEventIterator"; +import { normalizeAgentAiDefaults } from "@/common/types/agentAiDefaults"; import { normalizeModeAiDefaults } from "@/common/types/modeAiDefaults"; import { DEFAULT_TASK_SETTINGS, normalizeSubagentAiDefaults, normalizeTaskSettings, } from "@/common/types/tasks"; +import { + discoverAgentDefinitions, + readAgentDefinition, +} from "@/node/services/agentDefinitions/agentDefinitionsService"; import { isWorkspaceArchived } from "@/common/utils/archive"; export const router = (authToken?: string) => { @@ -251,6 +256,8 @@ export const router = (authToken?: string) => { const config = context.config.loadConfigOrDefault(); return { taskSettings: config.taskSettings ?? DEFAULT_TASK_SETTINGS, + agentAiDefaults: config.agentAiDefaults ?? {}, + // Legacy fields (downgrade compatibility) subagentAiDefaults: config.subagentAiDefaults ?? {}, modeAiDefaults: config.modeAiDefaults ?? {}, }; @@ -261,13 +268,61 @@ export const router = (authToken?: string) => { .handler(async ({ context, input }) => { await context.config.editConfig((config) => { const normalizedDefaults = normalizeModeAiDefaults(input.modeAiDefaults); + + const nextAgentAiDefaults = { ...(config.agentAiDefaults ?? {}) }; + for (const id of ["plan", "exec", "compact"] as const) { + const entry = normalizedDefaults[id]; + if (entry) { + nextAgentAiDefaults[id] = entry; + } else { + delete nextAgentAiDefaults[id]; + } + } + return { ...config, + agentAiDefaults: + Object.keys(nextAgentAiDefaults).length > 0 ? nextAgentAiDefaults : undefined, + // Keep legacy field up to date. modeAiDefaults: Object.keys(normalizedDefaults).length > 0 ? normalizedDefaults : undefined, }; }); }), + updateAgentAiDefaults: t + .input(schemas.config.updateAgentAiDefaults.input) + .output(schemas.config.updateAgentAiDefaults.output) + .handler(async ({ context, input }) => { + await context.config.editConfig((config) => { + const normalized = normalizeAgentAiDefaults(input.agentAiDefaults); + + const legacyModeDefaults = normalizeModeAiDefaults({ + plan: normalized.plan, + exec: normalized.exec, + compact: normalized.compact, + }); + + const legacySubagentDefaultsRaw: Record = {}; + for (const [agentType, entry] of Object.entries(normalized)) { + if (agentType === "plan" || agentType === "exec" || agentType === "compact") { + continue; + } + legacySubagentDefaultsRaw[agentType] = entry; + } + + const legacySubagentDefaults = normalizeSubagentAiDefaults(legacySubagentDefaultsRaw); + + return { + ...config, + agentAiDefaults: Object.keys(normalized).length > 0 ? normalized : undefined, + // Legacy fields (downgrade compatibility) + modeAiDefaults: + Object.keys(legacyModeDefaults).length > 0 ? legacyModeDefaults : undefined, + subagentAiDefaults: + Object.keys(legacySubagentDefaults).length > 0 ? legacySubagentDefaults : undefined, + }; + }); + }), saveConfig: t .input(schemas.config.saveConfig.input) .output(schemas.config.saveConfig.output) @@ -276,16 +331,124 @@ export const router = (authToken?: string) => { const normalizedTaskSettings = normalizeTaskSettings(input.taskSettings); const result = { ...config, taskSettings: normalizedTaskSettings }; + if (input.agentAiDefaults !== undefined) { + const normalized = normalizeAgentAiDefaults(input.agentAiDefaults); + result.agentAiDefaults = Object.keys(normalized).length > 0 ? normalized : undefined; + + // Legacy fields (downgrade compatibility) + const legacyModeDefaults = normalizeModeAiDefaults({ + plan: normalized.plan, + exec: normalized.exec, + compact: normalized.compact, + }); + result.modeAiDefaults = + Object.keys(legacyModeDefaults).length > 0 ? legacyModeDefaults : undefined; + + if (input.subagentAiDefaults === undefined) { + const legacySubagentDefaultsRaw: Record = {}; + for (const [agentType, entry] of Object.entries(normalized)) { + if (agentType === "plan" || agentType === "exec" || agentType === "compact") { + continue; + } + legacySubagentDefaultsRaw[agentType] = entry; + } + + const legacySubagentDefaults = + normalizeSubagentAiDefaults(legacySubagentDefaultsRaw); + result.subagentAiDefaults = + Object.keys(legacySubagentDefaults).length > 0 + ? legacySubagentDefaults + : undefined; + } + } + if (input.subagentAiDefaults !== undefined) { const normalizedDefaults = normalizeSubagentAiDefaults(input.subagentAiDefaults); result.subagentAiDefaults = Object.keys(normalizedDefaults).length > 0 ? normalizedDefaults : undefined; + + // Downgrade compatibility: keep agentAiDefaults in sync with legacy subagentAiDefaults. + // Only mutate keys previously managed by subagentAiDefaults so we don't clobber other + // agent defaults (e.g., UI-selectable custom agents). + const previousLegacy = config.subagentAiDefaults ?? {}; + const nextAgentAiDefaults: Record = { + ...(result.agentAiDefaults ?? config.agentAiDefaults ?? {}), + }; + + for (const legacyAgentType of Object.keys(previousLegacy)) { + if ( + legacyAgentType === "plan" || + legacyAgentType === "exec" || + legacyAgentType === "compact" + ) { + continue; + } + if (!(legacyAgentType in normalizedDefaults)) { + delete nextAgentAiDefaults[legacyAgentType]; + } + } + + for (const [agentType, entry] of Object.entries(normalizedDefaults)) { + if (agentType === "plan" || agentType === "exec" || agentType === "compact") + continue; + nextAgentAiDefaults[agentType] = entry; + } + + const normalizedAgent = normalizeAgentAiDefaults(nextAgentAiDefaults); + result.agentAiDefaults = + Object.keys(normalizedAgent).length > 0 ? normalizedAgent : undefined; } return result; }); }), }, + agents: { + list: t + .input(schemas.agents.list.input) + .output(schemas.agents.list.output) + .handler(async ({ context, input }) => { + const metadataResult = await context.aiService.getWorkspaceMetadata(input.workspaceId); + if (!metadataResult.success) { + throw new Error(metadataResult.error); + } + + const metadata = metadataResult.data; + const runtime = createRuntime( + metadata.runtimeConfig ?? { type: "local", srcBaseDir: context.config.srcDir }, + { projectPath: metadata.projectPath } + ); + + const isInPlace = metadata.projectPath === metadata.name; + const workspacePath = isInPlace + ? metadata.projectPath + : runtime.getWorkspacePath(metadata.projectPath, metadata.name); + + return discoverAgentDefinitions(runtime, workspacePath); + }), + get: t + .input(schemas.agents.get.input) + .output(schemas.agents.get.output) + .handler(async ({ context, input }) => { + const metadataResult = await context.aiService.getWorkspaceMetadata(input.workspaceId); + if (!metadataResult.success) { + throw new Error(metadataResult.error); + } + + const metadata = metadataResult.data; + const runtime = createRuntime( + metadata.runtimeConfig ?? { type: "local", srcBaseDir: context.config.srcDir }, + { projectPath: metadata.projectPath } + ); + + const isInPlace = metadata.projectPath === metadata.name; + const workspacePath = isInPlace + ? metadata.projectPath + : runtime.getWorkspacePath(metadata.projectPath, metadata.name); + + return readAgentDefinition(runtime, workspacePath, input.agentId); + }), + }, providers: { list: t .input(schemas.providers.list.input) @@ -1295,6 +1458,7 @@ export const router = (authToken?: string) => { return context.taskService.create({ parentWorkspaceId: input.parentWorkspaceId, kind: input.kind, + agentId: input.agentId, agentType: input.agentType, prompt: input.prompt, title: input.title, diff --git a/src/node/services/agentDefinitions/agentDefinitionsService.test.ts b/src/node/services/agentDefinitions/agentDefinitionsService.test.ts new file mode 100644 index 0000000000..86c59012a7 --- /dev/null +++ b/src/node/services/agentDefinitions/agentDefinitionsService.test.ts @@ -0,0 +1,71 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import { describe, expect, test } from "bun:test"; + +import { AgentIdSchema } from "@/common/orpc/schemas"; +import { LocalRuntime } from "@/node/runtime/LocalRuntime"; +import { DisposableTempDir } from "@/node/services/tempDir"; +import { discoverAgentDefinitions, readAgentDefinition } from "./agentDefinitionsService"; + +async function writeAgent(root: string, id: string, name: string): Promise { + await fs.mkdir(root, { recursive: true }); + const content = `--- +name: ${name} +ui: + selectable: true +policy: + base: exec +--- +Body +`; + await fs.writeFile(path.join(root, `${id}.md`), content, "utf-8"); +} + +describe("agentDefinitionsService", () => { + test("project agents override global agents", async () => { + using project = new DisposableTempDir("agent-defs-project"); + using global = new DisposableTempDir("agent-defs-global"); + + const projectAgentsRoot = path.join(project.path, ".mux", "agents"); + const globalAgentsRoot = global.path; + + await writeAgent(globalAgentsRoot, "foo", "Foo (global)"); + await writeAgent(projectAgentsRoot, "foo", "Foo (project)"); + await writeAgent(globalAgentsRoot, "bar", "Bar (global)"); + + const roots = { projectRoot: projectAgentsRoot, globalRoot: globalAgentsRoot }; + const runtime = new LocalRuntime(project.path); + + const agents = await discoverAgentDefinitions(runtime, project.path, { roots }); + + const foo = agents.find((a) => a.id === "foo"); + expect(foo).toBeDefined(); + expect(foo!.scope).toBe("project"); + expect(foo!.name).toBe("Foo (project)"); + + const bar = agents.find((a) => a.id === "bar"); + expect(bar).toBeDefined(); + expect(bar!.scope).toBe("global"); + }); + + test("readAgentDefinition resolves project before global", async () => { + using project = new DisposableTempDir("agent-defs-project"); + using global = new DisposableTempDir("agent-defs-global"); + + const projectAgentsRoot = path.join(project.path, ".mux", "agents"); + const globalAgentsRoot = global.path; + + await writeAgent(globalAgentsRoot, "foo", "Foo (global)"); + await writeAgent(projectAgentsRoot, "foo", "Foo (project)"); + + const roots = { projectRoot: projectAgentsRoot, globalRoot: globalAgentsRoot }; + const runtime = new LocalRuntime(project.path); + + const agentId = AgentIdSchema.parse("foo"); + const pkg = await readAgentDefinition(runtime, project.path, agentId, { roots }); + + expect(pkg.scope).toBe("project"); + expect(pkg.frontmatter.name).toBe("Foo (project)"); + }); +}); diff --git a/src/node/services/agentDefinitions/agentDefinitionsService.ts b/src/node/services/agentDefinitions/agentDefinitionsService.ts new file mode 100644 index 0000000000..82426830dd --- /dev/null +++ b/src/node/services/agentDefinitions/agentDefinitionsService.ts @@ -0,0 +1,312 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import type { Runtime } from "@/node/runtime/Runtime"; +import { SSHRuntime } from "@/node/runtime/SSHRuntime"; +import { execBuffered, readFileString } from "@/node/utils/runtime/helpers"; +import { shellQuote } from "@/node/runtime/backgroundCommands"; + +import { + AgentDefinitionDescriptorSchema, + AgentDefinitionPackageSchema, + AgentIdSchema, +} from "@/common/orpc/schemas"; +import type { + AgentDefinitionDescriptor, + AgentDefinitionPackage, + AgentDefinitionScope, + AgentId, +} from "@/common/types/agentDefinition"; +import { log } from "@/node/services/log"; +import { validateFileSize } from "@/node/services/tools/fileCommon"; + +import { getBuiltInAgentDefinitions } from "./builtInAgentDefinitions"; +import { + AgentDefinitionParseError, + parseAgentDefinitionMarkdown, +} from "./parseAgentDefinitionMarkdown"; + +const GLOBAL_AGENTS_ROOT = "~/.mux/agents"; + +export interface AgentDefinitionsRoots { + projectRoot: string; + globalRoot: string; +} + +export function getDefaultAgentDefinitionsRoots( + runtime: Runtime, + workspacePath: string +): AgentDefinitionsRoots { + if (!workspacePath) { + throw new Error("getDefaultAgentDefinitionsRoots: workspacePath is required"); + } + + return { + projectRoot: runtime.normalizePath(".mux/agents", workspacePath), + globalRoot: GLOBAL_AGENTS_ROOT, + }; +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function listAgentFilesFromLocalFs(root: string): Promise { + try { + const entries = await fs.readdir(root, { withFileTypes: true }); + return entries + .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md")) + .map((entry) => entry.name); + } catch { + return []; + } +} + +async function listAgentFilesFromRuntime( + runtime: Runtime, + root: string, + options: { cwd: string } +): Promise { + if (!options.cwd) { + throw new Error("listAgentFilesFromRuntime: options.cwd is required"); + } + + const quotedRoot = shellQuote(root); + const command = + `if [ -d ${quotedRoot} ]; then ` + + `find ${quotedRoot} -mindepth 1 -maxdepth 1 -type f -name '*.md' -exec basename {} \\; ; ` + + `fi`; + + const result = await execBuffered(runtime, command, { cwd: options.cwd, timeout: 10 }); + if (result.exitCode !== 0) { + log.warn(`Failed to read agents directory ${root}: ${result.stderr || result.stdout}`); + return []; + } + + return result.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +function getAgentIdFromFilename(filename: string): AgentId | null { + const parsed = path.parse(filename); + if (parsed.ext.toLowerCase() !== ".md") { + return null; + } + + const idRaw = parsed.name.trim().toLowerCase(); + const idParsed = AgentIdSchema.safeParse(idRaw); + if (!idParsed.success) { + return null; + } + + return idParsed.data; +} + +async function readAgentDescriptorFromFile( + runtime: Runtime, + filePath: string, + agentId: AgentId, + scope: Exclude +): Promise { + let stat; + try { + stat = await runtime.stat(filePath); + } catch { + return null; + } + + if (stat.isDirectory) { + return null; + } + + const sizeValidation = validateFileSize(stat); + if (sizeValidation) { + log.warn(`Skipping agent '${agentId}' (${scope}): ${sizeValidation.error}`); + return null; + } + + let content: string; + try { + content = await readFileString(runtime, filePath); + } catch (err) { + log.warn(`Failed to read agent definition ${filePath}: ${formatError(err)}`); + return null; + } + + try { + const parsed = parseAgentDefinitionMarkdown({ content, byteSize: stat.size }); + + const uiSelectable = parsed.frontmatter.ui?.selectable ?? false; + const subagentRunnable = parsed.frontmatter.subagent?.runnable ?? false; + const policyBase = parsed.frontmatter.policy?.base ?? "exec"; + + const descriptor: AgentDefinitionDescriptor = { + id: agentId, + scope, + name: parsed.frontmatter.name, + description: parsed.frontmatter.description, + uiSelectable, + subagentRunnable, + policyBase, + aiDefaults: parsed.frontmatter.ai, + toolFilter: parsed.frontmatter.policy?.tools, + }; + + const validated = AgentDefinitionDescriptorSchema.safeParse(descriptor); + if (!validated.success) { + log.warn(`Invalid agent definition descriptor for ${agentId}: ${validated.error.message}`); + return null; + } + + return validated.data; + } catch (err) { + const message = err instanceof AgentDefinitionParseError ? err.message : formatError(err); + log.warn(`Skipping invalid agent definition '${agentId}' (${scope}): ${message}`); + return null; + } +} + +export async function discoverAgentDefinitions( + runtime: Runtime, + workspacePath: string, + options?: { roots?: AgentDefinitionsRoots } +): Promise { + if (!workspacePath) { + throw new Error("discoverAgentDefinitions: workspacePath is required"); + } + + const roots = options?.roots ?? getDefaultAgentDefinitionsRoots(runtime, workspacePath); + + const byId = new Map(); + + // Seed built-ins (lowest precedence). + for (const pkg of getBuiltInAgentDefinitions()) { + const uiSelectable = pkg.frontmatter.ui?.selectable ?? false; + const subagentRunnable = pkg.frontmatter.subagent?.runnable ?? false; + const policyBase = pkg.frontmatter.policy?.base ?? "exec"; + + byId.set(pkg.id, { + id: pkg.id, + scope: "built-in", + name: pkg.frontmatter.name, + description: pkg.frontmatter.description, + uiSelectable, + subagentRunnable, + policyBase, + aiDefaults: pkg.frontmatter.ai, + toolFilter: pkg.frontmatter.policy?.tools, + }); + } + + const scans: Array<{ scope: Exclude; root: string }> = [ + { scope: "global", root: roots.globalRoot }, + { scope: "project", root: roots.projectRoot }, + ]; + + for (const scan of scans) { + let resolvedRoot: string; + try { + resolvedRoot = await runtime.resolvePath(scan.root); + } catch (err) { + log.warn(`Failed to resolve agents root ${scan.root}: ${formatError(err)}`); + continue; + } + + const filenames = + runtime instanceof SSHRuntime + ? await listAgentFilesFromRuntime(runtime, resolvedRoot, { cwd: workspacePath }) + : await listAgentFilesFromLocalFs(resolvedRoot); + + for (const filename of filenames) { + const agentId = getAgentIdFromFilename(filename); + if (!agentId) { + log.warn(`Skipping invalid agent filename '${filename}' in ${resolvedRoot}`); + continue; + } + + const filePath = runtime.normalizePath(filename, resolvedRoot); + const descriptor = await readAgentDescriptorFromFile(runtime, filePath, agentId, scan.scope); + if (!descriptor) continue; + + byId.set(agentId, descriptor); + } + } + + return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name)); +} + +export async function readAgentDefinition( + runtime: Runtime, + workspacePath: string, + agentId: AgentId, + options?: { roots?: AgentDefinitionsRoots } +): Promise { + if (!workspacePath) { + throw new Error("readAgentDefinition: workspacePath is required"); + } + + const roots = options?.roots ?? getDefaultAgentDefinitionsRoots(runtime, workspacePath); + + // Precedence: project overrides global overrides built-in. + const candidates: Array<{ scope: Exclude; root: string }> = [ + { scope: "project", root: roots.projectRoot }, + { scope: "global", root: roots.globalRoot }, + ]; + + for (const candidate of candidates) { + let resolvedRoot: string; + try { + resolvedRoot = await runtime.resolvePath(candidate.root); + } catch { + continue; + } + + const filePath = runtime.normalizePath(`${agentId}.md`, resolvedRoot); + + try { + const stat = await runtime.stat(filePath); + if (stat.isDirectory) { + continue; + } + + const sizeValidation = validateFileSize(stat); + if (sizeValidation) { + throw new Error(sizeValidation.error); + } + + const content = await readFileString(runtime, filePath); + const parsed = parseAgentDefinitionMarkdown({ content, byteSize: stat.size }); + + const pkg: AgentDefinitionPackage = { + id: agentId, + scope: candidate.scope, + frontmatter: parsed.frontmatter, + body: parsed.body, + }; + + const validated = AgentDefinitionPackageSchema.safeParse(pkg); + if (!validated.success) { + throw new Error( + `Invalid agent definition package for '${agentId}' (${candidate.scope}): ${validated.error.message}` + ); + } + + return validated.data; + } catch { + continue; + } + } + + const builtIn = getBuiltInAgentDefinitions().find((pkg) => pkg.id === agentId); + if (builtIn) { + const validated = AgentDefinitionPackageSchema.safeParse(builtIn); + if (!validated.success) { + throw new Error(`Invalid built-in agent definition '${agentId}': ${validated.error.message}`); + } + return validated.data; + } + + throw new Error(`Agent definition not found: ${agentId}`); +} diff --git a/src/node/services/agentDefinitions/builtInAgentDefinitions.ts b/src/node/services/agentDefinitions/builtInAgentDefinitions.ts new file mode 100644 index 0000000000..8377f59da6 --- /dev/null +++ b/src/node/services/agentDefinitions/builtInAgentDefinitions.ts @@ -0,0 +1,107 @@ +import type { AgentDefinitionPackage } from "@/common/types/agentDefinition"; + +const BUILT_IN_PACKAGES: AgentDefinitionPackage[] = [ + { + id: "plan", + scope: "built-in", + frontmatter: { + name: "Plan", + description: "Create a plan before coding", + ui: { selectable: true }, + subagent: { runnable: false }, + policy: { base: "plan" }, + }, + body: [ + "You are in Plan Mode.", + "", + "- Produce a crisp, actionable plan before making code changes.", + "- Keep the plan scannable; put long rationale in
/ blocks.", + "", + "Note: mux will provide a concrete plan file path separately.", + ].join("\n"), + }, + { + id: "exec", + scope: "built-in", + frontmatter: { + name: "Exec", + description: "Implement changes in the repository", + ui: { selectable: true }, + subagent: { runnable: true }, + policy: { base: "exec" }, + }, + body: [ + "You are in Exec mode.", + "", + "- Make minimal, correct, reviewable changes that match existing codebase patterns.", + "- Prefer targeted commands and checks (typecheck/tests) when feasible.", + "", + "If you are running as a sub-agent in a child workspace:", + "- When you have a final answer, call agent_report exactly once.", + "- Do not call task/task_await/task_list/task_terminate (subagent recursion is disabled).", + "- Do not call propose_plan.", + ].join("\n"), + }, + { + id: "compact", + scope: "built-in", + frontmatter: { + name: "Compact", + description: "History compaction (internal)", + ui: { selectable: false }, + subagent: { runnable: false }, + policy: { base: "compact" }, + }, + body: "You are running a compaction/summarization pass. Do not call tools.", + }, + { + id: "explore", + scope: "built-in", + frontmatter: { + name: "Explore", + description: "Read-only repository exploration", + ui: { selectable: false }, + subagent: { runnable: true }, + policy: { + base: "exec", + tools: { + only: [ + "file_read", + "bash", + "bash_output", + "bash_background_list", + "bash_background_terminate", + "web_fetch", + "web_search", + "google_search", + "agent_report", + ], + }, + }, + }, + body: [ + "You are an Explore sub-agent running inside a child workspace.", + "", + "Goals:", + "- Explore the repository to answer the prompt using read-only investigation.", + "- Return concise, actionable findings (paths, symbols, callsites, and facts).", + "", + "Rules:", + "=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===", + "- You MUST NOT create, edit, delete, move, or copy files.", + "- You MUST NOT create temporary files anywhere (including /tmp).", + "- You MUST NOT use redirect operators (>, >>, |) or heredocs to write to files.", + "- You MUST NOT run commands that change system state (rm, mv, cp, mkdir, touch, git add/commit, installs, etc.).", + "- Use bash only for read-only operations (rg, ls, cat, git diff/show/log, etc.).", + "- Do not call task/task_await/task_list/task_terminate (subagent recursion is disabled).", + "", + "Reporting:", + "- When you have a final answer, call agent_report exactly once.", + "- Do not call agent_report until you have completed the assigned task and integrated all relevant findings.", + ].join("\n"), + }, +]; + +export function getBuiltInAgentDefinitions(): AgentDefinitionPackage[] { + return BUILT_IN_PACKAGES; +} diff --git a/src/node/services/agentDefinitions/parseAgentDefinitionMarkdown.test.ts b/src/node/services/agentDefinitions/parseAgentDefinitionMarkdown.test.ts new file mode 100644 index 0000000000..91cb876494 --- /dev/null +++ b/src/node/services/agentDefinitions/parseAgentDefinitionMarkdown.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; + +import { + AgentDefinitionParseError, + parseAgentDefinitionMarkdown, +} from "./parseAgentDefinitionMarkdown"; + +describe("parseAgentDefinitionMarkdown", () => { + test("parses valid YAML frontmatter and body", () => { + const content = `--- +name: My Agent +description: Does stuff +ui: + selectable: true +policy: + base: exec +--- +# Instructions +Do the thing. +`; + + const result = parseAgentDefinitionMarkdown({ + content, + byteSize: Buffer.byteLength(content, "utf-8"), + }); + + expect(result.frontmatter.name).toBe("My Agent"); + expect(result.frontmatter.description).toBe("Does stuff"); + expect(result.frontmatter.ui?.selectable).toBe(true); + expect(result.frontmatter.policy?.base).toBe("exec"); + expect(result.body).toContain("# Instructions"); + }); + + test("throws on missing frontmatter", () => { + expect(() => + parseAgentDefinitionMarkdown({ + content: "# No frontmatter\n", + byteSize: 14, + }) + ).toThrow(AgentDefinitionParseError); + }); + + test("throws when policy.tools specifies both deny and only", () => { + const content = `--- +name: Bad Agent +policy: + base: exec + tools: + deny: ["file_read"] + only: ["bash"] +--- +Body +`; + + expect(() => + parseAgentDefinitionMarkdown({ + content, + byteSize: Buffer.byteLength(content, "utf-8"), + }) + ).toThrow(AgentDefinitionParseError); + }); +}); diff --git a/src/node/services/agentDefinitions/parseAgentDefinitionMarkdown.ts b/src/node/services/agentDefinitions/parseAgentDefinitionMarkdown.ts new file mode 100644 index 0000000000..a4686ed557 --- /dev/null +++ b/src/node/services/agentDefinitions/parseAgentDefinitionMarkdown.ts @@ -0,0 +1,106 @@ +import { AgentDefinitionFrontmatterSchema } from "@/common/orpc/schemas"; +import type { AgentDefinitionFrontmatter } from "@/common/types/agentDefinition"; +import { MAX_FILE_SIZE } from "@/node/services/tools/fileCommon"; +import YAML from "yaml"; + +export class AgentDefinitionParseError extends Error { + constructor(message: string) { + super(message); + this.name = "AgentDefinitionParseError"; + } +} + +export interface ParsedAgentDefinitionMarkdown { + frontmatter: AgentDefinitionFrontmatter; + body: string; +} + +function normalizeNewlines(input: string): string { + return input.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} + +function stripUtf8Bom(input: string): string { + return input.startsWith("\uFEFF") ? input.slice(1) : input; +} + +function assertObject(value: unknown, message: string): asserts value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new AgentDefinitionParseError(message); + } +} + +function formatZodIssues( + issues: ReadonlyArray<{ path: readonly PropertyKey[]; message: string }> +): string { + return issues + .map((issue) => { + const issuePath = + issue.path.length > 0 ? issue.path.map((part) => String(part)).join(".") : ""; + return `${issuePath}: ${issue.message}`; + }) + .join("; "); +} + +/** + * Parse an agent definition markdown file into validated YAML frontmatter + markdown body. + * + * Defensive constraints: + * - Enforces the shared 1MB max file size + * - Requires YAML frontmatter delimited by `---` on its own line at the top + */ +export function parseAgentDefinitionMarkdown(input: { + content: string; + byteSize: number; +}): ParsedAgentDefinitionMarkdown { + if (input.byteSize > MAX_FILE_SIZE) { + const sizeMB = (input.byteSize / (1024 * 1024)).toFixed(2); + const maxMB = (MAX_FILE_SIZE / (1024 * 1024)).toFixed(2); + throw new AgentDefinitionParseError( + `Agent definition is too large (${sizeMB}MB). Maximum supported size is ${maxMB}MB.` + ); + } + + const content = normalizeNewlines(stripUtf8Bom(input.content)); + + if (!content.startsWith("---")) { + throw new AgentDefinitionParseError( + "Agent definition must start with YAML frontmatter delimited by '---'." + ); + } + + const lines = content.split("\n"); + if ((lines[0] ?? "").trim() !== "---") { + throw new AgentDefinitionParseError( + "Agent definition frontmatter start delimiter must be exactly '---'." + ); + } + + const endIndex = lines.findIndex((line, idx) => idx > 0 && line.trim() === "---"); + if (endIndex === -1) { + throw new AgentDefinitionParseError( + "Agent definition frontmatter is missing the closing '---' delimiter." + ); + } + + const yamlText = lines.slice(1, endIndex).join("\n"); + const body = lines.slice(endIndex + 1).join("\n"); + + let raw: unknown; + try { + raw = YAML.parse(yamlText); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new AgentDefinitionParseError(`Failed to parse YAML frontmatter: ${message}`); + } + + assertObject(raw, "Agent definition YAML frontmatter must be a mapping/object."); + + const parsed = AgentDefinitionFrontmatterSchema.safeParse(raw); + if (!parsed.success) { + throw new AgentDefinitionParseError( + `Invalid agent definition frontmatter: ${formatZodIssues(parsed.error.issues)}` + ); + } + + return { frontmatter: parsed.data, body }; +} diff --git a/src/node/services/agentDefinitions/resolveToolPolicy.ts b/src/node/services/agentDefinitions/resolveToolPolicy.ts new file mode 100644 index 0000000000..33fb1530f5 --- /dev/null +++ b/src/node/services/agentDefinitions/resolveToolPolicy.ts @@ -0,0 +1,87 @@ +import type { AgentMode } from "@/common/types/mode"; +import type { AgentDefinitionFrontmatter } from "@/common/types/agentDefinition"; +import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; + +export interface ResolveToolPolicyOptions { + base: AgentMode; + frontmatter: AgentDefinitionFrontmatter; + isSubagent: boolean; + disableTaskToolsForDepth: boolean; +} + +const SUBAGENT_HARD_DENY: ToolPolicy = [ + { regex_match: "task", action: "disable" }, + { regex_match: "task_.*", action: "disable" }, + { regex_match: "propose_plan", action: "disable" }, + { regex_match: "ask_user_question", action: "disable" }, +]; + +const DEPTH_HARD_DENY: ToolPolicy = [ + { regex_match: "task", action: "disable" }, + { regex_match: "task_.*", action: "disable" }, +]; + +function normalizeToolName(value: string): string | null { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function buildPolicyFromToolFilter(args: { + base: AgentMode; + filter: NonNullable["tools"]>; +}): ToolPolicy { + if (args.base === "compact") { + // Compact baseline is already "no tools". Do not allow overrides. + return [{ regex_match: ".*", action: "disable" }]; + } + + // Baseline restrictions that must never be re-enabled. + const baselineDenied: string[] = args.base === "exec" ? ["propose_plan"] : []; + + const deny = args.filter.deny?.map(normalizeToolName).filter(Boolean) as string[]; + const only = args.filter.only?.map(normalizeToolName).filter(Boolean) as string[]; + + if (only && only.length > 0) { + const allowed = only.filter((name) => !baselineDenied.includes(name)); + return [ + { regex_match: ".*", action: "disable" }, + ...allowed.map((name) => ({ regex_match: name, action: "enable" as const })), + ]; + } + + const policy: ToolPolicy = []; + + for (const name of deny ?? []) { + policy.push({ regex_match: name, action: "disable" }); + } + + // Apply baseline denies last so callers cannot re-enable them. + for (const name of baselineDenied) { + policy.push({ regex_match: name, action: "disable" }); + } + + return policy; +} + +export function resolveToolPolicyForAgent(options: ResolveToolPolicyOptions): ToolPolicy { + const base = options.base; + + // Compact is an internal no-tools flow. + if (base === "compact") { + return [{ regex_match: ".*", action: "disable" }]; + } + + // Start with agent-specific filter policy. + const agentPolicy: ToolPolicy = options.frontmatter.policy?.tools + ? buildPolicyFromToolFilter({ base, filter: options.frontmatter.policy.tools }) + : // Baseline: exec disables propose_plan; plan allows all tools. + base === "exec" + ? [{ regex_match: "propose_plan", action: "disable" as const }] + : []; + + const depthPolicy: ToolPolicy = options.disableTaskToolsForDepth ? DEPTH_HARD_DENY : []; + const subagentPolicy: ToolPolicy = options.isSubagent ? SUBAGENT_HARD_DENY : []; + + // IMPORTANT: depth + subagent policies must be applied last. + return [...agentPolicy, ...depthPolicy, ...subagentPolicy]; +} diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 0d5afcddec..f9946409fa 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -661,6 +661,7 @@ export class AgentSession { options?.maxOutputTokens, options?.providerOptions, options?.mode, + options?.agentId, recordFileState, changedFileAttachments.length > 0 ? changedFileAttachments : undefined, postCompactionAttachments, diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 38f508c9fd..3a0730f7dc 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -9,6 +9,7 @@ import { sanitizeToolInputs } from "@/browser/utils/messages/sanitizeToolInput"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; import type { WorkspaceMetadata } from "@/common/types/workspace"; +import { AgentIdSchema } from "@/common/orpc/schemas"; import { PROVIDER_REGISTRY, PROVIDER_DEFINITIONS, @@ -73,7 +74,8 @@ import { getPlanModeInstruction } from "@/common/utils/ui/modeUtils"; import type { AgentMode, UIMode } from "@/common/types/mode"; import { MUX_APP_ATTRIBUTION_TITLE, MUX_APP_ATTRIBUTION_URL } from "@/constants/appAttribution"; import { readPlanFile } from "@/node/utils/runtime/helpers"; -import { getAgentPreset } from "@/node/services/agentPresets"; +import { readAgentDefinition } from "@/node/services/agentDefinitions/agentDefinitionsService"; +import { resolveToolPolicyForAgent } from "@/node/services/agentDefinitions/resolveToolPolicy"; // Export a standalone version of getToolsForModel for use in backend @@ -1007,6 +1009,7 @@ export class AIService extends EventEmitter { maxOutputTokens?: number, muxProviderOptions?: MuxProviderOptions, mode?: AgentMode, + agentId?: string, recordFileState?: (filePath: string, state: FileState) => void, changedFileAttachments?: EditedFileAttachment[], postCompactionAttachments?: PostCompactionAttachment[] | null, @@ -1027,8 +1030,7 @@ export class AIService extends EventEmitter { // This is idempotent - won't double-commit if already in chat.jsonl await this.partialService.commitToHistory(workspaceId); - const uiMode: UIMode | undefined = - mode === "plan" ? "plan" : mode === "exec" ? "exec" : undefined; + // Mode (plan|exec|compact) is derived from the selected agent definition. const effectiveMuxProviderOptions: MuxProviderOptions = muxProviderOptions ?? {}; // For xAI models, swap between reasoning and non-reasoning variants based on thinkingLevel @@ -1061,24 +1063,9 @@ export class AIService extends EventEmitter { // Use the provider name already extracted above (providerName variable) - // Get tool names early for mode transition sentinel (stub config, no workspace context needed) - const earlyRuntime = createRuntime({ type: "local", srcBaseDir: process.cwd() }); - const earlyAllTools = await getToolsForModel( - modelString, - { - cwd: process.cwd(), - runtime: earlyRuntime, - runtimeTempDir: os.tmpdir(), - secrets: {}, - mode: uiMode, - }, - "", // Empty workspace ID for early stub config - this.initStateManager, - undefined, - undefined - ); - const earlyTools = applyToolPolicy(earlyAllTools, toolPolicy); - const toolNamesForSentinel = Object.keys(earlyTools); + // Tool names are needed for the mode transition sentinel injection. + // Compute them once we know the effective agent + tool policy. + let toolNamesForSentinel: string[] = []; // Filter out assistant messages with only reasoning (no text/tools) // EXCEPTION: When extended thinking is enabled, preserve reasoning-only messages @@ -1127,6 +1114,75 @@ export class AIService extends EventEmitter { ? metadata.projectPath : runtime.getWorkspacePath(metadata.projectPath, metadata.name); + // Resolve the active agent definition. + // + // Precedence: + // - Child workspaces (tasks) use their persisted agentId/agentType. + // - Main workspaces use the requested agentId (frontend), falling back to legacy mode. + const requestedAgentIdRaw = + (metadata.parentWorkspaceId ? (metadata.agentId ?? metadata.agentType) : undefined) ?? + (typeof agentId === "string" ? agentId : undefined) ?? + (typeof mode === "string" ? mode : undefined) ?? + "exec"; + const requestedAgentIdNormalized = requestedAgentIdRaw.trim().toLowerCase(); + const parsedAgentId = AgentIdSchema.safeParse(requestedAgentIdNormalized); + const effectiveAgentId = parsedAgentId.success ? parsedAgentId.data : ("exec" as const); + + let agentDefinition; + try { + agentDefinition = await readAgentDefinition(runtime, workspacePath, effectiveAgentId); + } catch (error) { + log.warn("Failed to load agent definition; falling back to exec", { + workspaceId, + effectiveAgentId, + error: error instanceof Error ? error.message : String(error), + }); + agentDefinition = await readAgentDefinition(runtime, workspacePath, "exec"); + } + + const effectiveMode: AgentMode = agentDefinition.frontmatter.policy?.base ?? "exec"; + const uiMode: UIMode | undefined = + effectiveMode === "plan" ? "plan" : effectiveMode === "exec" ? "exec" : undefined; + + const cfg = this.config.loadConfigOrDefault(); + const taskSettings = cfg.taskSettings ?? DEFAULT_TASK_SETTINGS; + const taskDepth = getTaskDepthFromConfig(cfg, workspaceId); + const shouldDisableTaskToolsForDepth = taskDepth >= taskSettings.maxTaskNestingDepth; + + const isSubagentWorkspace = Boolean(metadata.parentWorkspaceId); + + // NOTE: Agent tool policy is applied after any caller-supplied policy so callers cannot + // broaden the tool set (e.g., re-enable propose_plan in exec mode). + const agentToolPolicy = resolveToolPolicyForAgent({ + base: effectiveMode, + frontmatter: agentDefinition.frontmatter, + isSubagent: isSubagentWorkspace, + disableTaskToolsForDepth: shouldDisableTaskToolsForDepth, + }); + const effectiveToolPolicy: ToolPolicy | undefined = + toolPolicy || agentToolPolicy.length > 0 + ? [...(toolPolicy ?? []), ...agentToolPolicy] + : undefined; + + // Compute tool names for mode transition sentinel. + const earlyRuntime = createRuntime({ type: "local", srcBaseDir: process.cwd() }); + const earlyAllTools = await getToolsForModel( + modelString, + { + cwd: process.cwd(), + runtime: earlyRuntime, + runtimeTempDir: os.tmpdir(), + secrets: {}, + mode: uiMode, + }, + "", // Empty workspace ID for early stub config + this.initStateManager, + undefined, + undefined + ); + const earlyTools = applyToolPolicy(earlyAllTools, effectiveToolPolicy); + toolNamesForSentinel = Object.keys(earlyTools); + // Fetch workspace MCP overrides (for filtering servers and tools) const mcpOverrides = this.config.getWorkspaceMCPOverrides(workspaceId); @@ -1149,17 +1205,26 @@ export class AIService extends EventEmitter { workspaceId ); - if (mode === "plan") { + if (effectiveMode === "plan") { const planModeInstruction = getPlanModeInstruction(planFilePath, planResult.exists); effectiveAdditionalInstructions = additionalSystemInstructions ? `${planModeInstruction}\n\n${additionalSystemInstructions}` : planModeInstruction; } + if (shouldDisableTaskToolsForDepth) { + const nestingInstruction = + `Task delegation is disabled in this workspace (taskDepth=${taskDepth}, ` + + `maxTaskNestingDepth=${taskSettings.maxTaskNestingDepth}). Do not call task/task_await/task_list/task_terminate.`; + effectiveAdditionalInstructions = effectiveAdditionalInstructions + ? `${effectiveAdditionalInstructions}\n\n${nestingInstruction}` + : nestingInstruction; + } + // Read plan content for mode transition (plan → exec) // Only read if switching to exec mode and last assistant was in plan mode let planContentForTransition: string | undefined; - if (mode === "exec") { + if (effectiveMode === "exec") { const lastAssistantMessage = [...filteredMessages] .reverse() .find((m) => m.role === "assistant"); @@ -1171,7 +1236,7 @@ export class AIService extends EventEmitter { // Now inject mode transition context with plan content (runtime is now available) const messagesWithModeContext = injectModeTransition( messagesWithSentinel, - mode, + effectiveMode, toolNamesForSentinel, planContentForTransition ); @@ -1245,34 +1310,19 @@ export class AIService extends EventEmitter { } } - const cfg = this.config.loadConfigOrDefault(); - const taskSettings = cfg.taskSettings ?? DEFAULT_TASK_SETTINGS; - const taskDepth = getTaskDepthFromConfig(cfg, workspaceId); - const shouldDisableTaskToolsForDepth = taskDepth >= taskSettings.maxTaskNestingDepth; - - const agentPreset = getAgentPreset(metadata.agentType); - const agentSystemPrompt = agentPreset - ? shouldDisableTaskToolsForDepth - ? [ - agentPreset.systemPrompt, - "", - "Nesting:", - `- Task delegation is disabled in this workspace (taskDepth=${taskDepth}, maxTaskNestingDepth=${taskSettings.maxTaskNestingDepth}).`, - "- Do not call task/task_await/task_list/task_terminate.", - ].join("\n") - : agentPreset.systemPrompt - : undefined; - // Build system message from workspace metadata const systemMessage = await buildSystemMessage( metadata, runtime, workspacePath, - mode, + effectiveMode, effectiveAdditionalInstructions, modelString, mcpServers, - agentSystemPrompt ? { variant: "agent", agentSystemPrompt } : undefined + { + agentId: effectiveAgentId, + agentSystemPrompt: agentDefinition.body, + } ); // Count system message tokens for cost tracking @@ -1365,18 +1415,7 @@ export class AIService extends EventEmitter { mcpTools ); - const depthToolPolicy: ToolPolicy = shouldDisableTaskToolsForDepth - ? [ - { regex_match: "task", action: "disable" }, - { regex_match: "task_.*", action: "disable" }, - ] - : []; - - // Preset + depth tool policies must be applied last so callers cannot re-enable restricted tools. - const effectiveToolPolicy = - agentPreset || depthToolPolicy.length > 0 - ? [...(toolPolicy ?? []), ...(agentPreset?.toolPolicy ?? []), ...depthToolPolicy] - : toolPolicy; + // NOTE: effectiveToolPolicy is derived from the selected agent definition (plus hard-denies). // Apply tool policy FIRST - this must happen before PTC to ensure sandbox // respects allow/deny filters. The policy-filtered tools are passed to @@ -1455,7 +1494,8 @@ export class AIService extends EventEmitter { properties: { workspaceId, model: modelString, - mode: mode ?? uiMode ?? "exec", + mode: effectiveMode, + agentId: effectiveAgentId, runtimeType: getRuntimeTypeForTelemetry(metadata.runtimeConfig), mcp_server_enabled_count: effectiveMcpStats.enabledServerCount, @@ -1488,7 +1528,7 @@ export class AIService extends EventEmitter { timestamp: Date.now(), model: modelString, systemMessageTokens, - mode, // Track the mode for this assistant response + mode: effectiveMode, // Track the base mode for this assistant response }); // Append to history to get historySequence assigned @@ -1555,7 +1595,7 @@ export class AIService extends EventEmitter { timestamp: Date.now(), model: modelString, systemMessageTokens, - toolPolicy, + toolPolicy: effectiveToolPolicy, }); const parts: StreamEndEvent["parts"] = [ @@ -1656,8 +1696,9 @@ export class AIService extends EventEmitter { providerOptions, thinkingLevel, maxOutputTokens, - mode, - toolPolicy, + mode: effectiveMode, + agentId: effectiveAgentId, + toolPolicy: effectiveToolPolicy, }; log.info( `[MUX_DEBUG_LLM_REQUEST] Full LLM request:\n${JSON.stringify(llmRequest, null, 2)}` @@ -1678,11 +1719,11 @@ export class AIService extends EventEmitter { { systemMessageTokens, timestamp: Date.now(), - mode, // Pass mode so it persists in final history entry + mode: effectiveMode, // Pass base mode so it persists in final history entry }, providerOptions, maxOutputTokens, - toolPolicy, + effectiveToolPolicy, streamToken // Pass the pre-generated stream token ); diff --git a/src/node/services/systemMessage.ts b/src/node/services/systemMessage.ts index f8c4db4c8e..104732f7f3 100644 --- a/src/node/services/systemMessage.ts +++ b/src/node/services/systemMessage.ts @@ -5,6 +5,7 @@ import { readInstructionSetFromRuntime, } from "@/node/utils/main/instructionFiles"; import { + extractAgentSection, extractModeSection, extractModelSection, extractToolSection, @@ -256,7 +257,7 @@ export async function buildSystemMessage( modelString?: string, mcpServers?: MCPServerMap, options?: { - variant?: "default" | "agent"; + agentId?: string; agentSystemPrompt?: string; } ): Promise { @@ -275,12 +276,12 @@ export async function buildSystemMessage( systemMessage += buildMCPContext(mcpServers); } - if (options?.variant === "agent") { - const agentPrompt = options.agentSystemPrompt?.trim(); - if (agentPrompt) { - systemMessage += `\n\n${agentPrompt}\n`; - } - return systemMessage; + // Add agent skills context (if any) + systemMessage += await buildAgentSkillsContext(runtime, workspacePath); + + const agentPrompt = options?.agentSystemPrompt?.trim(); + if (agentPrompt) { + systemMessage += `\n\n${agentPrompt}\n`; } // Read instruction sets @@ -303,6 +304,16 @@ export async function buildSystemMessage( ].filter((value): value is string => Boolean(value)); const customInstructions = customInstructionSources.join("\n\n"); + // Extract agent-specific section (context first, then global fallback) + const agentId = options?.agentId; + let agentContent: string | null = null; + if (agentId) { + agentContent = + (contextInstructions && extractAgentSection(contextInstructions, agentId)) ?? + (globalInstructions && extractAgentSection(globalInstructions, agentId)) ?? + null; + } + // Extract mode-specific section (context first, then global fallback) let modeContent: string | null = null; if (mode) { @@ -326,6 +337,13 @@ export async function buildSystemMessage( } const modeSection = buildTaggedSection(modeContent, mode, "mode"); + if (agentId) { + const agentSection = buildTaggedSection(agentContent, `agent-${agentId}`, "agent"); + if (agentSection) { + systemMessage += agentSection; + } + } + if (modeSection) { systemMessage += modeSection; } diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 58082a0e13..aae9d85d57 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -9,6 +9,7 @@ import type { HistoryService } from "@/node/services/historyService"; import type { PartialService } from "@/node/services/partialService"; import type { InitStateManager } from "@/node/services/initStateManager"; import { log } from "@/node/services/log"; +import { readAgentDefinition } from "@/node/services/agentDefinitions/agentDefinitionsService"; import { createRuntime } from "@/node/runtime/runtimeFactory"; import type { InitLogger, WorkspaceCreationResult } from "@/node/runtime/Runtime"; import { validateWorkspaceName } from "@/common/utils/validation/workspaceValidation"; @@ -18,6 +19,7 @@ import { DEFAULT_TASK_SETTINGS } from "@/common/types/tasks"; import { createMuxMessage, type MuxMessage } from "@/common/types/message"; import { defaultModel, normalizeGatewayModel } from "@/common/utils/ai/models"; import type { RuntimeConfig } from "@/common/types/runtime"; +import { AgentIdSchema } from "@/common/orpc/schemas"; import type { ThinkingLevel } from "@/common/types/thinking"; import type { ToolCallEndEvent, StreamEndEvent } from "@/common/types/stream"; import { isDynamicToolPart, type DynamicToolPart } from "@/common/types/toolParts"; @@ -37,7 +39,10 @@ export type AgentTaskStatus = NonNullable; export interface TaskCreateArgs { parentWorkspaceId: string; kind: TaskKind; - agentType: string; + /** Preferred identifier (matches agent definition id). */ + agentId?: string; + /** @deprecated Legacy alias for agentId (kept for on-disk compatibility). */ + agentType?: string; prompt: string; /** Human-readable title for the task (displayed in sidebar) */ title: string; @@ -326,11 +331,20 @@ export class TaskService { return Err("Task.create: prompt is required"); } - const agentType = coerceNonEmptyString(args.agentType); - if (!agentType) { - return Err("Task.create: agentType is required"); + const agentIdRaw = coerceNonEmptyString(args.agentId ?? args.agentType); + if (!agentIdRaw) { + return Err("Task.create: agentId is required"); + } + + const normalizedAgentId = agentIdRaw.trim().toLowerCase(); + const parsedAgentId = AgentIdSchema.safeParse(normalizedAgentId); + if (!parsedAgentId.success) { + return Err(`Task.create: invalid agentId (${normalizedAgentId})`); } + const agentId = parsedAgentId.data; + const agentType = agentId; // Legacy alias for on-disk compatibility. + await using _lock = await this.mutex.acquire(); // Validate parent exists and fetch runtime context. @@ -361,7 +375,7 @@ export class TaskService { const shouldQueue = activeCount >= taskSettings.maxParallelAgentTasks; const taskId = this.config.generateStableId(); - const workspaceName = buildAgentWorkspaceName(agentType, taskId); + const workspaceName = buildAgentWorkspaceName(agentId, taskId); const nameValidation = validateWorkspaceName(workspaceName); if (!nameValidation.valid) { @@ -377,8 +391,7 @@ export class TaskService { const inheritedThinkingLevel: ThinkingLevel = args.thinkingLevel ?? parentMeta.aiSettings?.thinkingLevel ?? "off"; - const normalizedAgentType = agentType.trim().toLowerCase(); - const subagentDefaults = cfg.subagentAiDefaults?.[normalizedAgentType]; + const subagentDefaults = cfg.agentAiDefaults?.[agentId] ?? cfg.subagentAiDefaults?.[agentId]; const taskModelString = subagentDefaults?.modelString ?? inheritedModelString; const canonicalModel = normalizeGatewayModel(taskModelString).trim(); @@ -393,12 +406,27 @@ export class TaskService { projectPath: parentMeta.projectPath, }); + // Validate the agent definition exists and is runnable as a sub-agent. + const isInPlace = parentMeta.projectPath === parentMeta.name; + const parentWorkspacePath = isInPlace + ? parentMeta.projectPath + : runtime.getWorkspacePath(parentMeta.projectPath, parentMeta.name); + + try { + const definition = await readAgentDefinition(runtime, parentWorkspacePath, agentId); + if (definition.frontmatter.subagent?.runnable !== true) { + return Err(`Task.create: agentId is not runnable as a sub-agent (${agentId})`); + } + } catch { + return Err(`Task.create: unknown agentId (${agentId})`); + } + const createdAt = getIsoNow(); taskQueueDebug("TaskService.create decision", { parentWorkspaceId, taskId, - agentType, + agentId, workspaceName, createdAt, activeCount, @@ -445,6 +473,7 @@ export class TaskService { runtimeConfig: taskRuntimeConfig, aiSettings: { model: canonicalModel, thinkingLevel: effectiveThinkingLevel }, parentWorkspaceId, + agentId, agentType, taskStatus: "queued", taskPrompt: prompt, @@ -529,6 +558,7 @@ export class TaskService { createdAt, runtimeConfig: taskRuntimeConfig, aiSettings: { model: canonicalModel, thinkingLevel: effectiveThinkingLevel }, + agentId, parentWorkspaceId, agentType, taskStatus: "running", diff --git a/src/node/services/tools/task.ts b/src/node/services/tools/task.ts index 68500fb70e..e6da0fced4 100644 --- a/src/node/services/tools/task.ts +++ b/src/node/services/tools/task.ts @@ -18,9 +18,14 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { throw new Error("Interrupted"); } + const requestedAgentId = + typeof args.agentId === "string" && args.agentId.trim().length > 0 + ? args.agentId + : args.subagent_type; + // Plan mode is explicitly non-executing. Allow only read-only exploration tasks. - if (config.mode === "plan" && args.subagent_type === "exec") { - throw new Error('In Plan Mode you may only spawn subagent_type: "explore" tasks.'); + if (config.mode === "plan" && requestedAgentId !== "explore") { + throw new Error('In Plan Mode you may only spawn agentId: "explore" tasks.'); } const modelString = @@ -32,7 +37,9 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { const created = await taskService.create({ parentWorkspaceId: workspaceId, kind: "agent", - agentType: args.subagent_type, + agentId: requestedAgentId, + // Legacy alias (persisted for older clients / on-disk compatibility). + agentType: requestedAgentId, prompt: args.prompt, title: args.title, modelString, @@ -63,7 +70,8 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { taskId: created.data.taskId, reportMarkdown: report.reportMarkdown, title: report.title, - agentType: args.subagent_type, + agentId: requestedAgentId, + agentType: requestedAgentId, }, "task" ); diff --git a/src/node/utils/main/markdown.ts b/src/node/utils/main/markdown.ts index 14cc88c0ff..c6119f92ec 100644 --- a/src/node/utils/main/markdown.ts +++ b/src/node/utils/main/markdown.ts @@ -79,6 +79,19 @@ function removeSectionsByHeading(markdown: string, headingMatcher: HeadingMatche /** * Extract the content under a heading titled "Mode: " (case-insensitive). */ + +/** + * Extract the content under a heading titled "Agent: " (case-insensitive). + */ +export function extractAgentSection(markdown: string, agentId: string): string | null { + if (!markdown || !agentId) return null; + + const expectedHeading = `agent: ${agentId}`.toLowerCase(); + return extractSectionByHeading( + markdown, + (headingText) => headingText.toLowerCase() === expectedHeading + ); +} export function extractModeSection(markdown: string, mode: string): string | null { if (!markdown || !mode) return null; @@ -149,6 +162,7 @@ export function stripScopedInstructionSections(markdown: string): string { return removeSectionsByHeading(markdown, (headingText) => { const normalized = headingText.trim().toLowerCase(); return ( + normalized.startsWith("agent:") || normalized.startsWith("mode:") || normalized.startsWith("model:") || normalized.startsWith("tool:") From 57ea1e52bc0049bd6de2088448645479839e3f4f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 23 Dec 2025 21:19:39 +0100 Subject: [PATCH 6/7] fix: remove missing skill schema imports Change-Id: If374623ddec9e20c953430e43e41146745a01529 Signed-off-by: Thomas Kosiewski --- src/common/utils/tools/toolDefinitions.ts | 2 +- src/node/services/systemMessage.ts | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index 1a93ae237c..40432abe68 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -6,7 +6,7 @@ */ import { z } from "zod"; -import { AgentIdSchema, AgentSkillPackageSchema, SkillNameSchema } from "@/common/orpc/schemas"; +import { AgentIdSchema } from "@/common/orpc/schemas"; import { BASH_HARD_MAX_LINES, BASH_MAX_LINE_BYTES, diff --git a/src/node/services/systemMessage.ts b/src/node/services/systemMessage.ts index 104732f7f3..378d9e6d41 100644 --- a/src/node/services/systemMessage.ts +++ b/src/node/services/systemMessage.ts @@ -276,9 +276,6 @@ export async function buildSystemMessage( systemMessage += buildMCPContext(mcpServers); } - // Add agent skills context (if any) - systemMessage += await buildAgentSkillsContext(runtime, workspacePath); - const agentPrompt = options?.agentSystemPrompt?.trim(); if (agentPrompt) { systemMessage += `\n\n${agentPrompt}\n`; From 08f28f4c33cf12b71b8f28264533b8d490d0496c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 23 Dec 2025 22:11:48 +0100 Subject: [PATCH 7/7] fix: stabilize bun tests for timers and module mocks Change-Id: Ib691f6831627e0e03ecfb26339a6bd9b4a4c310c Signed-off-by: Thomas Kosiewski --- src/browser/contexts/API.test.tsx | 1 + src/browser/hooks/useVoiceInput.ts | 9 ++-- src/browser/utils/RefreshController.test.ts | 59 ++++++++++----------- src/node/services/tools/task.test.ts | 1 + 4 files changed, 37 insertions(+), 33 deletions(-) diff --git a/src/browser/contexts/API.test.tsx b/src/browser/contexts/API.test.tsx index 08bcc31989..17b3fe7d84 100644 --- a/src/browser/contexts/API.test.tsx +++ b/src/browser/contexts/API.test.tsx @@ -66,6 +66,7 @@ void mock.module("@orpc/client/message-port", () => ({ })); void mock.module("@/browser/components/AuthTokenModal", () => ({ + AuthTokenModal: () => null, getStoredAuthToken: () => null, // eslint-disable-next-line @typescript-eslint/no-empty-function clearStoredAuthToken: () => {}, diff --git a/src/browser/hooks/useVoiceInput.ts b/src/browser/hooks/useVoiceInput.ts index 693b664037..9180c2c8a3 100644 --- a/src/browser/hooks/useVoiceInput.ts +++ b/src/browser/hooks/useVoiceInput.ts @@ -57,7 +57,8 @@ export interface UseVoiceInputResult { */ function hasTouchDictation(): boolean { if (typeof window === "undefined") return false; - const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0; + const hasTouch = + "ontouchstart" in window || (typeof navigator !== "undefined" && navigator.maxTouchPoints > 0); // Touch-only check: most touch devices have native dictation. // We don't check screen size because iPads are large but still have dictation. return hasTouch; @@ -66,7 +67,9 @@ function hasTouchDictation(): boolean { const HAS_TOUCH_DICTATION = hasTouchDictation(); const HAS_MEDIA_RECORDER = typeof window !== "undefined" && typeof MediaRecorder !== "undefined"; const HAS_GET_USER_MEDIA = - typeof window !== "undefined" && typeof navigator.mediaDevices?.getUserMedia === "function"; + typeof window !== "undefined" && + typeof navigator !== "undefined" && + typeof navigator.mediaDevices?.getUserMedia === "function"; // ============================================================================= // Global Key State Tracking @@ -79,7 +82,7 @@ const HAS_GET_USER_MEDIA = */ let isSpaceCurrentlyHeld = false; -if (typeof window !== "undefined") { +if (typeof window !== "undefined" && typeof window.addEventListener === "function") { window.addEventListener( "keydown", (e) => { diff --git a/src/browser/utils/RefreshController.test.ts b/src/browser/utils/RefreshController.test.ts index e4aa56babc..48652055d8 100644 --- a/src/browser/utils/RefreshController.test.ts +++ b/src/browser/utils/RefreshController.test.ts @@ -1,18 +1,13 @@ -import { describe, it, expect, beforeEach, afterEach, jest } from "@jest/globals"; -import { RefreshController } from "./RefreshController"; +import { describe, it, expect, mock } from "bun:test"; -describe("RefreshController", () => { - beforeEach(() => { - jest.useFakeTimers(); - }); +import { RefreshController } from "./RefreshController"; - afterEach(() => { - jest.useRealTimers(); - }); +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - it("debounces multiple schedule() calls", () => { - const onRefresh = jest.fn<() => void>(); - const controller = new RefreshController({ onRefresh, debounceMs: 100 }); +describe("RefreshController", () => { + it("debounces multiple schedule() calls", async () => { + const onRefresh = mock<() => void>(() => undefined); + const controller = new RefreshController({ onRefresh, debounceMs: 50 }); controller.schedule(); controller.schedule(); @@ -20,16 +15,17 @@ describe("RefreshController", () => { expect(onRefresh).not.toHaveBeenCalled(); - jest.advanceTimersByTime(100); + // Use real timers because bun's jest compatibility doesn't expose timer controls. + await sleep(120); expect(onRefresh).toHaveBeenCalledTimes(1); controller.dispose(); }); - it("requestImmediate() bypasses debounce", () => { - const onRefresh = jest.fn<() => void>(); - const controller = new RefreshController({ onRefresh, debounceMs: 100 }); + it("requestImmediate() bypasses debounce", async () => { + const onRefresh = mock<() => void>(() => undefined); + const controller = new RefreshController({ onRefresh, debounceMs: 50 }); controller.schedule(); expect(onRefresh).not.toHaveBeenCalled(); @@ -38,7 +34,7 @@ describe("RefreshController", () => { expect(onRefresh).toHaveBeenCalledTimes(1); // Original debounce timer should be cleared - jest.advanceTimersByTime(100); + await sleep(120); expect(onRefresh).toHaveBeenCalledTimes(1); controller.dispose(); @@ -47,7 +43,7 @@ describe("RefreshController", () => { it("guards against concurrent sync refreshes (in-flight queuing)", () => { // Track if refresh is currently in-flight let inFlight = false; - const onRefresh = jest.fn(() => { + const onRefresh = mock(() => { // Simulate sync operation that takes time expect(inFlight).toBe(false); // Should never be called while already in-flight inFlight = true; @@ -55,7 +51,7 @@ describe("RefreshController", () => { inFlight = false; }); - const controller = new RefreshController({ onRefresh, debounceMs: 100 }); + const controller = new RefreshController({ onRefresh, debounceMs: 50 }); // Multiple immediate requests should only call once (queued ones execute after) controller.requestImmediate(); @@ -64,14 +60,14 @@ describe("RefreshController", () => { controller.dispose(); }); - it("isRefreshing reflects in-flight state", () => { + it("isRefreshing reflects in-flight state", async () => { let resolveRefresh: () => void; const refreshPromise = new Promise((resolve) => { resolveRefresh = resolve; }); - const onRefresh = jest.fn(() => refreshPromise); - const controller = new RefreshController({ onRefresh, debounceMs: 100 }); + const onRefresh = mock(() => refreshPromise); + const controller = new RefreshController({ onRefresh, debounceMs: 50 }); expect(controller.isRefreshing).toBe(false); @@ -80,31 +76,34 @@ describe("RefreshController", () => { // Complete the promise resolveRefresh!(); + await Promise.resolve(); + + expect(controller.isRefreshing).toBe(false); controller.dispose(); }); - it("dispose() cleans up debounce timer", () => { - const onRefresh = jest.fn<() => void>(); - const controller = new RefreshController({ onRefresh, debounceMs: 100 }); + it("dispose() cleans up debounce timer", async () => { + const onRefresh = mock<() => void>(() => undefined); + const controller = new RefreshController({ onRefresh, debounceMs: 50 }); controller.schedule(); controller.dispose(); - jest.advanceTimersByTime(100); + await sleep(120); expect(onRefresh).not.toHaveBeenCalled(); }); - it("does not refresh after dispose", () => { - const onRefresh = jest.fn<() => void>(); - const controller = new RefreshController({ onRefresh, debounceMs: 100 }); + it("does not refresh after dispose", async () => { + const onRefresh = mock<() => void>(() => undefined); + const controller = new RefreshController({ onRefresh, debounceMs: 50 }); controller.dispose(); controller.schedule(); controller.requestImmediate(); - jest.advanceTimersByTime(100); + await sleep(120); expect(onRefresh).not.toHaveBeenCalled(); }); diff --git a/src/node/services/tools/task.test.ts b/src/node/services/tools/task.test.ts index 578758b0b7..3889d7d18c 100644 --- a/src/node/services/tools/task.test.ts +++ b/src/node/services/tools/task.test.ts @@ -75,6 +75,7 @@ describe("task tool", () => { taskId: "child-task", reportMarkdown: "Hello from child", title: "Result", + agentId: "explore", agentType: "explore", }); });