From 11f2dbadedde0a217ba8015f16ef7c4cc2f003be Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 5 May 2026 13:08:16 -0700 Subject: [PATCH] feat(agents): add agents list and demote presets to UI configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `superset agents list` for the CLI/SDK/MCP and reshapes how the host service exposes terminal-agent presets. **Why.** The host service was exposing two parallel concepts as runtime data: configured rows (`hostAgentConfigs`) and a hardcoded preset catalog (`AGENT_PRESETS`). The catalog isn't actually data — it's static configuration that ships with the host-service binary, version- locked to whatever desktop release the user has installed. Treating it as a runtime API resource forced the desktop detail view to fetch the catalog separately just to render row descriptions, made `add` validate `presetId` against an enum that blocks truly custom agents, and bloated the CLI/SDK/MCP with a parallel `listPresets` surface. **Changes.** - Move `AGENT_PRESETS` (V2 host-service preset list) from the host service into `packages/shared/src/host-agent-presets.ts` as `HOST_AGENT_PRESETS` / `HostAgentPreset` (named distinctly from V1's `AgentPresetField` / `AgentPresetOverride` types in the V1 desktop agent settings system, which is untouched). - Drop `settings.agentConfigs.listPresets` from the host service. The desktop V2 agents picker now reads the bundled const directly. - Reshape `settings.agentConfigs.add` to accept the full launch row (`{ label, command, args, promptTransport, promptArgs, env, presetId? }`). `presetId` is now free-form metadata used by the client for icon and description lookup, defaulting to `"custom"` when omitted. Truly custom agents are now possible at the API level. - Add `superset agents list` for configured rows (no `--presets` flag — the catalog isn't runtime data). - Add `Agents` resource to the SDK (`agents.list`, `agents.run` — routes the run through the cloud workspace lookup like the CLI does). - Add `agents_list` MCP tool, plus a new `hostServiceQuery` GET helper in `packages/mcp-v2/src/host-service-client.ts` (the existing helper only handled mutations). **Out of scope.** "Custom agent" form in the desktop picker (UI to enter a label/command/args/env), `agents add` in the CLI/SDK/MCP for headless custom installs, and unifying the V1 (`BUILTIN_TERMINAL_AGENTS`) and V2 (`HOST_AGENT_PRESETS`) catalogs into one. Those each deserve their own PR. **Verification.** - `bun run lint` and `bun run typecheck` (turbo, full repo) clean. - `bun test packages/host-service/src/trpc/router/settings/agent-configs.test.ts` — 21 pass, including the new "accepts a fully custom row" and "preserves an arbitrary presetId tag" cases. - `bun run build` in `packages/sdk` clean. - `superset agents list --local` prints the configured-row table. - `superset agents list --local --presets` errors with "Unknown option". --- .../useV2AgentConfigs/useV2AgentConfigs.ts | 4 +- .../V2AgentsSettings/V2AgentsSettings.tsx | 58 ++++---- .../components/AgentDetail/AgentDetail.tsx | 8 +- .../AgentsSettingsSidebar.tsx | 16 +-- .../cli/src/commands/agents/list/command.ts | 36 +++++ packages/cli/src/commands/agents/meta.ts | 2 +- .../router/settings/agent-configs.test.ts | 89 +++++++++--- .../src/trpc/router/settings/agent-configs.ts | 119 ++++++++-------- .../src/trpc/router/settings/index.ts | 3 +- packages/mcp-v2/src/host-service-client.ts | 91 ++++--------- packages/mcp-v2/src/tools/agents/list.ts | 44 ++++++ packages/mcp-v2/src/tools/agents/run.ts | 13 +- packages/mcp-v2/src/tools/register.ts | 2 + .../mcp-v2/src/tools/workspaces/create.ts | 42 ++---- .../mcp-v2/src/tools/workspaces/delete.ts | 5 +- packages/sdk/src/client.ts | 22 +++ packages/sdk/src/index.ts | 7 + packages/sdk/src/resources/agents.ts | 128 ++++++++++++++++++ packages/sdk/src/resources/index.ts | 9 ++ packages/shared/package.json | 4 + .../src/host-agent-presets.ts} | 22 +-- 21 files changed, 482 insertions(+), 242 deletions(-) create mode 100644 packages/cli/src/commands/agents/list/command.ts create mode 100644 packages/mcp-v2/src/tools/agents/list.ts create mode 100644 packages/sdk/src/resources/agents.ts rename packages/{host-service/src/trpc/router/settings/agent-presets.ts => shared/src/host-agent-presets.ts} (83%) diff --git a/apps/desktop/src/renderer/hooks/useV2AgentConfigs/useV2AgentConfigs.ts b/apps/desktop/src/renderer/hooks/useV2AgentConfigs/useV2AgentConfigs.ts index c1dac607509..cfa13c00237 100644 --- a/apps/desktop/src/renderer/hooks/useV2AgentConfigs/useV2AgentConfigs.ts +++ b/apps/desktop/src/renderer/hooks/useV2AgentConfigs/useV2AgentConfigs.ts @@ -1,4 +1,4 @@ -import type { HostAgentConfigDto } from "@superset/host-service/settings"; +import type { HostAgentConfig } from "@superset/host-service/settings"; import { useQuery } from "@tanstack/react-query"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; @@ -17,7 +17,7 @@ export function useV2AgentConfigs(hostUrl: string | null) { queryKey: [...V2_AGENT_CONFIGS_QUERY_KEY, hostUrl] as const, enabled: !!hostUrl, queryFn: () => { - if (!hostUrl) return [] as HostAgentConfigDto[]; + if (!hostUrl) return [] as HostAgentConfig[]; return getHostServiceClientByUrl( hostUrl, ).settings.agentConfigs.list.query(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/V2AgentsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/V2AgentsSettings.tsx index 9100aead644..a350229075e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/V2AgentsSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/V2AgentsSettings.tsx @@ -1,12 +1,13 @@ -import type { - AgentPreset, - HostAgentConfigDto, -} from "@superset/host-service/settings"; +import type { HostAgentConfig } from "@superset/host-service/settings"; +import { + HOST_AGENT_PRESETS, + type HostAgentPreset, +} from "@superset/shared/host-agent-presets"; import { Skeleton } from "@superset/ui/skeleton"; import { toast } from "@superset/ui/sonner"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Bot } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { V2_AGENT_CONFIGS_QUERY_KEY as QUERY_KEY, useV2AgentConfigs, @@ -16,32 +17,33 @@ import { useLocalHostService } from "renderer/routes/_authenticated/providers/Lo import { AgentDetail } from "./components/AgentDetail"; import { AgentsSettingsSidebar } from "./components/AgentsSettingsSidebar"; +const KNOWN_PRESETS: HostAgentPreset[] = HOST_AGENT_PRESETS.map((preset) => ({ + ...preset, + args: [...preset.args], + promptArgs: [...preset.promptArgs], + env: { ...preset.env }, +})); + +const DESCRIPTION_BY_PRESET_ID = new Map( + KNOWN_PRESETS.map((preset) => [preset.presetId, preset.description]), +); + export function V2AgentsSettings() { const { activeHostUrl } = useLocalHostService(); const queryClient = useQueryClient(); const configsQuery = useV2AgentConfigs(activeHostUrl); - const presetsQuery = useQuery({ - queryKey: [...QUERY_KEY, "presets", activeHostUrl] as const, - enabled: !!activeHostUrl, - queryFn: () => { - if (!activeHostUrl) return [] as AgentPreset[]; - return getHostServiceClientByUrl( - activeHostUrl, - ).settings.agentConfigs.listPresets.query(); - }, - }); - const invalidate = () => queryClient.invalidateQueries({ queryKey: [...QUERY_KEY, activeHostUrl] }); const addMutation = useMutation({ - mutationFn: (presetId: string) => { + mutationFn: (preset: HostAgentPreset) => { if (!activeHostUrl) throw new Error("Host service is not available"); + const { description: _description, ...body } = preset; return getHostServiceClientByUrl( activeHostUrl, - ).settings.agentConfigs.add.mutate({ presetId }); + ).settings.agentConfigs.add.mutate(body); }, onSuccess: (added) => { invalidate(); @@ -62,7 +64,7 @@ export function V2AgentsSettings() { await queryClient.cancelQueries({ queryKey: [...QUERY_KEY, activeHostUrl], }); - const previous = queryClient.getQueryData([ + const previous = queryClient.getQueryData([ ...QUERY_KEY, activeHostUrl, ]); @@ -73,7 +75,7 @@ export function V2AgentsSettings() { const row = byId.get(id); return row ? { ...row, order: index } : null; }) - .filter((row): row is HostAgentConfigDto => row !== null); + .filter((row): row is HostAgentConfig => row !== null); queryClient.setQueryData([...QUERY_KEY, activeHostUrl], next); } return { previous }; @@ -103,11 +105,9 @@ export function V2AgentsSettings() { }); const configs = configsQuery.data ?? []; - const presets = presetsQuery.data ?? []; - const descriptionByPresetId = useMemo( - () => - new Map(presets.map((preset) => [preset.presetId, preset.description])), - [presets], + const installedPresetIds = new Set(configs.map((row) => row.presetId)); + const addablePresets = KNOWN_PRESETS.filter( + (preset) => !installedPresetIds.has(preset.presetId), ); const [selectedAgentId, setSelectedAgentId] = useState(null); @@ -143,10 +143,10 @@ export function V2AgentsSettings() { ) : ( addMutation.mutate(presetId)} + onAddAgent={(preset) => addMutation.mutate(preset)} onReorder={(ids) => reorderMutation.mutate(ids)} onResetToDefaults={() => resetMutation.mutate()} isAdding={addMutation.isPending} @@ -159,7 +159,7 @@ export function V2AgentsSettings() { key={selectedAgent.id} config={selectedAgent} description={ - descriptionByPresetId.get(selectedAgent.presetId) ?? + DESCRIPTION_BY_PRESET_ID.get(selectedAgent.presetId) ?? "Terminal agent launch configuration" } onChanged={invalidate} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx index daacb0e4596..abaae5534ee 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx @@ -1,7 +1,5 @@ -import type { - HostAgentConfigDto, - PromptTransport, -} from "@superset/host-service/settings"; +import type { HostAgentConfig } from "@superset/host-service/settings"; +import type { PromptTransport } from "@superset/shared/agent-prompt-launch"; import { Button } from "@superset/ui/button"; import { Input } from "@superset/ui/input"; import { Label } from "@superset/ui/label"; @@ -24,7 +22,7 @@ import { } from "../../utils/argv"; interface AgentDetailProps { - config: HostAgentConfigDto; + config: HostAgentConfig; description: string; onChanged: () => void; onDeleted: () => void; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentsSettingsSidebar/AgentsSettingsSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentsSettingsSidebar/AgentsSettingsSidebar.tsx index 5512d99f47f..eda8a924288 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentsSettingsSidebar/AgentsSettingsSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentsSettingsSidebar/AgentsSettingsSidebar.tsx @@ -15,10 +15,8 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import type { - AgentPreset, - HostAgentConfigDto, -} from "@superset/host-service/settings"; +import type { HostAgentConfig } from "@superset/host-service/settings"; +import type { HostAgentPreset } from "@superset/shared/host-agent-presets"; import { DropdownMenu, DropdownMenuContent, @@ -40,11 +38,11 @@ import { } from "../../../../../components/SettingsListSidebar"; interface AgentsSettingsSidebarProps { - configs: HostAgentConfigDto[]; - presets: AgentPreset[]; + configs: HostAgentConfig[]; + presets: HostAgentPreset[]; selectedAgentId: string | null; onSelectAgent: (id: string) => void; - onAddAgent: (presetId: string) => void; + onAddAgent: (preset: HostAgentPreset) => void; onReorder: (orderedIds: string[]) => void; onResetToDefaults: () => void; isAdding: boolean; @@ -100,7 +98,7 @@ export function AgentsSettingsSidebar({ return ( onAddAgent(preset.presetId)} + onSelect={() => onAddAgent(preset)} className="gap-2" > {icon ? ( @@ -161,7 +159,7 @@ export function AgentsSettingsSidebar({ } interface AgentSidebarRowProps { - row: HostAgentConfigDto; + row: HostAgentConfig; isActive: boolean; onSelect: () => void; isDark: boolean; diff --git a/packages/cli/src/commands/agents/list/command.ts b/packages/cli/src/commands/agents/list/command.ts new file mode 100644 index 00000000000..464d549c507 --- /dev/null +++ b/packages/cli/src/commands/agents/list/command.ts @@ -0,0 +1,36 @@ +import { boolean, CLIError, string, table } from "@superset/cli-framework"; +import { command } from "../../../lib/command"; +import { requireHostTarget, resolveHostTarget } from "../../../lib/host-target"; + +export default command({ + description: "List agents configured on a host", + options: { + host: string().desc("Target host machineId"), + local: boolean().desc("Target this machine"), + }, + display: (data) => + table( + (data ?? []) as Record[], + ["label", "presetId", "command", "id"], + ["LABEL", "PRESET", "COMMAND", "ID"], + ), + run: async ({ ctx, options }) => { + const organizationId = ctx.config.organizationId; + if (!organizationId) { + throw new CLIError("No active organization", "Run: superset auth login"); + } + + const hostId = requireHostTarget({ + host: options.host ?? undefined, + local: options.local ?? undefined, + }); + + const target = resolveHostTarget({ + requestedHostId: hostId, + organizationId, + userJwt: ctx.bearer, + }); + + return target.client.settings.agentConfigs.list.query(); + }, +}); diff --git a/packages/cli/src/commands/agents/meta.ts b/packages/cli/src/commands/agents/meta.ts index 94b706332f4..9d1839404d5 100644 --- a/packages/cli/src/commands/agents/meta.ts +++ b/packages/cli/src/commands/agents/meta.ts @@ -1,3 +1,3 @@ export default { - description: "Run agents inside workspaces", + description: "Manage and run agents", }; diff --git a/packages/host-service/src/trpc/router/settings/agent-configs.test.ts b/packages/host-service/src/trpc/router/settings/agent-configs.test.ts index 6561aa5998b..77266a31375 100644 --- a/packages/host-service/src/trpc/router/settings/agent-configs.test.ts +++ b/packages/host-service/src/trpc/router/settings/agent-configs.test.ts @@ -1,12 +1,19 @@ import { Database } from "bun:sqlite"; import { describe, expect, it } from "bun:test"; import { resolve } from "node:path"; +import { getPresetById } from "@superset/shared/host-agent-presets"; import { drizzle } from "drizzle-orm/bun-sqlite"; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import * as schema from "../../../db/schema"; import type { HostServiceContext } from "../../../types"; import { agentConfigsRouter } from "./agent-configs"; -import { AGENT_PRESETS } from "./agent-presets"; + +function presetBody(presetId: string) { + const preset = getPresetById(presetId); + if (!preset) throw new Error(`unknown test preset ${presetId}`); + const { description: _description, ...rest } = preset; + return rest; +} const MIGRATIONS_FOLDER = resolve(import.meta.dir, "../../../../drizzle"); @@ -75,22 +82,12 @@ describe("agentConfigsRouter", () => { }); }); - describe("listPresets()", () => { - it("returns the full hardcoded preset catalog", async () => { - const caller = createCaller(); - const presets = await caller.listPresets(); - expect(presets.map((preset) => preset.presetId)).toEqual( - AGENT_PRESETS.map((preset) => preset.presetId), - ); - }); - }); - describe("add()", () => { - it("copies preset fields and assigns a unique id and next order", async () => { + it("inserts a row with the supplied launch shape and next order", async () => { const caller = createCaller(); await caller.list(); - const created = await caller.add({ presetId: "pi" }); + const created = await caller.add(presetBody("pi")); expect(created.presetId).toBe("pi"); expect(created.command).toBe("pi"); @@ -101,12 +98,12 @@ describe("agentConfigsRouter", () => { expect(new Set(all.map((row) => row.id)).size).toBe(6); }); - it("allows duplicate presetId entries with distinct ids", async () => { + it("allows duplicate presetId tags with distinct ids", async () => { const caller = createCaller(); await caller.list(); - const a = await caller.add({ presetId: "claude" }); - const b = await caller.add({ presetId: "claude" }); + const a = await caller.add(presetBody("claude")); + const b = await caller.add(presetBody("claude")); expect(a.id).not.toBe(b.id); const claudes = (await caller.list()).filter( @@ -115,10 +112,64 @@ describe("agentConfigsRouter", () => { expect(claudes).toHaveLength(3); }); - it("rejects unknown presetId", async () => { + it("accepts a fully custom row and defaults presetId to 'custom'", async () => { const caller = createCaller(); + await caller.list(); + + const created = await caller.add({ + label: "My Agent", + command: "my-agent", + args: ["--flag"], + promptTransport: "argv", + promptArgs: [], + env: { FOO: "bar" }, + }); + + expect(created.presetId).toBe("custom"); + expect(created.label).toBe("My Agent"); + expect(created.command).toBe("my-agent"); + expect(created.args).toEqual(["--flag"]); + expect(created.env).toEqual({ FOO: "bar" }); + }); + + it("preserves an arbitrary presetId tag verbatim", async () => { + const caller = createCaller(); + await caller.list(); + + const created = await caller.add({ + label: "Bespoke", + command: "bespoke", + args: [], + promptTransport: "argv", + promptArgs: [], + env: {}, + presetId: "my-bespoke-tag", + }); + + expect(created.presetId).toBe("my-bespoke-tag"); + }); + + it("rejects empty label or command", async () => { + const caller = createCaller(); + await expect( + caller.add({ + label: "", + command: "x", + args: [], + promptTransport: "argv", + promptArgs: [], + env: {}, + }), + ).rejects.toThrow(); await expect( - caller.add({ presetId: "nonexistent-preset" }), + caller.add({ + label: "x", + command: "", + args: [], + promptTransport: "argv", + promptArgs: [], + env: {}, + }), ).rejects.toThrow(); }); }); @@ -259,7 +310,7 @@ describe("agentConfigsRouter", () => { id: seedFirst.id, patch: { label: "Renamed" }, }); - await caller.add({ presetId: "pi" }); + await caller.add(presetBody("pi")); const result = await caller.resetToDefaults(); diff --git a/packages/host-service/src/trpc/router/settings/agent-configs.ts b/packages/host-service/src/trpc/router/settings/agent-configs.ts index 278a54b06fd..66a710361d3 100644 --- a/packages/host-service/src/trpc/router/settings/agent-configs.ts +++ b/packages/host-service/src/trpc/router/settings/agent-configs.ts @@ -1,30 +1,22 @@ import { randomUUID } from "node:crypto"; +import type { PromptTransport } from "@superset/shared/agent-prompt-launch"; +import { + getDefaultSeedPresets, + type HostAgentPreset, +} from "@superset/shared/host-agent-presets"; import { TRPCError } from "@trpc/server"; import { asc, eq, inArray } from "drizzle-orm"; import { z } from "zod"; import type { HostDb } from "../../../db"; import { hostAgentConfigs } from "../../../db/schema"; import { protectedProcedure, router } from "../../index"; -import { - AGENT_PRESETS, - type AgentPreset, - getDefaultSeedPresets, - getPresetById, - type PromptTransport, -} from "./agent-presets"; const promptTransportSchema = z.enum(["argv", "stdin"]); -const presetIdSchema = z - .string() - .refine((value) => getPresetById(value) !== undefined, { - message: "Unknown presetId", - }); - const argvSchema = z.array(z.string()); const envSchema = z.record(z.string(), z.string()); -interface HostAgentConfigOutput { +export interface HostAgentConfig { id: string; presetId: string; label: string; @@ -82,7 +74,7 @@ function parseEnv(value: string): Record { return parsed as Record; } -function toOutput(row: HostAgentConfigRow): HostAgentConfigOutput { +function toOutput(row: HostAgentConfigRow): HostAgentConfig { return { id: row.id, presetId: row.presetId, @@ -97,7 +89,7 @@ function toOutput(row: HostAgentConfigRow): HostAgentConfigOutput { } function rowFromPreset( - preset: AgentPreset, + preset: HostAgentPreset, displayOrder: number, ): typeof hostAgentConfigs.$inferInsert { return { @@ -152,6 +144,16 @@ const updatePatchSchema = z { message: "Patch must update at least one field" }, ); +const addInputSchema = z.object({ + label: z.string().trim().min(1), + command: z.string().trim().min(1), + args: argvSchema, + promptTransport: promptTransportSchema, + promptArgs: argvSchema, + env: envSchema, + presetId: z.string().trim().min(1).optional(), +}); + export const agentConfigsRouter = router({ /** * List configured host agents in persisted order. Seeds bundled defaults @@ -163,52 +165,45 @@ export const agentConfigsRouter = router({ }), /** - * Available add templates. Returns the hardcoded preset list — the UI - * uses this to render the "add agent" picker. + * Insert a configured host-agent row. Callers pass the full launch shape; + * `presetId` is a free-form metadata tag the client uses for icon and + * description lookup, defaulting to `"custom"` when omitted. Duplicate + * `presetId` values are allowed — each row gets a fresh `id`. */ - listPresets: protectedProcedure.query(() => - AGENT_PRESETS.map((preset) => ({ - ...preset, - args: [...preset.args], - promptArgs: [...preset.promptArgs], - env: { ...preset.env }, - })), - ), - - /** - * Create a new host agent config from a hardcoded preset. Allows - * duplicate `presetId` entries — each gets a fresh `id`. - */ - add: protectedProcedure - .input(z.object({ presetId: presetIdSchema })) - .mutation(({ ctx, input }) => { - const preset = getPresetById(input.presetId); - if (!preset) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Unknown presetId: ${input.presetId}`, - }); - } - const existing = listOrdered(ctx.db); - const nextOrder = - existing.length === 0 - ? 0 - : Math.max(...existing.map((row) => row.displayOrder)) + 1; - const insert = rowFromPreset(preset, nextOrder); - ctx.db.insert(hostAgentConfigs).values(insert).run(); - const created = ctx.db - .select() - .from(hostAgentConfigs) - .where(eq(hostAgentConfigs.id, insert.id)) - .get(); - if (!created) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to read back inserted host agent config", - }); - } - return toOutput(created); - }), + add: protectedProcedure.input(addInputSchema).mutation(({ ctx, input }) => { + const existing = listOrdered(ctx.db); + const nextOrder = + existing.length === 0 + ? 0 + : Math.max(...existing.map((row) => row.displayOrder)) + 1; + const id = randomUUID(); + ctx.db + .insert(hostAgentConfigs) + .values({ + id, + presetId: input.presetId ?? "custom", + label: input.label, + command: input.command, + argsJson: JSON.stringify(input.args), + promptTransport: input.promptTransport, + promptArgsJson: JSON.stringify(input.promptArgs), + envJson: JSON.stringify(input.env), + displayOrder: nextOrder, + }) + .run(); + const created = ctx.db + .select() + .from(hostAgentConfigs) + .where(eq(hostAgentConfigs.id, id)) + .get(); + if (!created) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to read back inserted host agent config", + }); + } + return toOutput(created); + }), /** * Update editable fields on an existing config. `presetId` and `order` @@ -358,5 +353,3 @@ export const agentConfigsRouter = router({ return listOrdered(ctx.db).map(toOutput); }), }); - -export type HostAgentConfigDto = HostAgentConfigOutput; diff --git a/packages/host-service/src/trpc/router/settings/index.ts b/packages/host-service/src/trpc/router/settings/index.ts index 14837d2f53d..18b91e73211 100644 --- a/packages/host-service/src/trpc/router/settings/index.ts +++ b/packages/host-service/src/trpc/router/settings/index.ts @@ -5,5 +5,4 @@ export const settingsRouter = router({ agentConfigs: agentConfigsRouter, }); -export type { HostAgentConfigDto } from "./agent-configs"; -export type { AgentPreset, PromptTransport } from "./agent-presets"; +export type { HostAgentConfig } from "./agent-configs"; diff --git a/packages/mcp-v2/src/host-service-client.ts b/packages/mcp-v2/src/host-service-client.ts index 916d7594001..edddeb1d64b 100644 --- a/packages/mcp-v2/src/host-service-client.ts +++ b/packages/mcp-v2/src/host-service-client.ts @@ -6,59 +6,46 @@ export interface HostServiceCallOptions { organizationId: string; hostId: string; jwt: string; - timeoutMs?: number; } -export class HostServiceCallError extends Error { - constructor( - message: string, - public readonly status: number, - public readonly body: string, - ) { - super(message); - this.name = "HostServiceCallError"; - } -} - -export async function hostServiceMutation( +export async function hostServiceCall( options: HostServiceCallOptions, procedure: string, - input: TInput, + method: "query" | "mutation", + input?: unknown, ): Promise { const routingKey = buildHostRoutingKey( options.organizationId, options.hostId, ); - const url = `${options.relayUrl}/hosts/${routingKey}/trpc/${procedure}`; - const encoded = SuperJSON.serialize(input); - - const controller = new AbortController(); - const timer = setTimeout( - () => controller.abort(), - options.timeoutMs ?? 25_000, - ); + const baseUrl = `${options.relayUrl}/hosts/${routingKey}/trpc/${procedure}`; + const headers: Record = { + authorization: `Bearer ${options.jwt}`, + }; - let response: Response; - try { - response = await fetch(url, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${options.jwt}`, - }, - body: JSON.stringify(encoded), - signal: controller.signal, - }); - } finally { - clearTimeout(timer); + let url = baseUrl; + let body: string | undefined; + if (method === "query") { + if (input !== undefined) { + const encoded = encodeURIComponent( + JSON.stringify(SuperJSON.serialize(input)), + ); + url = `${baseUrl}?input=${encoded}`; + } + } else { + headers["content-type"] = "application/json"; + body = JSON.stringify(SuperJSON.serialize(input)); } + const response = await fetch(url, { + method: method === "query" ? "GET" : "POST", + headers, + body, + }); const rawBody = await response.text(); if (!response.ok) { - throw new HostServiceCallError( - describeRelayFailure(response.status, rawBody, options.hostId, procedure), - response.status, - rawBody, + throw new Error( + `Host ${options.hostId} returned ${response.status} for ${procedure}: ${rawBody.slice(0, 200)}`, ); } @@ -67,38 +54,18 @@ export async function hostServiceMutation( try { parsed = JSON.parse(rawBody) as TrpcEnvelope; } catch { - throw new HostServiceCallError( - `invalid JSON from relay: ${rawBody.slice(0, 200)}`, - response.status, - rawBody, + throw new Error( + `Invalid JSON from host ${options.hostId} for ${procedure}: ${rawBody.slice(0, 200)}`, ); } const data = parsed.result?.data; if (data === undefined || data === null) { - throw new HostServiceCallError( + throw new Error( `Malformed response from host ${options.hostId} for ${procedure}`, - response.status, - rawBody, ); } return SuperJSON.deserialize( data as Parameters[0], ) as TOutput; } - -function describeRelayFailure( - status: number, - rawBody: string, - hostId: string, - procedure: string, -): string { - const trimmed = rawBody.slice(0, 200); - if (status === 503 && /host not connected/i.test(trimmed)) { - return `Host ${hostId} has not enabled remote access. Toggle "Allow remote workspaces to access this device" in Settings → Security on that machine.`; - } - if (status === 401) return "You are not authenticated"; - if (status === 403) return `You don't have access to host ${hostId}`; - if (status === 404) return `Host ${hostId} not found`; - return `Host ${hostId} returned ${status} for ${procedure}: ${trimmed}`; -} diff --git a/packages/mcp-v2/src/tools/agents/list.ts b/packages/mcp-v2/src/tools/agents/list.ts new file mode 100644 index 00000000000..74c98c18682 --- /dev/null +++ b/packages/mcp-v2/src/tools/agents/list.ts @@ -0,0 +1,44 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { defineTool } from "../../define-tool"; +import { hostServiceCall } from "../../host-service-client"; + +interface HostAgentConfig { + id: string; + presetId: string; + label: string; + command: string; + args: string[]; + promptTransport: "argv" | "stdin"; + promptArgs: string[]; + env: Record; + order: number; +} + +export function register(server: McpServer): void { + defineTool(server, { + name: "agents_list", + description: + "List terminal-agent instances configured on a host (the rows in Settings → Agents on that machine). Returns each row with its instance UUID, presetId, label, command, args, and env. Use to find an `agent` value for `agents_run` or to confirm what's installed before launching.", + inputSchema: { + hostId: z + .string() + .min(1) + .describe( + "Host machineId to query. See `hosts_list` to enumerate accessible hosts.", + ), + }, + handler: async (input, ctx) => { + return hostServiceCall( + { + relayUrl: ctx.relayUrl, + organizationId: ctx.organizationId, + hostId: input.hostId, + jwt: ctx.bearerToken, + }, + "settings.agentConfigs.list", + "query", + ); + }, + }); +} diff --git a/packages/mcp-v2/src/tools/agents/run.ts b/packages/mcp-v2/src/tools/agents/run.ts index 4ee1b558d30..e37c8e33a87 100644 --- a/packages/mcp-v2/src/tools/agents/run.ts +++ b/packages/mcp-v2/src/tools/agents/run.ts @@ -2,7 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createMcpCaller } from "../../caller"; import { defineTool } from "../../define-tool"; -import { hostServiceMutation } from "../../host-service-client"; +import { hostServiceCall } from "../../host-service-client"; export function register(server: McpServer): void { defineTool(server, { @@ -38,15 +38,7 @@ export function register(server: McpServer): void { throw new Error(`Workspace not found: ${input.workspaceId}`); } - return hostServiceMutation< - { - workspaceId: string; - agent: string; - prompt: string; - attachmentIds?: string[]; - }, - { sessionId: string; label: string } - >( + return hostServiceCall<{ sessionId: string; label: string }>( { relayUrl: ctx.relayUrl, organizationId: ctx.organizationId, @@ -54,6 +46,7 @@ export function register(server: McpServer): void { jwt: ctx.bearerToken, }, "agents.run", + "mutation", { workspaceId: input.workspaceId, agent: input.agent, diff --git a/packages/mcp-v2/src/tools/register.ts b/packages/mcp-v2/src/tools/register.ts index bcc8ec0bf58..555d4c624b6 100644 --- a/packages/mcp-v2/src/tools/register.ts +++ b/packages/mcp-v2/src/tools/register.ts @@ -4,6 +4,7 @@ import { setServerToolCallEmitter, } from "../define-tool"; +import * as agentsList from "./agents/list"; import * as agentsRun from "./agents/run"; import * as automationsCreate from "./automations/create"; import * as automationsDelete from "./automations/delete"; @@ -48,6 +49,7 @@ const REGISTRARS = [ workspacesCreate, workspacesDelete, agentsRun, + agentsList, projectsList, hostsList, ]; diff --git a/packages/mcp-v2/src/tools/workspaces/create.ts b/packages/mcp-v2/src/tools/workspaces/create.ts index 1378a79ea3e..ee5c3a601e5 100644 --- a/packages/mcp-v2/src/tools/workspaces/create.ts +++ b/packages/mcp-v2/src/tools/workspaces/create.ts @@ -1,7 +1,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { defineTool } from "../../define-tool"; -import { hostServiceMutation } from "../../host-service-client"; +import { hostServiceCall } from "../../host-service-client"; const agentLaunchSchema = z.object({ agent: z @@ -65,35 +65,20 @@ export function register(server: McpServer): void { ), }, handler: async (input, ctx) => { - return hostServiceMutation< - { + return hostServiceCall<{ + workspace: { + id: string; projectId: string; name: string; - branch?: string; - pr?: number; - baseBranch?: string; - taskId?: string; - agents?: Array<{ - agent: string; - prompt: string; - attachmentIds?: string[]; - }>; - }, - { - workspace: { - id: string; - projectId: string; - name: string; - branch: string; - }; - terminals: Array<{ terminalId: string; label?: string }>; - agents: Array< - | { ok: true; sessionId: string; label: string } - | { ok: false; error: string } - >; - alreadyExists: boolean; - } - >( + branch: string; + }; + terminals: Array<{ terminalId: string; label?: string }>; + agents: Array< + | { ok: true; sessionId: string; label: string } + | { ok: false; error: string } + >; + alreadyExists: boolean; + }>( { relayUrl: ctx.relayUrl, organizationId: ctx.organizationId, @@ -101,6 +86,7 @@ export function register(server: McpServer): void { jwt: ctx.bearerToken, }, "workspaces.create", + "mutation", { projectId: input.projectId, name: input.name, diff --git a/packages/mcp-v2/src/tools/workspaces/delete.ts b/packages/mcp-v2/src/tools/workspaces/delete.ts index e3500be13d4..1576e3d5f6b 100644 --- a/packages/mcp-v2/src/tools/workspaces/delete.ts +++ b/packages/mcp-v2/src/tools/workspaces/delete.ts @@ -2,7 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createMcpCaller } from "../../caller"; import { defineTool } from "../../define-tool"; -import { hostServiceMutation } from "../../host-service-client"; +import { hostServiceCall } from "../../host-service-client"; export function register(server: McpServer): void { defineTool(server, { @@ -21,7 +21,7 @@ export function register(server: McpServer): void { if (!workspace) { return { success: true, alreadyGone: true }; } - return hostServiceMutation<{ id: string }, { success: boolean }>( + return hostServiceCall<{ success: boolean }>( { relayUrl: ctx.relayUrl, organizationId: ctx.organizationId, @@ -29,6 +29,7 @@ export function register(server: McpServer): void { jwt: ctx.bearerToken, }, "workspace.delete", + "mutation", { id: input.id }, ); }, diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index d681543689f..764e46a2ef9 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -49,6 +49,15 @@ import { } from "./internal/utils/log"; import { stringifyQuery } from "./internal/utils/query"; import { isEmptyObj } from "./internal/utils/values"; +import { + AgentListParams, + AgentListResponse, + AgentRunParams, + AgentRunResult, + Agents, + HostAgentConfig, + PromptTransport, +} from "./resources/agents"; import { AgentConfig, Automation, @@ -1100,6 +1109,8 @@ export class Superset { hosts: API.Hosts = new API.Hosts(this); /** Recurring automations: full CRUD plus run/pause/resume/logs/prompt. */ automations: API.Automations = new API.Automations(this); + /** Agents (per-host terminal-agent rows): list, run. */ + agents: API.Agents = new API.Agents(this); } Superset.Tasks = Tasks; @@ -1107,6 +1118,7 @@ Superset.Workspaces = Workspaces; Superset.Projects = Projects; Superset.Hosts = Hosts; Superset.Automations = Automations; +Superset.Agents = Agents; export declare namespace Superset { export type RequestOptions = Opts.RequestOptions; @@ -1151,4 +1163,14 @@ export declare namespace Superset { AutomationLogsResponse, AgentConfig, }; + + export { + Agents, + HostAgentConfig, + AgentListResponse, + AgentListParams, + AgentRunParams, + AgentRunResult, + PromptTransport, + }; } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index ec30140fc5e..3d10e6ada42 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -24,6 +24,11 @@ export { toFile, type Uploadable } from "./core/uploads"; // the `Superset` namespace. export { type AgentConfig, + type AgentListParams, + type AgentListResponse, + type AgentRunParams, + type AgentRunResult, + Agents, type Automation, type AutomationCreateParams, type AutomationListResponse, @@ -35,12 +40,14 @@ export { type AutomationSummary, type AutomationUpdateParams, type Host, + type HostAgentConfig, type HostListResponse, Hosts, type HostWorkspace, type Project, type ProjectListResponse, Projects, + type PromptTransport, type Task, type TaskCreateParams, type TaskListItem, diff --git a/packages/sdk/src/resources/agents.ts b/packages/sdk/src/resources/agents.ts new file mode 100644 index 00000000000..5c90f4a03bd --- /dev/null +++ b/packages/sdk/src/resources/agents.ts @@ -0,0 +1,128 @@ +import { SupersetError } from "../core/error"; +import { APIResource } from "../core/resource"; +import type { RequestOptions } from "../internal/request-options"; + +/** + * Configured terminal-agent rows live on each developer's host service — + * one row per installed agent in Settings → Agents on that machine. Reads + * (`list`) and the launch action (`run`) are routed to a specific host + * through the relay tunnel. + * + * Mirrors the CLI's `superset agents …` commands. + */ +export class Agents extends APIResource { + /** + * List agents configured on a host — the rows that drive the agent picker + * inside workspaces, in persisted display order. Includes user edits to + * label/command/args/env. First call on a fresh host seeds bundled + * defaults. + * + * Mirrors `superset agents list --host `. + */ + list(params: AgentListParams, options?: RequestOptions) { + this._requireOrgId(); + return this._client.hostQuery( + params.hostId, + "settings.agentConfigs.list", + undefined, + options, + ); + } + + /** + * Launch an agent inside an existing workspace. Looks up the host that + * owns the workspace (cloud index) and starts the named preset (or + * HostAgentConfig instance) in a fresh terminal session on that host. + * Pass an explicit `hostId` to skip the lookup. + * + * Mirrors `superset agents run`. + */ + async run( + params: AgentRunParams, + options?: { hostId?: string }, + ): Promise { + this._requireOrgId(); + let hostId = options?.hostId; + if (!hostId) { + const cloud = await this._client.query( + "v2Workspace.getFromHost", + { + organizationId: this._client.organizationId, + id: params.workspaceId, + }, + ); + if (!cloud) { + throw new SupersetError(`Workspace not found: ${params.workspaceId}`); + } + hostId = cloud.hostId; + } + return this._client.hostMutation(hostId, "agents.run", { + workspaceId: params.workspaceId, + agent: params.agent, + prompt: params.prompt, + attachmentIds: params.attachmentIds, + }); + } + + private _requireOrgId(): string { + if (!this._client.organizationId) { + throw new SupersetError( + "organizationId is required. Set SUPERSET_ORGANIZATION_ID, or pass `organizationId` to the Superset constructor.", + ); + } + return this._client.organizationId; + } +} + +export type PromptTransport = "argv" | "stdin"; + +/** A configured terminal-agent row on a host (from `list`). */ +export interface HostAgentConfig { + id: string; + presetId: string; + label: string; + command: string; + args: string[]; + promptTransport: PromptTransport; + promptArgs: string[]; + env: Record; + order: number; +} + +export type AgentListResponse = Array; + +export interface AgentListParams { + /** Host machineId to query (see `hosts.list()`). */ + hostId: string; +} + +export interface AgentRunParams { + /** Workspace UUID to run the agent in. */ + workspaceId: string; + /** Agent preset id (e.g. `"claude"`) or HostAgentConfig instance UUID. */ + agent: string; + /** Prompt sent to the agent. */ + prompt: string; + /** Host-scoped attachment ids; host resolves to absolute paths in the prompt. */ + attachmentIds?: string[]; +} + +interface HostLookup { + hostId: string; +} + +export interface AgentRunResult { + sessionId: string; + label: string; +} + +export declare namespace Agents { + export type { + HostAgentConfig, + AgentListResponse, + AgentListParams, + AgentRunParams, + AgentRunResult, + PromptTransport, + }; +} diff --git a/packages/sdk/src/resources/index.ts b/packages/sdk/src/resources/index.ts index b67e6ced589..4136553f5ac 100644 --- a/packages/sdk/src/resources/index.ts +++ b/packages/sdk/src/resources/index.ts @@ -1,3 +1,12 @@ +export { + type AgentListParams, + type AgentListResponse, + type AgentRunParams, + type AgentRunResult, + Agents, + type HostAgentConfig, + type PromptTransport, +} from "./agents"; export { type AgentConfig, type Automation, diff --git a/packages/shared/package.json b/packages/shared/package.json index da76421903e..ea146b1bb16 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -36,6 +36,10 @@ "types": "./src/agent-catalog.ts", "default": "./src/agent-catalog.ts" }, + "./host-agent-presets": { + "types": "./src/host-agent-presets.ts", + "default": "./src/host-agent-presets.ts" + }, "./agent-launch": { "types": "./src/agent-launch.ts", "default": "./src/agent-launch.ts" diff --git a/packages/host-service/src/trpc/router/settings/agent-presets.ts b/packages/shared/src/host-agent-presets.ts similarity index 83% rename from packages/host-service/src/trpc/router/settings/agent-presets.ts rename to packages/shared/src/host-agent-presets.ts index 2ad480ced1c..c95234f3447 100644 --- a/packages/host-service/src/trpc/router/settings/agent-presets.ts +++ b/packages/shared/src/host-agent-presets.ts @@ -1,6 +1,6 @@ -export type PromptTransport = "argv" | "stdin"; +import type { PromptTransport } from "./agent-prompt-launch"; -export interface AgentPreset { +export interface HostAgentPreset { presetId: string; label: string; description: string; @@ -12,8 +12,10 @@ export interface AgentPreset { } /** - * Hardcoded terminal agent presets. Used as add templates and as the seed - * for first `list()` / `resetToDefaults()`. + * Hardcoded terminal agent presets. Used as the seed list when a host's + * agent table is empty, and as the install catalog the desktop picker + * renders. Lives here (not on the host service) because it's static + * configuration that ships with the binary, not data the API owns. * * Launch resolution: * prompt @@ -28,7 +30,7 @@ export interface AgentPreset { * Superset Chat is intentionally excluded — its model/provider config * lives in chat settings, not in terminal-agent configs. */ -export const AGENT_PRESETS = [ +export const HOST_AGENT_PRESETS = [ { presetId: "claude", label: "Claude", @@ -138,7 +140,7 @@ export const AGENT_PRESETS = [ promptArgs: [], env: {}, }, -] as const satisfies readonly AgentPreset[]; +] as const satisfies readonly HostAgentPreset[]; const DEFAULT_PRESET_IDS = new Set([ "claude", @@ -148,8 +150,8 @@ const DEFAULT_PRESET_IDS = new Set([ "copilot", ]); -export function getDefaultSeedPresets(): AgentPreset[] { - return AGENT_PRESETS.filter((preset) => +export function getDefaultSeedPresets(): HostAgentPreset[] { + return HOST_AGENT_PRESETS.filter((preset) => DEFAULT_PRESET_IDS.has(preset.presetId), ).map((preset) => ({ ...preset, @@ -159,8 +161,8 @@ export function getDefaultSeedPresets(): AgentPreset[] { })); } -export function getPresetById(presetId: string): AgentPreset | undefined { - const preset = AGENT_PRESETS.find((item) => item.presetId === presetId); +export function getPresetById(presetId: string): HostAgentPreset | undefined { + const preset = HOST_AGENT_PRESETS.find((item) => item.presetId === presetId); if (!preset) return undefined; return { ...preset,