diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx index 4b56f0774565b..7a54115d699f0 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx @@ -68,7 +68,7 @@ interface ModelSelectorProps { onReasoningEffortChange?: (value: string) => void; } -const formatContextLimit = (tokens: number): string => { +export const formatContextLimit = (tokens: number): string => { if (tokens >= 1_000_000) { const m = tokens / 1_000_000; return `${Number.isInteger(m) ? m : m.toFixed(1)}M`; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ListSubagentModelsTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ListSubagentModelsTool.tsx new file mode 100644 index 0000000000000..7c78424f6601f --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ListSubagentModelsTool.tsx @@ -0,0 +1,140 @@ +import type React from "react"; +import { ScrollArea } from "#/components/ScrollArea/ScrollArea"; +import { ProviderIcon } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderIcon"; +import { formatProviderLabel } from "#/utils/aiProviders"; +import { formatContextLimit } from "../ModelSelector"; +import { ToolCall } from "./ToolCall"; +import { asNumber, asRecord, asString, type ToolStatus } from "./utils"; + +const ModelRow: React.FC<{ model: unknown }> = ({ model }) => { + const rec = asRecord(model); + if (!rec) { + return null; + } + const displayName = asString(rec.display_name).trim(); + const modelName = asString(rec.model).trim(); + const contextLimit = asNumber(rec.context_limit); + const context = + contextLimit !== undefined && contextLimit > 0 + ? formatContextLimit(contextLimit) + : ""; + const efforts = Array.isArray(rec.reasoning_efforts) + ? rec.reasoning_efforts.map(asString).filter((effort) => effort.trim()) + : []; + const effortRange = + efforts.length > 1 + ? `${efforts[0]} - ${efforts[efforts.length - 1]}` + : (efforts[0] ?? ""); + const title = displayName || modelName || "Unknown model"; + + return ( +
+ + {title} + + {effortRange ? ( + + {effortRange} + + ) : ( +
+ ); +}; + +const ListSubagentModelsContent: React.FC<{ models: unknown[] }> = ({ + models, +}) => { + // Group by provider like the model picker so models from one provider + // sit under a single headed section instead of a flat list. + const grouped = new Map(); + for (const model of models) { + const rec = asRecord(model); + if (!rec) { + continue; + } + const provider = asString(rec.provider).trim() || "unknown"; + const group = grouped.get(provider); + if (group) { + group.push(model); + continue; + } + grouped.set(provider, [model]); + } + + return ( + +
+ {Array.from(grouped.entries(), ([provider, providerModels], index) => ( +
0 + ? "border-0 border-t border-solid border-border-default" + : undefined + } + > +
+ + + {formatProviderLabel(provider)} + +
+ {providerModels.map((model, modelIndex) => { + const rec = asRecord(model); + const configID = rec ? asString(rec.model_config_id).trim() : ""; + return ; + })} +
+ ))} +
+
+ ); +}; + +/** + * Collapsed-by-default rendering for `list_subagent_models` tool calls. + * Shows "Listed N subagent models" with a chevron; expanding reveals the + * models accepted by spawn_agent's model_config_id argument, grouped by + * provider like the chat model picker. + */ +export const ListSubagentModelsTool: React.FC<{ + models: unknown[]; + status: ToolStatus; + isError: boolean; + errorMessage?: string; +}> = ({ models, status, isError, errorMessage }) => { + const hasContent = models.length > 0; + const isRunning = status === "running"; + + const label = isRunning + ? "Listing subagent models…" + : `Listed ${models.length} subagent ${models.length === 1 ? "model" : "models"}`; + + return ( + + + + + + + ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 3f6fd0b45e46d..89f51604dc3c3 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -138,6 +138,20 @@ const allToolShowcaseItems: ToolShowcaseItem[] = [ total: 1, }, }, + { + name: "list_subagent_models", + result: { + models: [ + { + model_config_id: "model-1", + display_name: "Fast Model", + model: "fast-1", + provider: "openai", + is_default: true, + }, + ], + }, + }, { name: "read_template", args: { template_id: "template-1" }, @@ -1324,6 +1338,117 @@ export const ListAgentsError: Story = { }, }; +// --------------------------------------------------------------------------- +// ListSubagentModels stories +// --------------------------------------------------------------------------- + +export const ListSubagentModelsCompleted: Story = { + args: { + name: "list_subagent_models", + status: "completed", + args: {}, + result: { + models: [ + { + model_config_id: "10000000-0000-0000-0000-000000000001", + display_name: "Fast Model", + model: "fast-1", + provider: "openai", + context_limit: 200_000, + is_default: true, + reasoning_efforts: ["low", "medium", "high"], + }, + { + model_config_id: "20000000-0000-0000-0000-000000000002", + display_name: "Large Model", + model: "large-2", + provider: "anthropic", + context_limit: 1_000_000, + is_default: false, + reasoning_efforts: [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + ], + }, + { + model_config_id: "30000000-0000-0000-0000-000000000003", + display_name: "", + model: "gemini-3.6-flash", + provider: "google", + context_limit: 262_000, + is_default: false, + }, + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const header = canvas.getByRole("button", { + name: /Listed 3 subagent models/, + }); + expect(header).toBeInTheDocument(); + await userEvent.click(header); + expect(canvas.getByText("OpenAI")).toBeInTheDocument(); + expect(canvas.getByText("Anthropic")).toBeInTheDocument(); + expect(canvas.getByText("Google")).toBeInTheDocument(); + expect(canvas.getByText("Fast Model")).toBeInTheDocument(); + expect(canvas.getByText("(200K)")).toBeInTheDocument(); + expect(canvas.getByText("low - high")).toBeInTheDocument(); + expect(canvas.getByText("Large Model")).toBeInTheDocument(); + expect(canvas.getByText("(1M)")).toBeInTheDocument(); + expect(canvas.getByText("none - max")).toBeInTheDocument(); + expect(canvas.getByText("(262K)")).toBeInTheDocument(); + expect(canvas.getByText("gemini-3.6-flash")).toBeInTheDocument(); + }, +}; + +export const ListSubagentModelsRunning: Story = { + args: { + name: "list_subagent_models", + status: "running", + args: {}, + result: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Listing subagent models…")).toBeInTheDocument(); + }, +}; + +export const ListSubagentModelsEmpty: Story = { + args: { + name: "list_subagent_models", + status: "completed", + args: {}, + result: { + models: [], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Listed 0 subagent models")).toBeInTheDocument(); + }, +}; + +export const ListSubagentModelsError: Story = { + args: { + name: "list_subagent_models", + status: "error", + isError: true, + args: {}, + result: "list_subagent_models is only available on root chats", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Listed 0 subagent models")).toBeInTheDocument(); + }, +}; + // --------------------------------------------------------------------------- // ListTemplates stories // --------------------------------------------------------------------------- diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index 299716a156edb..58e38f35d1373 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -16,6 +16,7 @@ import { DiffFileHeader } from "./DiffFileHeader"; import { EditFilesTool } from "./EditFilesTool"; import { ExecuteTool as ExecuteToolComponent } from "./ExecuteTool"; import { ListAgentsTool } from "./ListAgentsTool"; +import { ListSubagentModelsTool } from "./ListSubagentModelsTool"; import { ListTemplatesTool } from "./ListTemplatesTool"; import { ProcessOutputTool } from "./ProcessOutputTool"; import { ProposePlanTool } from "./ProposePlanTool"; @@ -573,6 +574,24 @@ const ListAgentsRenderer: FC = ({ ); }; +const ListSubagentModelsRenderer: FC = ({ + status, + result, + isError, +}) => { + const rec = asRecord(result); + const models = rec && Array.isArray(rec.models) ? rec.models : []; + + return ( + + ); +}; + const ReadTemplateRenderer: FC = ({ status, result, @@ -1024,6 +1043,7 @@ export const toolRenderers: Record> = { start_workspace: StartWorkspaceRenderer, list_templates: ListTemplatesRenderer, list_agents: ListAgentsRenderer, + list_subagent_models: ListSubagentModelsRenderer, read_template: ReadTemplateRenderer, read_skill: ReadSkillRenderer, read_skill_file: ReadSkillFileRenderer, diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolIcon.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolIcon.tsx index b74f26d14835c..0d5cc6448b32d 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolIcon.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolIcon.tsx @@ -38,6 +38,7 @@ export const toolIcons: Partial> = { start_workspace: PowerIcon, chat_summarized: BotIcon, list_agents: BotIcon, + list_subagent_models: BotIcon, thinking: LightbulbIcon, propose_plan: RouteIcon, ask_user_question: BadgeQuestionMarkIcon,