feat: add Explore mode as subagent-only modality (#24448)

> This PR was authored by Mux on behalf of Mike.

Introduce Explore mode, a read-only subagent modality for delegated
discovery and code investigation.

## What

Adds a `spawn_explore_agent` tool that creates child chats restricted to
read-only operations. An admin can optionally configure a
deployment-wide
model override so Explore subagents use a model optimized for large
context
or reasoning without changing the root chat's model.

### Backend

- New `ChatModeExplore` enum value (migration 000471).
- `spawn_explore_agent` tool definition with read-only allowlist:
`read_file`, `execute`, `process_output`, `read_skill`,
`read_skill_file`.
  Write tools, file editors, and nested subagent spawning are blocked.
- Deployment config storage for the Explore model override
  (`agents_chat_explore_model_override` in `site_configs`).
- Model resolution hierarchy: configured override, then current turn
model,
then global default. Silent fallback with warning log when the override
  becomes unavailable.
- RBAC: `AsChatd` for daemon reads, `ActionRead` and `ActionUpdate` on
  `ResourceDeploymentConfig` for admin API calls.
- Plan mode root chats can use `spawn_explore_agent` for read-only
research,
  matching the planning prompt guidance.
- The Explore override config API now reports malformed saved overrides
as
  "treated as unset" so admins can clear them explicitly.

### Frontend

- `ExploreModelOverrideSettings` component in admin agent behavior
settings.
  Uses `ModelSelector`, handles unavailable model warnings, and supports
  explicit Save and Clear actions.
- Malformed saved overrides show a warning and require an explicit Save
to
  clear, instead of Clear auto-submitting behind the scenes.

### Tests

- Integration: `TestExploreSubagentIsReadOnly` (full spawn flow, tool
  verification, prompt overlay, DB state).
- Unit: tool allowlist tests for explore, plan, and default modes.
- Internal: model override resolution with valid, invalid UUID,
disabled, and
  unconfigured override scenarios.
- RBAC: `dbauthz_test.go` for `GetChatExploreModelOverride` and
  `UpsertChatExploreModelOverride`.
- API: admin set and clear, malformed stored override reporting,
disabled
  model rejection, non-admin denial.
This commit is contained in:
Michael Suchacz
2026-04-17 13:40:17 +02:00
committed by GitHub
parent 890c610e08
commit 73b5058923
42 changed files with 1852 additions and 157 deletions
+18
View File
@@ -3276,6 +3276,24 @@ class ExperimentalApiMethods {
);
};
getChatExploreModelOverride =
async (): Promise<TypesGen.ChatExploreModelOverrideResponse> => {
const response =
await this.axios.get<TypesGen.ChatExploreModelOverrideResponse>(
"/api/experimental/chats/config/explore-model-override",
);
return response.data;
};
updateChatExploreModelOverride = async (
req: TypesGen.UpdateChatExploreModelOverrideRequest,
): Promise<void> => {
await this.axios.put(
"/api/experimental/chats/config/explore-model-override",
req,
);
};
getChatDesktopEnabled =
async (): Promise<TypesGen.ChatDesktopEnabledResponse> => {
const response =
+17
View File
@@ -922,6 +922,23 @@ export const updateChatPlanModeInstructions = (queryClient: QueryClient) => ({
},
});
const chatExploreModelOverrideKey = ["chat-explore-model-override"] as const;
export const chatExploreModelOverride = () => ({
queryKey: chatExploreModelOverrideKey,
queryFn: () => API.experimental.getChatExploreModelOverride(),
});
export const updateChatExploreModelOverride = (queryClient: QueryClient) => ({
mutationFn: (req: TypesGen.UpdateChatExploreModelOverrideRequest) =>
API.experimental.updateChatExploreModelOverride(req),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: chatExploreModelOverrideKey,
});
},
});
const chatDesktopEnabledKey = ["chat-desktop-enabled"] as const;
export const chatDesktopEnabled = () => ({
+23
View File
@@ -1582,6 +1582,20 @@ export interface ChatDiffStatus {
readonly stale_at?: string;
}
// From codersdk/chats.go
/**
* ChatExploreModelOverrideResponse is the response body for the Explore
* subagent model override configuration endpoint.
*/
export interface ChatExploreModelOverrideResponse {
readonly model_config_id?: string;
/**
* HasMalformedOverride reports whether the saved override is malformed and
* is currently being treated as unset.
*/
readonly has_malformed_override: boolean;
}
// From codersdk/chats.go
/**
* ChatFileMetadata contains lightweight metadata about a file
@@ -7581,6 +7595,15 @@ export interface UpdateChatDesktopEnabledRequest {
readonly enable_desktop: boolean;
}
// From codersdk/chats.go
/**
* UpdateChatExploreModelOverrideRequest is the request body for updating the
* Explore subagent model override configuration endpoint.
*/
export interface UpdateChatExploreModelOverrideRequest {
readonly model_config_id?: string;
}
// From codersdk/chats.go
/**
* UpdateChatModelConfigRequest updates a chat model config.
@@ -0,0 +1,44 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
chatExploreModelOverride,
chatModelConfigs,
updateChatExploreModelOverride,
} from "#/api/queries/chats";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { AgentSettingsAgentsPageView } from "./AgentSettingsAgentsPageView";
const AgentSettingsAgentsPage: FC = () => {
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
const exploreModelOverrideQuery = useQuery({
...chatExploreModelOverride(),
enabled: permissions.editDeploymentConfig,
});
const modelConfigsQuery = useQuery(chatModelConfigs());
const saveExploreModelOverrideMutation = useMutation(
updateChatExploreModelOverride(queryClient),
);
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<AgentSettingsAgentsPageView
exploreModelOverrideData={exploreModelOverrideQuery.data}
modelConfigsData={modelConfigsQuery.data}
modelConfigsError={modelConfigsQuery.error}
isLoadingModelConfigs={modelConfigsQuery.isLoading}
onSaveExploreModelOverride={saveExploreModelOverrideMutation.mutate}
isSavingExploreModelOverride={
saveExploreModelOverrideMutation.isPending
}
isSaveExploreModelOverrideError={
saveExploreModelOverrideMutation.isError
}
/>
</RequirePermission>
);
};
export default AgentSettingsAgentsPage;
@@ -0,0 +1,213 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import {
AgentSettingsAgentsPageView,
type AgentSettingsAgentsPageViewProps,
} from "./AgentSettingsAgentsPageView";
const baseArgs: AgentSettingsAgentsPageViewProps = {
exploreModelOverrideData: {
has_malformed_override: false,
},
modelConfigsData: [],
modelConfigsError: undefined,
isLoadingModelConfigs: false,
onSaveExploreModelOverride: fn(),
isSavingExploreModelOverride: false,
isSaveExploreModelOverrideError: false,
};
const meta = {
title: "pages/AgentsPage/AgentSettingsAgentsPageView",
component: AgentSettingsAgentsPageView,
args: baseArgs,
} satisfies Meta<typeof AgentSettingsAgentsPageView>;
export default meta;
type Story = StoryObj<typeof AgentSettingsAgentsPageView>;
export const ExploreModelOverrideSetting: Story = {
args: {
exploreModelOverrideData: {
model_config_id: "model-explore-1",
has_malformed_override: false,
},
modelConfigsData: [
{
id: "model-explore-1",
provider: "openai",
model: "gpt-4.1-mini",
display_name: "GPT 4.1 Mini",
enabled: true,
is_default: false,
context_limit: 1_000_000,
compression_threshold: 70,
created_at: "2026-03-12T12:00:00.000Z",
updated_at: "2026-03-12T12:00:00.000Z",
},
{
id: "model-explore-2",
provider: "anthropic",
model: "claude-sonnet-4",
display_name: "Claude Sonnet 4",
enabled: true,
is_default: false,
context_limit: 200_000,
compression_threshold: 70,
created_at: "2026-03-12T12:00:00.000Z",
updated_at: "2026-03-12T12:00:00.000Z",
},
] as TypesGen.ChatModelConfig[],
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await canvas.findByText("Agents");
await canvas.findByText("Explore subagent model");
const trigger = canvas.getByRole("combobox", {
name: /gpt 4.1 mini/i,
});
await userEvent.click(trigger);
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("option", { name: "Claude Sonnet 4" }),
);
const form = trigger.closest("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("Expected Explore model selector to live inside a form.");
}
const saveButton = within(form).getByRole("button", { name: "Save" });
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
await userEvent.click(saveButton);
await waitFor(() => {
expect(args.onSaveExploreModelOverride).toHaveBeenCalledWith(
{ model_config_id: "model-explore-2" },
expect.anything(),
);
});
},
};
export const ExploreModelOverrideAllowsExplicitClear: Story = {
args: {
exploreModelOverrideData: {
model_config_id: "model-explore-clear",
has_malformed_override: false,
},
modelConfigsData: [
{
id: "model-explore-clear",
provider: "openai",
model: "gpt-4.1-mini",
display_name: "GPT 4.1 Mini",
enabled: true,
is_default: false,
context_limit: 1_000_000,
compression_threshold: 70,
created_at: "2026-03-12T12:00:00.000Z",
updated_at: "2026-03-12T12:00:00.000Z",
},
] as TypesGen.ChatModelConfig[],
onSaveExploreModelOverride: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const clearButton = await canvas.findByRole("button", { name: "Clear" });
const form = clearButton.closest("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error(
"Expected Explore model clear button to live inside a form.",
);
}
const saveButton = within(form).getByRole("button", { name: "Save" });
await userEvent.click(clearButton);
expect(args.onSaveExploreModelOverride).not.toHaveBeenCalled();
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
await userEvent.click(saveButton);
await waitFor(() => {
expect(args.onSaveExploreModelOverride).toHaveBeenCalledWith(
{},
expect.anything(),
);
});
},
};
export const ExploreModelOverrideClearsMalformedSavedValue: Story = {
args: {
exploreModelOverrideData: {
has_malformed_override: true,
},
modelConfigsData: [],
onSaveExploreModelOverride: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await canvas.findByText(
"The saved override is malformed and is being treated as unset. Click Save to clear it.",
);
const clearButton = await canvas.findByRole("button", { name: "Clear" });
const form = clearButton.closest("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error(
"Expected Explore model clear button to live inside a form.",
);
}
const saveButton = within(form).getByRole("button", { name: "Save" });
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
await userEvent.click(clearButton);
expect(args.onSaveExploreModelOverride).not.toHaveBeenCalled();
await userEvent.click(saveButton);
await waitFor(() => {
expect(args.onSaveExploreModelOverride).toHaveBeenCalledWith(
{},
expect.anything(),
);
});
},
};
export const ExploreModelOverrideFallsBackToModelName: Story = {
args: {
exploreModelOverrideData: {
model_config_id: "model-explore-empty-name",
has_malformed_override: false,
},
modelConfigsData: [
{
id: "model-explore-empty-name",
provider: "anthropic",
model: "claude-sonnet-4-20250514",
display_name: "",
enabled: true,
is_default: false,
context_limit: 200_000,
compression_threshold: 70,
created_at: "2026-03-12T12:00:00.000Z",
updated_at: "2026-03-12T12:00:00.000Z",
},
] as TypesGen.ChatModelConfig[],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = await canvas.findByRole("combobox", {
name: /claude-sonnet-4-20250514/i,
});
expect(trigger).toHaveTextContent("claude-sonnet-4-20250514");
await userEvent.click(trigger);
const body = within(canvasElement.ownerDocument.body);
expect(
await body.findByRole("option", {
name: "claude-sonnet-4-20250514",
}),
).toBeInTheDocument();
},
};
@@ -0,0 +1,64 @@
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { AdminBadge } from "./components/AdminBadge";
import { ExploreModelOverrideSettings } from "./components/ExploreModelOverrideSettings";
import { SectionHeader } from "./components/SectionHeader";
interface MutationCallbacks {
onSuccess?: () => void;
onError?: () => void;
}
export interface AgentSettingsAgentsPageViewProps {
exploreModelOverrideData:
| TypesGen.ChatExploreModelOverrideResponse
| undefined;
modelConfigsData: TypesGen.ChatModelConfig[] | undefined;
modelConfigsError: unknown;
isLoadingModelConfigs: boolean;
onSaveExploreModelOverride: (
req: TypesGen.UpdateChatExploreModelOverrideRequest,
options?: MutationCallbacks,
) => void;
isSavingExploreModelOverride: boolean;
isSaveExploreModelOverrideError: boolean;
}
export const AgentSettingsAgentsPageView: FC<
AgentSettingsAgentsPageViewProps
> = ({
exploreModelOverrideData,
modelConfigsData,
modelConfigsError,
isLoadingModelConfigs,
onSaveExploreModelOverride,
isSavingExploreModelOverride,
isSaveExploreModelOverrideError,
}) => {
return (
<div className="flex flex-col gap-8">
<SectionHeader
label="Agents"
description="Configure defaults for delegated agents and other agent-specific capabilities."
badge={<AdminBadge />}
/>
<div className="flex flex-col gap-3">
<SectionHeader
label="Explore subagent model"
description="Optional deployment-wide model override for read-only Explore subagents."
level="section"
/>
<ExploreModelOverrideSettings
exploreModelOverrideData={exploreModelOverrideData}
modelConfigs={modelConfigsData ?? []}
modelConfigsError={modelConfigsError}
isLoadingModelConfigs={isLoadingModelConfigs}
onSaveExploreModelOverride={onSaveExploreModelOverride}
isSavingExploreModelOverride={isSavingExploreModelOverride}
isSaveExploreModelOverrideError={isSaveExploreModelOverrideError}
showHeader={false}
/>
</div>
</div>
);
};
@@ -27,6 +27,11 @@ const baseProps = {
} as TypesGen.ChatWorkspaceTTLResponse,
isWorkspaceTTLLoading: false,
isWorkspaceTTLLoadError: false,
retentionDaysData: {
retention_days: 30,
} as TypesGen.ChatRetentionDaysResponse,
isRetentionDaysLoading: false,
isRetentionDaysLoadError: false,
modelConfigsData: [] as TypesGen.ChatModelConfig[],
modelConfigsError: undefined as unknown,
isLoadingModelConfigs: false,
@@ -43,6 +48,8 @@ const baseProps = {
isSaveDesktopEnabledError: false,
isSavingWorkspaceTTL: false,
isSaveWorkspaceTTLError: false,
isSavingRetentionDays: false,
isSaveRetentionDaysError: false,
};
const meta = {
@@ -55,6 +62,7 @@ const meta = {
onSaveUserPrompt: fn(),
onSaveDesktopEnabled: fn(),
onSaveWorkspaceTTL: fn(),
onSaveRetentionDays: fn(),
onSaveThreshold: fn(),
onResetThreshold: fn(),
},
@@ -28,6 +28,7 @@ import {
} from "#/testHelpers/storybook";
import AgentAnalyticsPage from "./AgentAnalyticsPage";
import AgentCreatePage from "./AgentCreatePage";
import { AgentSettingsAgentsPageView } from "./AgentSettingsAgentsPageView";
import { AgentSettingsBehaviorPageView } from "./AgentSettingsBehaviorPageView";
import AgentSettingsPage from "./AgentSettingsPage";
import AgentSettingsSpendPage from "./AgentSettingsSpendPage";
@@ -202,6 +203,18 @@ const BehaviorRouteElement = () => {
);
};
const AgentsRouteElement = () => (
<AgentSettingsAgentsPageView
exploreModelOverrideData={{ has_malformed_override: false }}
modelConfigsData={[]}
modelConfigsError={undefined}
isLoadingModelConfigs={false}
onSaveExploreModelOverride={fn()}
isSavingExploreModelOverride={false}
isSaveExploreModelOverrideError={false}
/>
);
const agentsRouting = {
path: "/agents",
useStoryElement: true,
@@ -212,6 +225,7 @@ const agentsRouting = {
children: [
{ index: true, element: <Navigate to="behavior" replace /> },
{ path: "behavior", element: <BehaviorRouteElement /> },
{ path: "agents", element: <AgentsRouteElement /> },
{ path: "spend", element: <AgentSettingsSpendPage now={fixedNow} /> },
{
path: "usage",
@@ -20,6 +20,7 @@ const appendText = (current: string, next: string): string => {
const isSubagentToolName = (name: string): boolean =>
name === "spawn_agent" ||
name === "spawn_explore_agent" ||
name === "spawn_computer_use_agent" ||
name === "wait_agent" ||
name === "message_agent";
@@ -332,6 +333,7 @@ export const buildSubagentTitles = (
for (const tool of parsed.tools) {
if (
tool.name !== "spawn_agent" &&
tool.name !== "spawn_explore_agent" &&
tool.name !== "spawn_computer_use_agent"
) {
continue;
@@ -83,6 +83,23 @@ export const CustomPlaceholder: Story = {
},
};
export const InputBorderTreatment: Story = {
args: {
value: "openai/gpt-4o-mini",
className:
"h-10 border border-border border-solid bg-transparent px-3 shadow-sm",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = canvas.getByRole("combobox", { name: /gpt-4o mini/i });
const styles = getComputedStyle(trigger);
expect(styles.borderTopStyle).toBe("solid");
expect(styles.borderTopWidth).not.toBe("0px");
expect(styles.boxShadow).not.toBe("none");
},
};
export const Disabled: Story = {
args: {
disabled: true,
@@ -54,6 +54,18 @@ const formatContextLimit = (tokens: number): string => {
return `${k}K context window`;
};
const getOptionLabel = (option: ModelSelectorOption): string => {
const displayName = option.displayName.trim();
if (displayName) {
return displayName;
}
const model = option.model.trim();
if (model) {
return model;
}
return option.id;
};
export const ModelSelector: FC<ModelSelectorProps> = ({
options,
value,
@@ -87,13 +99,14 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
return (
<Select value={value} onValueChange={onValueChange} disabled={isDisabled}>
<SelectTrigger
aria-label={selectedModel ? getOptionLabel(selectedModel) : placeholder}
className={cn(
"h-8 w-auto gap-1.5 border-none bg-transparent px-1 text-xs shadow-none transition-colors hover:bg-transparent hover:text-content-primary focus:ring-0 [&>svg]:transition-colors [&>svg]:hover:text-content-primary",
"h-8 w-auto gap-1.5 border-0 bg-transparent px-1 text-xs shadow-none transition-colors hover:bg-transparent hover:text-content-primary focus:ring-0 [&>svg]:transition-colors [&>svg]:hover:text-content-primary",
className,
)}
>
<SelectValue placeholder={placeholder}>
{selectedModel?.displayName ?? placeholder}
{selectedModel ? getOptionLabel(selectedModel) : placeholder}
</SelectValue>
</SelectTrigger>
<SelectContent
@@ -139,11 +152,11 @@ const ModelOptionItem: FC<ModelOptionItemProps> = ({
return (
<Tooltip>
<TooltipTrigger asChild>
<SelectItem value={option.id}>{option.displayName}</SelectItem>
<SelectItem value={option.id}>{getOptionLabel(option)}</SelectItem>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={4} className="px-2.5 py-1.5">
<span className="block font-semibold text-content-primary leading-tight">
{option.displayName} via {providerLabel}
{getOptionLabel(option)} via {providerLabel}
</span>
{option.contextLimit != null && option.contextLimit > 0 && (
<span className="block text-content-secondary leading-tight">
@@ -33,6 +33,12 @@ const SUBAGENT_VERBS: Record<
error: "Failed to spawn ",
timeout: "Timed out spawning ",
},
spawn_explore_agent: {
completed: "Spawned ",
running: "Spawning ",
error: "Failed to spawn ",
timeout: "Timed out spawning ",
},
wait_agent: {
completed: "Waited for ",
running: "Waiting for ",
@@ -170,6 +170,30 @@ export const SubagentRunning: Story = {
},
};
export const ExploreSubagentRunning: Story = {
args: {
name: "spawn_explore_agent",
status: "running",
args: {
prompt: "Read the repository and summarize the auth flow.",
},
result: {
chat_id: "explore-chat-id",
status: "pending",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("link", { name: "View agent" })).toHaveAttribute(
"href",
"/agents/explore-chat-id",
);
expect(
canvas.getByRole("button", { name: /Spawning Explore agent/ }),
).toBeInTheDocument();
},
};
export const SubagentAwaitLinkCard: Story = {
args: {
name: "wait_agent",
@@ -463,7 +463,9 @@ const SubagentRenderer: FC<ToolRendererProps> = ({
(chatId && subagentTitles?.get(chatId)) ||
(name === "spawn_computer_use_agent"
? "Computer use sub-agent"
: "Sub-agent");
: name === "spawn_explore_agent"
? "Explore agent"
: "Sub-agent");
const subagentCompleted = isSubagentSuccessStatus(subagentStatus);
const subagentToolStatus = mapSubagentStatusToToolStatus(
subagentStatus,
@@ -887,6 +889,7 @@ const toolRenderers: Record<string, FC<ToolRendererProps>> = {
read_skill: ReadSkillRenderer,
read_skill_file: ReadSkillFileRenderer,
spawn_agent: SubagentRenderer,
spawn_explore_agent: SubagentRenderer,
wait_agent: SubagentRenderer,
message_agent: SubagentRenderer,
close_agent: SubagentRenderer,
@@ -82,6 +82,11 @@ export const ToolIcon: React.FC<{
return <FilePenIcon className={base} />;
case "create_workspace":
return <PlusCircleIcon className={base} />;
case "spawn_agent":
case "spawn_explore_agent":
case "wait_agent":
case "message_agent":
case "close_agent":
case "chat_summarized":
return <BotIcon className={base} />;
case "propose_plan":
@@ -156,6 +156,16 @@ export const ToolLabel: React.FC<{
</span>
);
}
case "spawn_explore_agent": {
const spawnTitle =
(parsedResult ? asString(parsedResult.title) : "") ||
(parsed ? asString(parsed.title) : "");
return (
<span className="truncate text-sm text-content-secondary">
{spawnTitle ? `Spawning ${spawnTitle}` : "Spawning Explore agent…"}
</span>
);
}
case "spawn_agent": {
const spawnTitle =
(parsedResult ? asString(parsedResult.title) : "") ||
@@ -0,0 +1,177 @@
import { useFormik } from "formik";
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert, AlertDescription } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import { AdminBadge } from "./AdminBadge";
import type { ModelSelectorOption } from "./ChatElements/ModelSelector";
import { ModelSelector } from "./ChatElements/ModelSelector";
interface MutationCallbacks {
onSuccess?: () => void;
onError?: () => void;
}
interface ExploreModelOverrideSettingsProps {
exploreModelOverrideData:
| TypesGen.ChatExploreModelOverrideResponse
| undefined;
modelConfigs: readonly TypesGen.ChatModelConfig[];
modelConfigsError: unknown;
isLoadingModelConfigs: boolean;
onSaveExploreModelOverride: (
req: TypesGen.UpdateChatExploreModelOverrideRequest,
options?: MutationCallbacks,
) => void;
isSavingExploreModelOverride: boolean;
isSaveExploreModelOverrideError: boolean;
showHeader?: boolean;
}
const toModelSelectorOption = (
modelConfig: TypesGen.ChatModelConfig,
): ModelSelectorOption => ({
id: modelConfig.id,
provider: modelConfig.provider,
model: modelConfig.model,
displayName: modelConfig.display_name.trim() || modelConfig.model,
contextLimit: modelConfig.context_limit,
});
export const ExploreModelOverrideSettings: FC<
ExploreModelOverrideSettingsProps
> = ({
exploreModelOverrideData,
modelConfigs,
modelConfigsError,
isLoadingModelConfigs,
onSaveExploreModelOverride,
isSavingExploreModelOverride,
isSaveExploreModelOverrideError,
showHeader = true,
}) => {
const hasLoadedExploreModelOverride = exploreModelOverrideData !== undefined;
const enabledModelOptions = modelConfigs
.filter((modelConfig) => modelConfig.enabled)
.map(toModelSelectorOption);
const form = useFormik({
enableReinitialize: true,
initialValues: {
model_config_id: exploreModelOverrideData?.model_config_id ?? "",
},
onSubmit: (values, { resetForm }) => {
onSaveExploreModelOverride(
{
model_config_id: values.model_config_id || undefined,
},
{
onSuccess: () => {
resetForm({ values });
},
},
);
},
});
const isUnavailableSavedModel =
form.values.model_config_id !== "" &&
!enabledModelOptions.some(
(option) => option.id === form.values.model_config_id,
);
const hasMalformedOverride =
exploreModelOverrideData?.has_malformed_override ?? false;
const isExploreModelOverrideDisabled =
isSavingExploreModelOverride ||
isLoadingModelConfigs ||
!hasLoadedExploreModelOverride;
const canSaveExploreModelOverride =
hasLoadedExploreModelOverride && (form.dirty || hasMalformedOverride);
return (
<form className="space-y-2" onSubmit={form.handleSubmit}>
{showHeader && (
<>
<div className="flex items-center gap-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Explore subagent model
</h3>
<AdminBadge />
</div>
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
Optional deployment-wide model override for read-only Explore
subagents spawned with <code>spawn_explore_agent</code>.
</p>
</>
)}
<div className="rounded-lg border border-border bg-surface-primary px-3 py-2">
<ModelSelector
options={enabledModelOptions}
value={form.values.model_config_id}
onValueChange={(value) =>
form.setFieldValue("model_config_id", value)
}
disabled={isExploreModelOverrideDisabled}
placeholder={
isUnavailableSavedModel ? "Unavailable model" : "Use chat default"
}
emptyMessage={
isLoadingModelConfigs
? "Loading models..."
: "No enabled models found."
}
className="h-10 w-full justify-between rounded-md border border-border border-solid bg-transparent px-3 text-sm shadow-sm"
contentClassName="min-w-[18rem]"
/>
</div>
{isUnavailableSavedModel && (
<Alert severity="warning">
<AlertDescription>
The saved model is no longer enabled and will be ignored until you
choose a new override.
</AlertDescription>
</Alert>
)}
{hasMalformedOverride && (
<Alert severity="warning">
<AlertDescription>
The saved override is malformed and is being treated as unset. Click
Save to clear it.
</AlertDescription>
</Alert>
)}
{Boolean(modelConfigsError) && (
<p className="m-0 text-xs text-content-destructive">
Failed to load model configs.
</p>
)}
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
type="button"
onClick={() => {
form.setFieldValue("model_config_id", "");
}}
disabled={isExploreModelOverrideDisabled}
>
Clear
</Button>
<Button
size="sm"
type="submit"
disabled={
isExploreModelOverrideDisabled || !canSaveExploreModelOverride
}
>
Save
</Button>
</div>
{isSaveExploreModelOverrideError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save Explore model override.
</p>
)}
</form>
);
};
@@ -20,6 +20,7 @@ import {
AlertTriangleIcon,
ArchiveIcon,
ArchiveRestoreIcon,
BotIcon,
BoxesIcon,
CheckIcon,
ChevronDownIcon,
@@ -1277,6 +1278,14 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
)}
{isAdmin && (
<>
<SettingsNavItem
icon={BotIcon}
label="Agents"
active={sidebarView.section === "agents"}
to="/agents/settings/agents"
state={location.state}
adminOnly
/>
<SettingsNavItem
icon={LayoutTemplateIcon}
label="Templates"
+4
View File
@@ -360,6 +360,9 @@ const AgentSettingsPage = lazy(
const AgentSettingsBehaviorPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsBehaviorPage"),
);
const AgentSettingsAgentsPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsAgentsPage"),
);
const AgentSettingsProvidersPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsProvidersPage"),
);
@@ -709,6 +712,7 @@ export const router = createBrowserRouter(
<Route path="settings" element={<AgentSettingsPage />}>
<Route index element={<AgentSettingsBehaviorPage />} />
<Route path="behavior" element={<AgentSettingsBehaviorPage />} />
<Route path="agents" element={<AgentSettingsAgentsPage />} />
<Route path="api-keys" element={<AgentSettingsAPIKeysPage />} />
<Route path="providers" element={<AgentSettingsProvidersPage />} />
<Route path="models" element={<AgentSettingsModelsPage />} />