mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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 =
|
||||
|
||||
@@ -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 = () => ({
|
||||
|
||||
Generated
+23
@@ -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"
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
Reference in New Issue
Block a user