diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index e5b25d1a..bbbb3597 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -2,7 +2,10 @@ import { shell, BrowserWindow } from 'electron'; import { createAuthServer } from './auth-server.js'; import * as composioClient from '@x/core/dist/composio/client.js'; import { composioAccountsRepo } from '@x/core/dist/composio/repo.js'; +import { composioEnabledToolsRepo } from '@x/core/dist/composio/enabled-tools-repo.js'; +import type { EnabledTool } from '@x/core/dist/composio/enabled-tools-repo.js'; import type { LocalConnectedAccount } from '@x/core/dist/composio/types.js'; +import { refreshComposioTools } from '@x/core/dist/application/lib/builtin-tools.js'; const REDIRECT_URI = 'http://localhost:8081/oauth/callback'; @@ -249,11 +252,16 @@ export async function disconnect(toolkitSlug: string): Promise<{ success: boolea // Delete local record composioAccountsRepo.deleteAccount(toolkitSlug); } + // Clean up enabled tools for this toolkit + composioEnabledToolsRepo.disableAllForToolkit(toolkitSlug); + refreshComposioTools(); return { success: true }; } catch (error) { console.error('[Composio] Disconnect failed:', error); // Still delete local record even if API call fails composioAccountsRepo.deleteAccount(toolkitSlug); + composioEnabledToolsRepo.disableAllForToolkit(toolkitSlug); + refreshComposioTools(); return { success: true }; } } @@ -294,3 +302,95 @@ export async function executeAction( }; } } + +/** + * List available Composio toolkits + */ +export async function listToolkits(cursor?: string): Promise<{ + items: Array<{ + slug: string; + name: string; + meta: { description: string; logo: string; tools_count: number; triggers_count: number }; + no_auth: boolean; + auth_schemes: string[]; + composio_managed_auth_schemes: string[]; + }>; + nextCursor: string | null; + totalItems: number; +}> { + const result = await composioClient.listToolkits(cursor || null); + return { + items: result.items, + nextCursor: result.next_cursor, + totalItems: result.total_items, + }; +} + +/** + * List tools for a toolkit with full details + */ +export async function listToolkitToolsDetailed(toolkitSlug: string, search?: string): Promise<{ + items: Array<{ + slug: string; + name: string; + description: string; + toolkitSlug: string; + inputParameters?: { type?: string; properties?: Record; required?: string[] }; + }>; +}> { + return composioClient.listToolkitToolsDetailed(toolkitSlug, search || null); +} + +/** + * Get all enabled tools + */ +export function getEnabledTools(): { + tools: Record; +} { + const all = composioEnabledToolsRepo.getAll(); + const tools: Record = {}; + for (const [slug, tool] of Object.entries(all)) { + tools[slug] = { + slug: tool.slug, + name: tool.name, + description: tool.description, + toolkitSlug: tool.toolkitSlug, + }; + } + return { tools }; +} + +/** + * Enable specific tools from a toolkit + */ +export function enableTools(tools: Array<{ + slug: string; + name: string; + description: string; + toolkitSlug: string; + inputParameters?: { type?: string; properties?: Record; required?: string[] }; +}>): { success: boolean } { + const enabledTools: EnabledTool[] = tools.map(t => ({ + slug: t.slug, + name: t.name, + description: t.description, + toolkitSlug: t.toolkitSlug, + inputParameters: { + type: 'object' as const, + properties: t.inputParameters?.properties ?? {}, + required: t.inputParameters?.required, + }, + })); + composioEnabledToolsRepo.enableBatch(enabledTools); + refreshComposioTools(); + return { success: true }; +} + +/** + * Disable specific tools + */ +export function disableTools(toolSlugs: string[]): { success: boolean } { + composioEnabledToolsRepo.disableBatch(toolSlugs); + refreshComposioTools(); + return { success: true }; +} diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 4d272275..8b8ebba8 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -443,6 +443,22 @@ export function setupIpcHandlers() { 'composio:execute-action': async (_event, args) => { return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input); }, + // Composio Tools Library handlers + 'composio:list-toolkits': async (_event, args) => { + return composioHandler.listToolkits(args.cursor); + }, + 'composio:list-toolkit-tools': async (_event, args) => { + return composioHandler.listToolkitToolsDetailed(args.toolkitSlug, args.search); + }, + 'composio:get-enabled-tools': async () => { + return composioHandler.getEnabledTools(); + }, + 'composio:enable-tools': async (_event, args) => { + return composioHandler.enableTools(args.tools); + }, + 'composio:disable-tools': async (_event, args) => { + return composioHandler.disableTools(args.toolSlugs); + }, // Agent schedule handlers 'agent-schedule:getConfig': async () => { const repo = container.resolve('agentScheduleRepo'); diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 7cb0a011..c328db35 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback } from "react" -import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X } from "lucide-react" +import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronDown, ChevronRight, Check, Link2, Unlink } from "lucide-react" import { Dialog, @@ -22,7 +22,7 @@ import { cn } from "@/lib/utils" import { useTheme } from "@/contexts/theme-context" import { toast } from "sonner" -type ConfigTab = "models" | "mcp" | "security" | "appearance" +type ConfigTab = "models" | "mcp" | "security" | "appearance" | "tools" interface TabConfig { id: ConfigTab @@ -60,6 +60,12 @@ const tabs: TabConfig[] = [ icon: Palette, description: "Customize the look and feel", }, + { + id: "tools", + label: "Tools Library", + icon: Wrench, + description: "Browse and enable Composio toolkits", + }, ] interface SettingsDialogProps { @@ -685,6 +691,540 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { ) } +// --- Tools Library Settings --- + +interface ToolkitInfo { + slug: string + name: string + meta: { description: string; logo: string; tools_count: number; triggers_count: number } + no_auth: boolean + auth_schemes: string[] + composio_managed_auth_schemes: string[] +} + +interface ToolInfo { + slug: string + name: string + description: string + toolkitSlug: string + inputParameters?: { type?: string; properties?: Record; required?: string[] } +} + +function ToolsLibrarySettings({ dialogOpen }: { dialogOpen: boolean }) { + // API key state + const [apiKeyConfigured, setApiKeyConfigured] = useState(false) + const [apiKeyInput, setApiKeyInput] = useState("") + const [apiKeySaving, setApiKeySaving] = useState(false) + const [showApiKeyInput, setShowApiKeyInput] = useState(false) + + // Toolkit browsing state + const [toolkits, setToolkits] = useState([]) + const [toolkitsLoading, setToolkitsLoading] = useState(false) + const [searchQuery, setSearchQuery] = useState("") + + // Connection state + const [connectedToolkits, setConnectedToolkits] = useState>(new Set()) + const [connectingToolkit, setConnectingToolkit] = useState(null) + + // Tool selection state + const [expandedToolkit, setExpandedToolkit] = useState(null) + const [toolkitTools, setToolkitTools] = useState>({}) + const [toolsLoading, setToolsLoading] = useState(null) + const [enabledToolSlugs, setEnabledToolSlugs] = useState>(new Set()) + + // Check API key configuration + const checkApiKey = useCallback(async () => { + try { + const result = await window.ipc.invoke("composio:is-configured", null) + setApiKeyConfigured(result.configured) + if (!result.configured) { + setShowApiKeyInput(true) + } + } catch { + setApiKeyConfigured(false) + } + }, []) + + // Load connected toolkits + const loadConnected = useCallback(async () => { + try { + const result = await window.ipc.invoke("composio:list-connected", null) + setConnectedToolkits(new Set(result.toolkits)) + } catch { + // ignore + } + }, []) + + // Load enabled tools + const loadEnabledTools = useCallback(async () => { + try { + const result = await window.ipc.invoke("composio:get-enabled-tools", null) + setEnabledToolSlugs(new Set(Object.keys(result.tools))) + } catch { + // ignore + } + }, []) + + // Load toolkits + const loadToolkits = useCallback(async () => { + setToolkitsLoading(true) + try { + const result = await window.ipc.invoke("composio:list-toolkits", {}) + setToolkits(result.items) + } catch { + toast.error("Failed to load toolkits") + } finally { + setToolkitsLoading(false) + } + }, []) + + // Initial load + useEffect(() => { + if (!dialogOpen) return + checkApiKey() + loadConnected() + loadEnabledTools() + }, [dialogOpen, checkApiKey, loadConnected, loadEnabledTools]) + + // Load toolkits when API key is configured + useEffect(() => { + if (dialogOpen && apiKeyConfigured) { + loadToolkits() + } + }, [dialogOpen, apiKeyConfigured, loadToolkits]) + + // Listen for composio connection events + useEffect(() => { + const cleanup = window.ipc.on('composio:didConnect', (event) => { + const { toolkitSlug, success, error } = event + setConnectingToolkit(null) + if (success) { + setConnectedToolkits(prev => new Set([...prev, toolkitSlug])) + toast.success(`Connected to ${toolkitSlug}`) + } else { + toast.error(error || `Failed to connect to ${toolkitSlug}`) + } + }) + return cleanup + }, []) + + // Save API key + const handleSaveApiKey = async () => { + const trimmed = apiKeyInput.trim() + if (!trimmed) return + setApiKeySaving(true) + try { + const result = await window.ipc.invoke("composio:set-api-key", { apiKey: trimmed }) + if (result.success) { + setApiKeyConfigured(true) + setShowApiKeyInput(false) + setApiKeyInput("") + toast.success("Composio API key saved") + } else { + toast.error(result.error || "Failed to save API key") + } + } catch { + toast.error("Failed to save API key") + } finally { + setApiKeySaving(false) + } + } + + // Connect a toolkit + const handleConnect = async (toolkitSlug: string) => { + setConnectingToolkit(toolkitSlug) + try { + const result = await window.ipc.invoke("composio:initiate-connection", { toolkitSlug }) + if (!result.success) { + toast.error(result.error || "Failed to connect") + setConnectingToolkit(null) + } + // Success will be handled by composio:didConnect event + } catch { + toast.error("Failed to connect") + setConnectingToolkit(null) + } + } + + // Disconnect a toolkit + const handleDisconnect = async (toolkitSlug: string) => { + try { + await window.ipc.invoke("composio:disconnect", { toolkitSlug }) + setConnectedToolkits(prev => { + const next = new Set(prev) + next.delete(toolkitSlug) + return next + }) + // Remove enabled tools for this toolkit from local state + setEnabledToolSlugs(prev => { + const toolsForToolkit = toolkitTools[toolkitSlug] || [] + const next = new Set(prev) + for (const t of toolsForToolkit) { + next.delete(t.slug) + } + return next + }) + if (expandedToolkit === toolkitSlug) { + setExpandedToolkit(null) + } + toast.success(`Disconnected from ${toolkitSlug}`) + } catch { + toast.error("Failed to disconnect") + } + } + + // Load tools for a toolkit + const loadToolsForToolkit = async (toolkitSlug: string) => { + if (toolkitTools[toolkitSlug]) return // Already loaded + setToolsLoading(toolkitSlug) + try { + const result = await window.ipc.invoke("composio:list-toolkit-tools", { toolkitSlug }) + setToolkitTools(prev => ({ ...prev, [toolkitSlug]: result.items })) + } catch { + toast.error("Failed to load tools") + } finally { + setToolsLoading(null) + } + } + + // Toggle toolkit expansion + const handleToggleToolkit = (toolkitSlug: string) => { + if (expandedToolkit === toolkitSlug) { + setExpandedToolkit(null) + } else { + setExpandedToolkit(toolkitSlug) + if (connectedToolkits.has(toolkitSlug)) { + loadToolsForToolkit(toolkitSlug) + } + } + } + + // Enable/disable a tool + const handleToggleTool = async (tool: ToolInfo, enable: boolean) => { + try { + if (enable) { + await window.ipc.invoke("composio:enable-tools", { tools: [tool] }) + setEnabledToolSlugs(prev => new Set([...prev, tool.slug])) + } else { + await window.ipc.invoke("composio:disable-tools", { toolSlugs: [tool.slug] }) + setEnabledToolSlugs(prev => { + const next = new Set(prev) + next.delete(tool.slug) + return next + }) + } + } catch { + toast.error("Failed to update tool") + } + } + + // Enable/disable all tools for a toolkit + const handleToggleAllTools = async (toolkitSlug: string, enable: boolean) => { + const tools = toolkitTools[toolkitSlug] || [] + if (tools.length === 0) return + + try { + if (enable) { + await window.ipc.invoke("composio:enable-tools", { tools }) + setEnabledToolSlugs(prev => { + const next = new Set(prev) + for (const t of tools) next.add(t.slug) + return next + }) + } else { + await window.ipc.invoke("composio:disable-tools", { toolSlugs: tools.map(t => t.slug) }) + setEnabledToolSlugs(prev => { + const next = new Set(prev) + for (const t of tools) next.delete(t.slug) + return next + }) + } + } catch { + toast.error("Failed to update tools") + } + } + + // Filter toolkits by search + const filteredToolkits = searchQuery.trim() + ? toolkits.filter(t => + t.name.toLowerCase().includes(searchQuery.toLowerCase()) || + t.slug.toLowerCase().includes(searchQuery.toLowerCase()) || + t.meta.description.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : toolkits + + return ( +
+ {/* Section A: API Key */} +
+ Composio API Key + {apiKeyConfigured && !showApiKeyInput ? ( +
+
+ + API key configured +
+ +
+ ) : ( +
+

+ Enter your Composio API key to browse and enable tool integrations. + Get your key from{" "} + + app.composio.dev/settings + +

+
+ setApiKeyInput(e.target.value)} + placeholder="Paste your Composio API key" + onKeyDown={(e) => e.key === "Enter" && handleSaveApiKey()} + className="flex-1" + /> + + {apiKeyConfigured && ( + + )} +
+
+ )} +
+ + {/* Section B: Toolkit Browser (only when API key configured) */} + {apiKeyConfigured && ( + <> +
+ Available Toolkits +
+ + setSearchQuery(e.target.value)} + placeholder="Search toolkits..." + className="pl-8" + /> +
+
+ + {toolkitsLoading ? ( +
+ + Loading toolkits... +
+ ) : ( +
+ {filteredToolkits.map((toolkit) => { + const isConnected = connectedToolkits.has(toolkit.slug) + const isConnecting = connectingToolkit === toolkit.slug + const isExpanded = expandedToolkit === toolkit.slug + const tools = toolkitTools[toolkit.slug] || [] + const isLoadingTools = toolsLoading === toolkit.slug + const enabledCount = tools.filter(t => enabledToolSlugs.has(t.slug)).length + const allEnabled = tools.length > 0 && enabledCount === tools.length + + return ( +
+ {/* Toolkit card header */} + + + {/* Expanded content */} + {isExpanded && ( +
+ {/* Connection controls */} +
+ {isConnected ? ( + + ) : ( + + )} + + {/* Enable/Disable all (only if connected and tools loaded) */} + {isConnected && tools.length > 0 && ( + + )} +
+ + {/* Tools list (only if connected) */} + {isConnected && ( +
+ {isLoadingTools ? ( +
+ + Loading tools... +
+ ) : tools.length === 0 ? ( +

No tools found

+ ) : ( +
+ {tools.map((tool) => { + const isEnabled = enabledToolSlugs.has(tool.slug) + return ( + + ) + })} +
+ )} +
+ )} + + {/* Not connected hint */} + {!isConnected && ( +

+ Connect this toolkit to browse and enable its tools. +

+ )} +
+ )} +
+ ) + })} + + {filteredToolkits.length === 0 && !toolkitsLoading && ( +
+ {searchQuery ? "No toolkits match your search" : "No toolkits available"} +
+ )} +
+ )} + + )} +
+ ) +} + // --- Main Settings Dialog --- export function SettingsDialog({ children }: SettingsDialogProps) { @@ -814,11 +1354,13 @@ export function SettingsDialog({ children }: SettingsDialogProps) { {/* Content */} -
+
{activeTab === "models" ? ( ) : activeTab === "appearance" ? ( + ) : activeTab === "tools" ? ( + ) : loading ? (
Loading... diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 0aeb167f..70a5793b 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -12,6 +12,7 @@ import { execTool } from "../application/lib/exec-tool.js"; import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js"; import { BuiltinTools } from "../application/lib/builtin-tools.js"; import { CopilotAgent } from "../application/assistant/agent.js"; +import { buildCopilotInstructions } from "../application/assistant/instructions.js"; import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js"; import container from "../di/container.js"; import { IModelConfigRepo } from "../models/repo.js"; @@ -312,7 +313,15 @@ function formatLlmStreamError(rawError: unknown): string { export async function loadAgent(id: string): Promise> { if (id === "copilot" || id === "rowboatx") { - return CopilotAgent; + // Rebuild tools from current BuiltinTools to pick up dynamically + // registered Composio tools (added via refreshComposioTools). + const tools: Record> = {}; + for (const name of Object.keys(BuiltinTools)) { + tools[name] = { type: "builtin", name }; + } + // Rebuild instructions to include current Composio tools section + const instructions = buildCopilotInstructions(); + return { ...CopilotAgent, tools, instructions }; } if (id === 'note_creation') { diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index 96b50bb3..74c3e15f 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -1,9 +1,56 @@ import { skillCatalog } from "./skills/index.js"; import { WorkDir as BASE_DIR } from "../../config/config.js"; import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js"; +import { composioEnabledToolsRepo } from "../../composio/enabled-tools-repo.js"; +import { composioAccountsRepo } from "../../composio/repo.js"; +import { isConfigured as isComposioConfigured } from "../../composio/client.js"; const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext()); +/** + * Generate dynamic instructions section for Composio tools. + * Returns empty string if no tools are enabled. + */ +function getComposioToolsPrompt(): string { + if (!isComposioConfigured()) return ''; + + const enabledTools = composioEnabledToolsRepo.getAll(); + const toolEntries = Object.values(enabledTools); + if (toolEntries.length === 0) return ''; + + // Group tools by toolkit + const byToolkit: Record> = {}; + for (const tool of toolEntries) { + if (!byToolkit[tool.toolkitSlug]) { + byToolkit[tool.toolkitSlug] = []; + } + byToolkit[tool.toolkitSlug].push(tool); + } + + // Check which toolkits are connected + const connectedToolkits = new Set(composioAccountsRepo.getConnectedToolkits()); + + let prompt = `\n## Composio Integration Tools\n\n`; + prompt += `You have access to external service integrations via Composio. These tools are prefixed with \`composio-\` and connect to third-party services on the user's behalf. The user has enabled these tools in Settings > Tools Library.\n\n`; + prompt += `**How to use Composio tools:**\n`; + prompt += `- Each tool is named \`composio-{ACTION_SLUG}\` (e.g., \`composio-GMAIL_SEND_EMAIL\`)\n`; + prompt += `- Call them like any other builtin tool — pass the required parameters and they execute via the connected account\n`; + prompt += `- If a tool returns an error about the toolkit not being connected, inform the user they need to connect it in Settings > Tools Library\n`; + prompt += `- Always confirm with the user before taking actions that send messages, create items, or modify data in external services\n\n`; + + for (const [toolkitSlug, tools] of Object.entries(byToolkit)) { + const isConnected = connectedToolkits.has(toolkitSlug); + const statusBadge = isConnected ? '(Connected)' : '(Not Connected)'; + prompt += `### ${toolkitSlug.charAt(0).toUpperCase() + toolkitSlug.slice(1)} ${statusBadge}\n`; + for (const tool of tools) { + prompt += `- \`composio-${tool.slug}\` — ${tool.description}\n`; + } + prompt += `\n`; + } + + return prompt; +} + export const CopilotInstructions = `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything. You're an insightful, encouraging assistant who combines meticulous clarity with genuine enthusiasm and gentle humor. @@ -184,6 +231,7 @@ ${runtimeContextPrompt} - \`loadSkill\` - Skill loading - \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them. - \`web-search\` and \`research-search\` - Web and research search tools (available when configured). **You MUST load the \`web-search\` skill before using either of these tools.** It tells you which tool to pick and how many searches to do. +- **Composio tools** (\`composio-*\`) — External service integrations enabled by the user in Settings > Tools Library. These connect to third-party apps like Gmail, GitHub, Linear, Notion, etc. See the "Composio Integration Tools" section below for available tools. **Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`. @@ -223,3 +271,13 @@ This renders as an interactive card in the UI that the user can click to open th **IMPORTANT:** Only use filepath blocks for files that already exist. The card is clickable and opens the file, so it must point to a real file. If you are proposing a path for a file that hasn't been created yet (e.g., "Shall I save it at ~/Documents/report.pdf?"), use inline code (\`~/Documents/report.pdf\`) instead of a filepath block. Use the filepath block only after the file has been written/created successfully. Never output raw file paths in plain text when they could be wrapped in a filepath block — unless the file does not exist yet.`; + +/** + * Build full copilot instructions with dynamic Composio tools section. + * Called each time the agent is loaded to reflect currently enabled tools. + */ +export function buildCopilotInstructions(): string { + const composioPrompt = getComposioToolsPrompt(); + if (!composioPrompt) return CopilotInstructions; + return CopilotInstructions + '\n' + composioPrompt; +} diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index feb41a7f..502a61cc 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -13,10 +13,11 @@ import * as workspace from "../../workspace/workspace.js"; import { IAgentsRepo } from "../../agents/repo.js"; import { WorkDir } from "../../config/config.js"; import { composioAccountsRepo } from "../../composio/repo.js"; +import { composioEnabledToolsRepo } from "../../composio/enabled-tools-repo.js"; import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, listToolkitTools } from "../../composio/client.js"; import { slackToolCatalog } from "../assistant/skills/slack/tool-catalog.js"; import type { ToolContext } from "./exec-tool.js"; -import { generateText } from "ai"; +import { generateText, jsonSchema } from "ai"; import { createProvider } from "../../models/models.js"; import { IModelConfigRepo } from "../../models/repo.js"; // Parser libraries are loaded dynamically inside parseFile.execute() @@ -1478,3 +1479,76 @@ export const BuiltinTools: z.infer = { }, }, }; + +// ============================================================================ +// Dynamic Composio Tool Registration +// ============================================================================ + +const COMPOSIO_TOOL_PREFIX = 'composio-'; + +/** + * Unregister all dynamically registered Composio tools + */ +function unregisterComposioTools(): void { + for (const key of Object.keys(BuiltinTools)) { + if (key.startsWith(COMPOSIO_TOOL_PREFIX)) { + delete BuiltinTools[key]; + } + } +} + +/** + * Register enabled Composio tools as builtin tools. + * Each enabled tool gets a generic execute function that routes + * to the Composio API via the connected account. + */ +function registerComposioTools(): void { + const enabledTools = composioEnabledToolsRepo.getAll(); + + for (const [slug, tool] of Object.entries(enabledTools)) { + const toolKey = `${COMPOSIO_TOOL_PREFIX}${slug}`; + const toolkitSlug = tool.toolkitSlug; + + const inputParams = tool.inputParameters ?? { type: 'object', properties: {} }; + + BuiltinTools[toolKey] = { + description: `[${tool.toolkitSlug}] ${tool.description}`, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputSchema: jsonSchema({ + type: 'object', + properties: (inputParams.properties ?? {}) as any, + ...(inputParams.required ? { required: inputParams.required } : {}), + } as any) as unknown as ZodType, + execute: async (input: Record) => { + const account = composioAccountsRepo.getAccount(toolkitSlug); + if (!account || account.status !== 'ACTIVE') { + return { + success: false, + error: `Toolkit "${toolkitSlug}" is not connected. Please connect it in Settings > Tools Library.`, + }; + } + return executeComposioAction(slug, account.id, input); + }, + isAvailable: async () => { + return isComposioConfigured() && composioAccountsRepo.isConnected(toolkitSlug); + }, + }; + } + + const count = Object.keys(enabledTools).length; + if (count > 0) { + console.log(`[Composio] Registered ${count} dynamic tool(s)`); + } +} + +/** + * Refresh dynamic Composio tools by unregistering all and re-registering from the repo. + * Called after enabling/disabling tools or disconnecting a toolkit. + */ +export function refreshComposioTools(): void { + unregisterComposioTools(); + registerComposioTools(); +} + +// Register on module load +refreshComposioTools(); diff --git a/apps/x/packages/core/src/composio/client.ts b/apps/x/packages/core/src/composio/client.ts index 070b4642..da0f55f1 100644 --- a/apps/x/packages/core/src/composio/client.ts +++ b/apps/x/packages/core/src/composio/client.ts @@ -330,6 +330,55 @@ export async function listToolkitTools( }; } +/** + * List available tools for a toolkit with full details including input_parameters + */ +export async function listToolkitToolsDetailed( + toolkitSlug: string, + searchQuery: string | null = null, +): Promise<{ items: Array<{ slug: string; name: string; description: string; toolkitSlug: string; inputParameters: { type: 'object'; properties: Record; required?: string[] } }> }> { + const apiKey = getApiKey(); + if (!apiKey) { + throw new Error('Composio API key not configured'); + } + + const url = new URL(`${BASE_URL}/tools`); + url.searchParams.set('toolkit_slug', toolkitSlug); + url.searchParams.set('limit', '200'); + if (searchQuery) { + url.searchParams.set('search', searchQuery); + } + + console.log(`[Composio] Listing tools (detailed) for toolkit: ${toolkitSlug}`); + + const response = await fetch(url.toString(), { + headers: { "x-api-key": apiKey }, + }); + + if (!response.ok) { + throw new Error(`Failed to list tools: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as { items?: Array> }; + + return { + items: (data.items || []).map((item) => { + const inputParams = item.input_parameters as Record | undefined; + return { + slug: String(item.slug ?? ''), + name: String(item.name ?? ''), + description: String(item.description ?? ''), + toolkitSlug, + inputParameters: { + type: 'object' as const, + properties: (inputParams?.properties as Record) ?? {}, + required: Array.isArray(inputParams?.required) ? inputParams.required as string[] : undefined, + }, + }; + }), + }; +} + /** * Execute a tool action using Composio SDK */ diff --git a/apps/x/packages/core/src/composio/enabled-tools-repo.ts b/apps/x/packages/core/src/composio/enabled-tools-repo.ts new file mode 100644 index 00000000..4f18b391 --- /dev/null +++ b/apps/x/packages/core/src/composio/enabled-tools-repo.ts @@ -0,0 +1,129 @@ +import fs from "fs"; +import path from "path"; +import { z } from "zod"; +import { WorkDir } from "../config/config.js"; + +const ENABLED_TOOLS_FILE = path.join(WorkDir, 'data', 'composio', 'enabled_tools.json'); + +/** + * Schema for an enabled Composio tool + */ +export const ZEnabledTool = z.object({ + slug: z.string(), + name: z.string(), + description: z.string(), + toolkitSlug: z.string(), + inputParameters: z.object({ + type: z.literal('object').optional().default('object'), + properties: z.record(z.string(), z.unknown()).optional().default({}), + required: z.array(z.string()).optional(), + }).optional().default({ type: 'object', properties: {} }), +}); + +export type EnabledTool = z.infer; + +/** + * Schema for the enabled tools storage file + */ +const ZEnabledToolsStorage = z.object({ + tools: z.record(z.string(), ZEnabledTool), // keyed by tool slug +}); + +type EnabledToolsStorage = z.infer; + +/** + * Interface for Composio enabled tools repository + */ +export interface IComposioEnabledToolsRepo { + getAll(): Record; + getByToolkit(toolkitSlug: string): EnabledTool[]; + enable(tool: EnabledTool): void; + enableBatch(tools: EnabledTool[]): void; + disable(toolSlug: string): void; + disableBatch(toolSlugs: string[]): void; + disableAllForToolkit(toolkitSlug: string): void; + isEnabled(toolSlug: string): boolean; +} + +function ensureStorageDir(): void { + const dir = path.dirname(ENABLED_TOOLS_FILE); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function loadStorage(): EnabledToolsStorage { + try { + if (fs.existsSync(ENABLED_TOOLS_FILE)) { + const data = fs.readFileSync(ENABLED_TOOLS_FILE, 'utf-8'); + return ZEnabledToolsStorage.parse(JSON.parse(data)); + } + } catch (error) { + console.error('[ComposioEnabledTools] Failed to load storage:', error); + } + return { tools: {} }; +} + +function saveStorage(storage: EnabledToolsStorage): void { + ensureStorageDir(); + fs.writeFileSync(ENABLED_TOOLS_FILE, JSON.stringify(storage, null, 2)); +} + +/** + * Repository for managing enabled Composio tools + */ +export class ComposioEnabledToolsRepo implements IComposioEnabledToolsRepo { + getAll(): Record { + return loadStorage().tools; + } + + getByToolkit(toolkitSlug: string): EnabledTool[] { + const storage = loadStorage(); + return Object.values(storage.tools).filter(t => t.toolkitSlug === toolkitSlug); + } + + enable(tool: EnabledTool): void { + const storage = loadStorage(); + storage.tools[tool.slug] = tool; + saveStorage(storage); + } + + enableBatch(tools: EnabledTool[]): void { + const storage = loadStorage(); + for (const tool of tools) { + storage.tools[tool.slug] = tool; + } + saveStorage(storage); + } + + disable(toolSlug: string): void { + const storage = loadStorage(); + delete storage.tools[toolSlug]; + saveStorage(storage); + } + + disableBatch(toolSlugs: string[]): void { + const storage = loadStorage(); + for (const slug of toolSlugs) { + delete storage.tools[slug]; + } + saveStorage(storage); + } + + disableAllForToolkit(toolkitSlug: string): void { + const storage = loadStorage(); + for (const [slug, tool] of Object.entries(storage.tools)) { + if (tool.toolkitSlug === toolkitSlug) { + delete storage.tools[slug]; + } + } + saveStorage(storage); + } + + isEnabled(toolSlug: string): boolean { + const storage = loadStorage(); + return toolSlug in storage.tools; + } +} + +export const composioEnabledToolsRepo = new ComposioEnabledToolsRepo(); diff --git a/apps/x/packages/core/src/composio/types.ts b/apps/x/packages/core/src/composio/types.ts index e2cbaf56..6a679757 100644 --- a/apps/x/packages/core/src/composio/types.ts +++ b/apps/x/packages/core/src/composio/types.ts @@ -46,8 +46,10 @@ export const ZToolkit = z.object({ name: z.string(), meta: ZToolkitMeta, no_auth: z.boolean(), - auth_schemes: z.array(ZAuthScheme), - composio_managed_auth_schemes: z.array(ZAuthScheme), + // Use z.string() instead of ZAuthScheme to be resilient against + // new auth types added by the Composio API over time. + auth_schemes: z.array(z.string()), + composio_managed_auth_schemes: z.array(z.string()), }); /** diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index b5803ffc..2a140a88 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -362,6 +362,81 @@ const ipcSchemas = { }), res: z.null(), }, + // Composio Tools Library channels + 'composio:list-toolkits': { + req: z.object({ + cursor: z.string().optional(), + }), + res: z.object({ + items: z.array(z.object({ + slug: z.string(), + name: z.string(), + meta: z.object({ + description: z.string(), + logo: z.string(), + tools_count: z.number(), + triggers_count: z.number(), + }), + no_auth: z.boolean(), + auth_schemes: z.array(z.string()), + composio_managed_auth_schemes: z.array(z.string()), + })), + nextCursor: z.string().nullable(), + totalItems: z.number(), + }), + }, + 'composio:list-toolkit-tools': { + req: z.object({ + toolkitSlug: z.string(), + search: z.string().optional(), + }), + res: z.object({ + items: z.array(z.object({ + slug: z.string(), + name: z.string(), + description: z.string(), + toolkitSlug: z.string(), + inputParameters: z.object({ + type: z.string().optional(), + properties: z.record(z.string(), z.unknown()).optional(), + required: z.array(z.string()).optional(), + }).optional(), + })), + }), + }, + 'composio:get-enabled-tools': { + req: z.null(), + res: z.object({ + tools: z.record(z.string(), z.object({ + slug: z.string(), + name: z.string(), + description: z.string(), + toolkitSlug: z.string(), + })), + }), + }, + 'composio:enable-tools': { + req: z.object({ + tools: z.array(z.object({ + slug: z.string(), + name: z.string(), + description: z.string(), + toolkitSlug: z.string(), + inputParameters: z.object({ + type: z.string().optional(), + properties: z.record(z.string(), z.unknown()).optional(), + required: z.array(z.string()).optional(), + }).optional(), + })), + }), + res: z.object({ success: z.boolean() }), + }, + 'composio:disable-tools': { + req: z.object({ + toolSlugs: z.array(z.string()), + }), + res: z.object({ success: z.boolean() }), + }, // Agent schedule channels 'agent-schedule:getConfig': { req: z.null(),