diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index 04b2ba41..59532373 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -2,18 +2,21 @@ 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 type { LocalConnectedAccount, ZExecuteActionResponse } from '@x/core/dist/composio/types.js'; -import { z } from 'zod'; +import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js'; +import { CURATED_TOOLKIT_SLUGS } from '@x/shared/dist/composio.js'; +import type { LocalConnectedAccount, Toolkit } from '@x/core/dist/composio/types.js'; import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js'; import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js'; const REDIRECT_URI = 'http://localhost:8081/oauth/callback'; -// Store active OAuth flows +// Store active OAuth flows (keyed by toolkitSlug to prevent concurrent flows for the same toolkit) const activeFlows = new Map(); /** @@ -125,13 +128,14 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ }; } - // Store flow state - const flowKey = `${toolkitSlug}-${Date.now()}`; - activeFlows.set(flowKey, { - toolkitSlug, - connectedAccountId, - authConfigId, - }); + // Abort any existing flow for this toolkit before starting a new one + const existingFlow = activeFlows.get(toolkitSlug); + if (existingFlow) { + console.log(`[Composio] Aborting existing flow for ${toolkitSlug}`); + clearTimeout(existingFlow.timeout); + existingFlow.server.close(); + activeFlows.delete(toolkitSlug); + } // Save initial account state const account: LocalConnectedAccount = { @@ -145,7 +149,7 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ composioAccountsRepo.saveAccount(account); // Set up callback server - let cleanupTimeout: NodeJS.Timeout; + const timeoutRef: { current: NodeJS.Timeout | null } = { current: null }; let callbackHandled = false; const { server } = await createAuthServer(8081, async () => { // Guard against duplicate callbacks (browser may send multiple requests) @@ -157,6 +161,8 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status); if (accountStatus.status === 'ACTIVE') { + // Invalidate instructions cache so the copilot knows about the new connection + invalidateCopilotInstructionsCache(); emitComposioEvent({ toolkitSlug, success: true }); if (toolkitSlug === 'gmail') { triggerGmailSync(); @@ -179,17 +185,17 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ error: error instanceof Error ? error.message : 'Unknown error', }); } finally { - activeFlows.delete(flowKey); + activeFlows.delete(toolkitSlug); server.close(); - clearTimeout(cleanupTimeout); + if (timeoutRef.current) clearTimeout(timeoutRef.current); } }); // Timeout for abandoned flows (5 minutes) - cleanupTimeout = setTimeout(() => { - if (activeFlows.has(flowKey)) { + const cleanupTimeout = setTimeout(() => { + if (activeFlows.has(toolkitSlug)) { console.log(`[Composio] Cleaning up abandoned flow for ${toolkitSlug}`); - activeFlows.delete(flowKey); + activeFlows.delete(toolkitSlug); server.close(); emitComposioEvent({ toolkitSlug, @@ -198,6 +204,16 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ }); } }, 5 * 60 * 1000); + timeoutRef.current = cleanupTimeout; + + // Store flow state (keyed by toolkit to prevent concurrent flows) + activeFlows.set(toolkitSlug, { + toolkitSlug, + connectedAccountId, + authConfigId, + server, + timeout: cleanupTimeout, + }); // Open browser for OAuth shell.openExternal(redirectUrl); @@ -257,18 +273,16 @@ export async function disconnect(toolkitSlug: string): Promise<{ success: boolea try { const account = composioAccountsRepo.getAccount(toolkitSlug); if (account) { - // Delete from Composio await composioClient.deleteConnectedAccount(account.id); - // Delete local record - composioAccountsRepo.deleteAccount(toolkitSlug); } - return { success: true }; } catch (error) { console.error('[Composio] Disconnect failed:', error); - // Still delete local record even if API call fails + } finally { + // Always clean up local state, even if the API call fails composioAccountsRepo.deleteAccount(toolkitSlug); - return { success: true }; + invalidateCopilotInstructionsCache(); } + return { success: true }; } /** @@ -293,36 +307,24 @@ export async function useComposioForGoogleCalendar(): Promise<{ enabled: boolean } /** - * Execute a Composio action + * List available Composio toolkits — filtered to curated list only. + * Return type matches the ZToolkit schema from core/composio/types.ts. */ -export async function executeAction( - actionSlug: string, - toolkitSlug: string, - input: Record -): Promise> { - try { - const account = composioAccountsRepo.getAccount(toolkitSlug); - if (!account || account.status !== 'ACTIVE') { - return { - data: null, - successful: false, - error: `Toolkit ${toolkitSlug} is not connected`, - }; - } - - const result = await composioClient.executeAction(actionSlug, { - connected_account_id: account.id, - user_id: 'rowboat-user', - version: 'latest', - arguments: input, - }); - return result; - } catch (error) { - console.error('[Composio] Action execution failed:', error); - return { - successful: false, - data: null, - error: error instanceof Error ? error.message : 'Unknown error', - }; +export async function listToolkits() { + // Paginate through all API pages to collect every curated toolkit + const allItems: Toolkit[] = []; + let cursor: string | null = null; + const maxPages = 10; // safety limit + for (let page = 0; page < maxPages; page++) { + const result = await composioClient.listToolkits(cursor); + allItems.push(...result.items); + cursor = result.next_cursor; + if (!cursor) break; } + const filtered = allItems.filter(item => CURATED_TOOLKIT_SLUGS.has(item.slug)); + return { + items: filtered, + nextCursor: null as string | null, + totalItems: filtered.length, + }; } diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 0fa0de79..e05b57b3 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -559,8 +559,9 @@ export function setupIpcHandlers() { 'composio:list-connected': async () => { return composioHandler.listConnected(); }, - 'composio:execute-action': async (_event, args) => { - return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input); + // Composio Tools Library handlers + 'composio:list-toolkits': async () => { + return composioHandler.listToolkits(); }, 'composio:use-composio-for-google': async () => { return composioHandler.useComposioForGoogle(); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 8292a196..7ef34cd1 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -34,9 +34,10 @@ import { import { Shimmer } from '@/components/ai-elements/shimmer'; import { useSmoothedText } from './hooks/useSmoothedText'; -import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'; +import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'; import { WebSearchResult } from '@/components/ai-elements/web-search-result'; import { AppActionCard } from '@/components/ai-elements/app-action-card'; +import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'; import { PermissionRequest } from '@/components/ai-elements/permission-request'; import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; import { Suggestions } from '@/components/ai-elements/suggestions'; @@ -67,6 +68,8 @@ import { createEmptyChatTabViewState, getWebSearchCardData, getAppActionCardData, + getComposioConnectCardData, + getToolDisplayName, inferRunTitleFromMessage, isChatMessage, isErrorMessage, @@ -76,6 +79,7 @@ import { parseAttachedFiles, toToolState, } from '@/lib/chat-conversation' +import { COMPOSIO_DISPLAY_NAMES as composioDisplayNames } from '@x/shared/src/composio.js' import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js' import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js' import { toast } from "sonner" @@ -2242,6 +2246,12 @@ function App() { } handlePromptSubmitRef.current = handlePromptSubmit + const handleComposioConnected = useCallback((toolkitSlug: string) => { + // Auto-send a continuation message when a Composio toolkit connects + const name = composioDisplayNames[toolkitSlug] || toolkitSlug + handlePromptSubmitRef.current?.({ text: `${name} connected successfully.`, files: [] }) + }, []) + const handleStop = useCallback(async () => { if (!runId) return const now = Date.now() @@ -3826,6 +3836,22 @@ function App() { /> ) } + const composioConnectData = getComposioConnectCardData(item) + if (composioConnectData) { + // Skip rendering if this is a duplicate "already connected" card + if (composioConnectData.hidden) return null + return ( + + ) + } + const toolTitle = getToolDisplayName(item) const errorText = item.status === 'error' ? 'Tool error' : '' const output = normalizeToolOutput(item.result, item.status) const input = normalizeToolInput(item.input) @@ -3836,15 +3862,12 @@ function App() { onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)} > - - {output !== null ? ( - - ) : null} + ) @@ -4470,6 +4493,7 @@ function App() { ttsMode={ttsMode} onToggleTts={handleToggleTts} onTtsModeChange={handleTtsModeChange} + onComposioConnected={handleComposioConnected} /> )} {/* Rendered last so its no-drag region paints over the sidebar drag region */} diff --git a/apps/x/apps/renderer/src/components/ai-elements/composio-connect-card.tsx b/apps/x/apps/renderer/src/components/ai-elements/composio-connect-card.tsx new file mode 100644 index 00000000..731eeb09 --- /dev/null +++ b/apps/x/apps/renderer/src/components/ai-elements/composio-connect-card.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + CheckCircleIcon, + Link2Icon, + LoaderIcon, + XCircleIcon, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface ComposioConnectCardProps { + toolkitSlug: string; + toolkitDisplayName: string; + status: "pending" | "running" | "completed" | "error"; + alreadyConnected?: boolean; + onConnected?: (toolkitSlug: string) => void; +} + +export function ComposioConnectCard({ + toolkitSlug, + toolkitDisplayName, + status, + alreadyConnected, + onConnected, +}: ComposioConnectCardProps) { + const [connectionState, setConnectionState] = useState< + "idle" | "connecting" | "connected" | "error" + >(alreadyConnected ? "connected" : "idle"); + const [errorMessage, setErrorMessage] = useState(null); + const didFireCallback = useRef(alreadyConnected ?? false); + + // Listen for composio:didConnect events + useEffect(() => { + const cleanup = window.ipc.on( + "composio:didConnect", + (event: { toolkitSlug: string; success: boolean; error?: string }) => { + if (event.toolkitSlug !== toolkitSlug) return; + if (event.success) { + setConnectionState("connected"); + setErrorMessage(null); + if (!didFireCallback.current) { + didFireCallback.current = true; + onConnected?.(toolkitSlug); + } + } else { + setConnectionState("error"); + setErrorMessage(event.error || "Connection failed"); + } + } + ); + return cleanup; + }, [toolkitSlug, onConnected]); + + const handleConnect = useCallback(async () => { + setConnectionState("connecting"); + setErrorMessage(null); + try { + const result = await window.ipc.invoke("composio:initiate-connection", { + toolkitSlug, + }); + if (!result.success) { + setConnectionState("error"); + setErrorMessage(result.error || "Failed to initiate connection"); + } + } catch { + setConnectionState("error"); + setErrorMessage("Failed to initiate connection"); + } + }, [toolkitSlug]); + + const isToolRunning = status === "pending" || status === "running"; + const displayName = toolkitDisplayName || toolkitSlug; + + return ( +
+ {/* Toolkit initial */} +
+ + {displayName.charAt(0).toUpperCase()} + +
+ + {/* Name & status */} +
+
+ {displayName} + {connectionState === "connected" && ( + + Connected + + )} +
+ {connectionState === "error" && errorMessage && ( +

{errorMessage}

+ )} + {connectionState === "idle" && isToolRunning && ( +

Waiting to connect...

+ )} +
+ + {/* Action area */} + {connectionState === "connected" ? ( + + ) : connectionState === "connecting" ? ( + + ) : connectionState === "error" ? ( +
+ + +
+ ) : isToolRunning ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx index d9453aa1..18af8b0e 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx @@ -16,8 +16,8 @@ import { WrenchIcon, XCircleIcon, } from "lucide-react"; -import type { ComponentProps, ReactNode } from "react"; -import { isValidElement } from "react"; +import { type ComponentProps, type ReactNode, isValidElement, useState } from "react"; + const formatToolValue = (value: unknown) => { if (typeof value === "string") return value; try { @@ -37,7 +37,7 @@ const ToolCode = ({ }) => (
@@ -129,64 +129,90 @@ export const ToolContent = ({ className, ...props }: ToolContentProps) => (
   />
 );
 
-export type ToolInputProps = ComponentProps<"div"> & {
+/* ── Tabbed content (Parameters / Result) ────────────────────────── */
+
+export type ToolTabbedContentProps = {
   input: ToolUIPart["input"];
-};
-
-export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
-  
-

- Parameters -

-
- -
-
-); - -export type ToolOutputProps = ComponentProps<"div"> & { output: ToolUIPart["output"]; - errorText: ToolUIPart["errorText"]; + errorText?: ToolUIPart["errorText"]; }; -export const ToolOutput = ({ - className, +export const ToolTabbedContent = ({ + input, output, errorText, - ...props -}: ToolOutputProps) => { - if (!(output || errorText)) { - return null; - } +}: ToolTabbedContentProps) => { + const [activeTab, setActiveTab] = useState<"parameters" | "result">("parameters"); + const hasOutput = output != null || !!errorText; - let Output =
{output as ReactNode}
; - - if (typeof output === "object" && !isValidElement(output)) { - Output = ; - } else if (typeof output === "string") { - Output = ; + let OutputNode: ReactNode = null; + if (errorText) { + OutputNode = ; + } else if (output != null) { + if (typeof output === "object" && !isValidElement(output)) { + OutputNode = ; + } else if (typeof output === "string") { + OutputNode = ; + } else { + OutputNode =
{output as ReactNode}
; + } } return ( -
-

- {errorText ? "Error" : "Result"} -

-
- {errorText && ( -
- {errorText} +
+ {/* Tabs */} +
+ + +
+ + {/* Tab content */} +
+ {activeTab === "parameters" && ( +
+ +
+ )} + {activeTab === "result" && ( +
+ {hasOutput ? ( +
+ {OutputNode} +
+ ) : ( + (pending...) + )}
)} - {Output}
); }; + diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index ac7f23be..160d8fb1 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -16,8 +16,9 @@ import { MessageResponse, } from '@/components/ai-elements/message' import { Shimmer } from '@/components/ai-elements/shimmer' -import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool' +import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool' import { WebSearchResult } from '@/components/ai-elements/web-search-result' +import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card' import { PermissionRequest } from '@/components/ai-elements/permission-request' import { AskHumanRequest } from '@/components/ai-elements/ask-human-request' import { Suggestions } from '@/components/ai-elements/suggestions' @@ -34,6 +35,8 @@ import { type PermissionResponse, createEmptyChatTabViewState, getWebSearchCardData, + getComposioConnectCardData, + getToolDisplayName, isChatMessage, isErrorMessage, isToolCall, @@ -121,6 +124,7 @@ interface ChatSidebarProps { ttsMode?: 'summary' | 'full' onToggleTts?: () => void onTtsModeChange?: (mode: 'summary' | 'full') => void + onComposioConnected?: (toolkitSlug: string) => void } export function ChatSidebar({ @@ -171,6 +175,7 @@ export function ChatSidebar({ ttsMode, onToggleTts, onTtsModeChange, + onComposioConnected, }: ChatSidebarProps) { const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth)) const [isResizing, setIsResizing] = useState(false) @@ -337,6 +342,21 @@ export function ChatSidebar({ /> ) } + const composioConnectData = getComposioConnectCardData(item) + if (composioConnectData) { + if (composioConnectData.hidden) return null + return ( + + ) + } + const toolTitle = getToolDisplayName(item) const errorText = item.status === 'error' ? 'Tool error' : '' const output = normalizeToolOutput(item.result, item.status) const input = normalizeToolInput(item.input) @@ -346,10 +366,9 @@ export function ChatSidebar({ open={isToolOpenForTab?.(tabId, item.id) ?? false} onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)} > - + - - {output !== null ? : null} + ) diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 53a69335..66765d14 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, useMemo } from "react" -import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Tags, Mail, BookOpen, ChevronRight, Plus, X, User, Plug } from "lucide-react" +import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug } from "lucide-react" import { Dialog, @@ -25,7 +25,7 @@ import { toast } from "sonner" import { AccountSettings } from "@/components/settings/account-settings" import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings" -type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "note-tagging" +type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "tools" | "note-tagging" interface TabConfig { id: ConfigTab @@ -75,6 +75,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", + }, { id: "note-tagging", label: "Note Tagging", @@ -707,6 +713,323 @@ 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[] +} + +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) + + // 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 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() + }, [dialogOpen, checkApiKey, loadConnected]) + + // 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 + }) + toast.success(`Disconnected from ${toolkitSlug}`) + } catch { + toast.error("Failed to disconnect") + } + } + + // 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 + + return ( +
+
+ {/* Logo */} + {toolkit.meta.logo ? ( + { (e.target as HTMLImageElement).style.display = 'none' }} + /> + ) : ( +
+ +
+ )} + + {/* Name & description */} +
+
+ {toolkit.name} + {isConnected && ( + + Connected + + )} +
+

+ {toolkit.meta.description} +

+
+ + {/* Connect / Disconnect button */} + {isConnected ? ( + + ) : ( + + )} +
+
+ ) + })} + + {filteredToolkits.length === 0 && !toolkitsLoading && ( +
+ {searchQuery ? "No toolkits match your search" : "No toolkits available"} +
+ )} +
+ )} + + )} +
+ ) +} + // --- Rowboat Model Settings (when signed in via Rowboat) --- function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) { @@ -1312,7 +1635,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
{/* Content */} -
+
{activeTab === "account" ? ( ) : activeTab === "connected-accounts" ? ( @@ -1325,6 +1648,8 @@ export function SettingsDialog({ children }: SettingsDialogProps) { ) : activeTab === "appearance" ? ( + ) : activeTab === "tools" ? ( + ) : loading ? (
Loading... diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index d92c124d..af01cfdd 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -1,6 +1,7 @@ import type { ToolUIPart } from 'ai' import z from 'zod' import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js' +import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js' export interface MessageAttachment { path: string @@ -253,6 +254,142 @@ export const parseAttachedFiles = (content: string): { message: string; files: s return { message: cleanMessage.trim(), files } } +// Composio connect card data +export type ComposioConnectCardData = { + toolkitSlug: string + toolkitDisplayName: string + alreadyConnected: boolean + /** When true, the connect card should not be rendered (toolkit was already connected). */ + hidden: boolean +} + + +export const getComposioConnectCardData = (tool: ToolCall): ComposioConnectCardData | null => { + if (tool.name !== 'composio-connect-toolkit') return null + + const input = normalizeToolInput(tool.input) as Record | undefined + const result = tool.result as Record | undefined + + const toolkitSlug = (input?.toolkitSlug as string) || '' + const alreadyConnected = result?.alreadyConnected === true + + return { + toolkitSlug, + toolkitDisplayName: COMPOSIO_DISPLAY_NAMES[toolkitSlug] || toolkitSlug, + alreadyConnected, + // Don't render a connect card if the toolkit was already connected — + // the original card from the first connect call already shows the "Connected" state. + hidden: alreadyConnected, + } +} + +// Human-friendly display names for builtin tools +const TOOL_DISPLAY_NAMES: Record = { + 'workspace-readFile': 'Reading file', + 'workspace-writeFile': 'Writing file', + 'workspace-edit': 'Editing file', + 'workspace-readdir': 'Reading directory', + 'workspace-exists': 'Checking path', + 'workspace-stat': 'Getting file info', + 'workspace-glob': 'Finding files', + 'workspace-grep': 'Searching files', + 'workspace-mkdir': 'Creating directory', + 'workspace-rename': 'Renaming', + 'workspace-copy': 'Copying file', + 'workspace-remove': 'Removing', + 'workspace-getRoot': 'Getting workspace root', + 'loadSkill': 'Loading skill', + 'parseFile': 'Parsing file', + 'LLMParse': 'Extracting content', + 'analyzeAgent': 'Analyzing agent', + 'executeCommand': 'Running command', + 'addMcpServer': 'Adding MCP server', + 'listMcpServers': 'Listing MCP servers', + 'listMcpTools': 'Listing MCP tools', + 'executeMcpTool': 'Running MCP tool', + 'web-search': 'Searching the web', + 'save-to-memory': 'Saving to memory', + 'app-navigation': 'Navigating app', + 'composio-list-toolkits': 'Listing integrations', + 'composio-search-tools': 'Searching tools', + 'composio-execute-tool': 'Running tool', + 'composio-connect-toolkit': 'Connecting service', +} + +/** + * Get a human-friendly display name for a tool call. + * For Composio tools, returns a contextual label (e.g., "Found 3 tools for 'send email' in Gmail"). + * For builtin tools, returns a static friendly name (e.g., "Reading file"). + * Falls back to the raw tool name if no mapping exists. + */ +export const getToolDisplayName = (tool: ToolCall): string => { + const composioData = getComposioActionCardData(tool) + if (composioData) return composioData.label + return TOOL_DISPLAY_NAMES[tool.name] || tool.name +} + +// Composio action card data (for search, execute, list tools) +export type ComposioActionCardData = { + actionType: 'search' | 'execute' | 'list' + label: string +} + +export const getComposioActionCardData = (tool: ToolCall): ComposioActionCardData | null => { + const input = normalizeToolInput(tool.input) as Record | undefined + const result = tool.result as Record | undefined + + if (tool.name === 'composio-search-tools') { + const query = (input?.query as string) || 'tools' + const toolkitSlug = input?.toolkitSlug as string | undefined + const toolkit = toolkitSlug ? COMPOSIO_DISPLAY_NAMES[toolkitSlug] || toolkitSlug : null + const count = (result?.resultCount as number) ?? null + + let label = `Searching for "${query}"` + if (toolkit) label += ` in ${toolkit}` + if (count !== null && tool.status === 'completed') { + label = count > 0 ? `Found ${count} tool${count !== 1 ? 's' : ''} for "${query}"` : `No tools found for "${query}"` + if (toolkit) label += ` in ${toolkit}` + } + return { actionType: 'search', label } + } + + if (tool.name === 'composio-execute-tool') { + const toolSlug = (input?.toolSlug as string) || '' + const toolkitSlug = (input?.toolkitSlug as string) || '' + const toolkit = COMPOSIO_DISPLAY_NAMES[toolkitSlug] || toolkitSlug + const successful = result?.successful as boolean | undefined + + // Make the tool slug human-readable: GITHUB_ISSUES_LIST_FOR_REPO → "Issues list for repo" + const readableName = toolSlug + .replace(/^[A-Z]+_/, '') // Remove toolkit prefix + .toLowerCase() + .replace(/_/g, ' ') + .replace(/^\w/, c => c.toUpperCase()) + + let label = `Running ${readableName}` + if (toolkit) label += ` on ${toolkit}` + if (tool.status === 'completed') { + label = successful === false ? `Failed: ${readableName}` : `${readableName}` + if (toolkit) label += ` on ${toolkit}` + } + return { actionType: 'execute', label } + } + + if (tool.name === 'composio-list-toolkits') { + const count = (result?.totalCount as number) ?? null + const connected = (result?.connectedCount as number) ?? null + + let label = 'Listing available integrations' + if (count !== null && tool.status === 'completed') { + label = `${count} integrations available` + if (connected !== null && connected > 0) label += `, ${connected} connected` + } + return { actionType: 'list', label } + } + + return null +} + export const inferRunTitleFromMessage = (content: string): string | undefined => { const { message } = parseAttachedFiles(content) const normalized = message.replace(/\s+/g, ' ').trim() diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index b597a8d6..34e2b401 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -10,7 +10,7 @@ import { LlmStepStreamEvent } from "@x/shared/dist/llm-step-events.js"; 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 { buildCopilotAgent } from "../application/assistant/agent.js"; import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js"; import container from "../di/container.js"; import { IModelConfigRepo } from "../models/repo.js"; @@ -369,7 +369,7 @@ function formatLlmStreamError(rawError: unknown): string { export async function loadAgent(id: string): Promise> { if (id === "copilot" || id === "rowboatx") { - return CopilotAgent; + return buildCopilotAgent(); } if (id === 'note_creation') { diff --git a/apps/x/packages/core/src/application/assistant/agent.ts b/apps/x/packages/core/src/application/assistant/agent.ts index 4904642e..0096601c 100644 --- a/apps/x/packages/core/src/application/assistant/agent.ts +++ b/apps/x/packages/core/src/application/assistant/agent.ts @@ -1,19 +1,23 @@ import { Agent, ToolAttachment } from "@x/shared/dist/agent.js"; import z from "zod"; -import { CopilotInstructions } from "./instructions.js"; +import { buildCopilotInstructions } from "./instructions.js"; import { BuiltinTools } from "../lib/builtin-tools.js"; -const tools: Record> = {}; -for (const name of Object.keys(BuiltinTools)) { - tools[name] = { - type: "builtin", - name, +/** + * Build the CopilotAgent dynamically. + * Tools are derived from the current BuiltinTools (which include Composio meta-tools), + * and instructions include the live Composio connection status. + */ +export async function buildCopilotAgent(): Promise> { + const tools: Record> = {}; + for (const name of Object.keys(BuiltinTools)) { + tools[name] = { type: "builtin", name }; + } + const instructions = await buildCopilotInstructions(); + return { + name: "rowboatx", + description: "Rowboatx copilot", + instructions, + tools, }; } - -export const CopilotAgent: z.infer = { - name: "rowboatx", - description: "Rowboatx copilot", - instructions: CopilotInstructions, - tools, -} \ No newline at end of file diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index a29b225f..f647d821 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -1,9 +1,32 @@ -import { skillCatalog } from "./skills/index.js"; -import { WorkDir as BASE_DIR } from "../../config/config.js"; +import { skillCatalog } from "./skills/index.js"; // eslint-disable-line @typescript-eslint/no-unused-vars -- used in template literal import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js"; +import { composioAccountsRepo } from "../../composio/repo.js"; +import { isConfigured as isComposioConfigured } from "../../composio/client.js"; +import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js"; const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext()); +/** + * Generate dynamic instructions section for Composio integrations. + * Lists connected toolkits and explains the meta-tool discovery flow. + */ +async function getComposioToolsPrompt(): Promise { + if (!(await isComposioConfigured())) return ''; + + const connectedToolkits = composioAccountsRepo.getConnectedToolkits(); + const connectedSection = connectedToolkits.length > 0 + ? `**Currently connected:** ${connectedToolkits.map(slug => CURATED_TOOLKITS.find(t => t.slug === slug)?.displayName ?? slug).join(', ')}` + : `**No services connected yet.** Load the \`composio-integration\` skill to help the user connect one.`; + + return ` +## Composio Integrations + +${connectedSection} + +Load the \`composio-integration\` skill when the user asks to interact with any third-party service. NEVER say "I can't access [service]" without loading the skill and trying Composio first. +`; +} + 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. @@ -25,7 +48,9 @@ You're an insightful, encouraging assistant who combines meticulous clarity with ## What Rowboat Is Rowboat is an agentic assistant for everyday work - emails, meetings, projects, and people. Users give you tasks like "draft a follow-up email," "prep me for this meeting," or "summarize where we are with this project." You figure out what context you need, pull from emails and meetings, and get it done. -**Email Drafting:** When users ask you to draft emails or respond to emails, load the \`draft-emails\` skill first. It provides structured guidance for processing emails, gathering context from calendar and knowledge base, and creating well-informed draft responses. +**Email Drafting:** When users ask you to **draft** or **compose** emails (e.g., "draft a follow-up to Monica", "write an email to John about the project"), load the \`draft-emails\` skill first. Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead. + +**Third-Party Services:** When users ask to interact with any external service (Gmail, GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.) — reading emails, listing issues, sending messages, fetching profiles — load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data. **Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs. @@ -166,13 +191,9 @@ Always consult this catalog first so you load the right skills before taking act - Never start a response with a heading. Lead with a sentence or two of context first. - Avoid deeply nested bullets. If nesting beyond 2 levels, restructure. -## MCP Tool Discovery (CRITICAL) +## Tool Priority -**ALWAYS check for MCP tools BEFORE saying you can't do something.** - -When a user asks for ANY task that might require external capabilities (web search, internet access, APIs, data fetching, etc.), check MCP tools first using \`listMcpServers\` and \`listMcpTools\`. Load the "mcp-integration" skill for detailed guidance on discovering and executing MCP tools. - -**DO NOT** immediately respond with "I can't access the internet" or "I don't have that capability" without checking MCP tools first! +For third-party services (GitHub, Gmail, Slack, etc.), load the \`composio-integration\` skill. For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill. ## Execution Reminders - Explore existing files and structure before creating new assets. @@ -212,6 +233,7 @@ ${runtimeContextPrompt} - \`web-search\` - Search the web. Returns rich results with full text, highlights, and metadata. The \`category\` parameter defaults to \`general\` (full web search) — only use a specific category like \`news\`, \`company\`, \`research paper\` etc. when the query is clearly about that type. For everyday queries (weather, restaurants, prices, how-to), use \`general\`. - \`app-navigation\` - Control the app UI: open notes, switch views, filter/search the knowledge base, manage saved views. **Load the \`app-navigation\` skill before using this tool.** - \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations. +- \`composio-list-toolkits\`, \`composio-search-tools\`, \`composio-execute-tool\`, \`composio-connect-toolkit\` — Composio integration tools. Load the \`composio-integration\` skill for usage guidance. **Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`. @@ -251,3 +273,29 @@ 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.`; + +/** + * Cached Composio instructions. Invalidated by calling invalidateCopilotInstructionsCache(). + */ +let cachedInstructions: string | null = null; + +/** + * Invalidate the cached instructions so the next buildCopilotInstructions() call + * regenerates the Composio section. Call this after connecting/disconnecting a toolkit. + */ +export function invalidateCopilotInstructionsCache(): void { + cachedInstructions = null; +} + +/** + * Build full copilot instructions with dynamic Composio tools section. + * Results are cached and reused until invalidated via invalidateCopilotInstructionsCache(). + */ +export async function buildCopilotInstructions(): Promise { + if (cachedInstructions !== null) return cachedInstructions; + const composioPrompt = await getComposioToolsPrompt(); + cachedInstructions = composioPrompt + ? CopilotInstructions + '\n' + composioPrompt + : CopilotInstructions; + return cachedInstructions; +} diff --git a/apps/x/packages/core/src/application/assistant/skills/composio-integration/skill.ts b/apps/x/packages/core/src/application/assistant/skills/composio-integration/skill.ts new file mode 100644 index 00000000..795daeeb --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/composio-integration/skill.ts @@ -0,0 +1,127 @@ +export const skill = String.raw` +# Composio Integration + +**Load this skill** when the user asks to interact with ANY third-party service — email, GitHub, Slack, LinkedIn, Notion, Jira, Google Sheets, calendar, etc. This skill provides the complete workflow for discovering, connecting, and executing Composio tools. + +## Available Tools + +| Tool | Purpose | +|------|---------| +| **composio-list-toolkits** | List all available integrations and their connection status | +| **composio-search-tools** | Search for tools by use case; returns slugs and input schemas | +| **composio-execute-tool** | Execute a tool by slug with parameters | +| **composio-connect-toolkit** | Connect a service via OAuth (opens browser) | + +## Toolkit Slugs (exact values for toolkitSlug parameter) + +| Service | Slug | +|---------|------| +| Gmail | \`gmail\` | +| Google Calendar | \`googlecalendar\` | +| Google Sheets | \`googlesheets\` | +| Google Docs | \`googledocs\` | +| Google Drive | \`googledrive\` | +| Slack | \`slack\` | +| GitHub | \`github\` | +| Notion | \`notion\` | +| Linear | \`linear\` | +| Jira | \`jira\` | +| Asana | \`asana\` | +| Trello | \`trello\` | +| HubSpot | \`hubspot\` | +| Salesforce | \`salesforce\` | +| LinkedIn | \`linkedin\` | +| X (Twitter) | \`twitter\` | +| Reddit | \`reddit\` | +| Dropbox | \`dropbox\` | +| OneDrive | \`onedrive\` | +| Microsoft Outlook | \`microsoft_outlook\` | +| Microsoft Teams | \`microsoft_teams\` | +| Calendly | \`calendly\` | +| Cal.com | \`cal\` | +| Intercom | \`intercom\` | +| Zendesk | \`zendesk\` | +| Airtable | \`airtable\` | + +**IMPORTANT:** Always use these exact slugs. Do NOT guess — e.g., Google Sheets is \`googlesheets\` (no underscore), not \`google_sheets\`. + +## Critical: Check First, Connect Second + +**BEFORE calling composio-connect-toolkit, ALWAYS check if the service is already connected.** The system prompt includes a "Currently connected" list. If the service is there, skip connecting and go straight to search + execute. + +**Flow:** +1. Check if the service is in the "Currently connected" list (in the system prompt above) +2. If **connected** → go directly to step 4 +3. If **NOT connected** → call \`composio-connect-toolkit\` once, wait for user to authenticate, then continue +4. Call \`composio-search-tools\` with SHORT keyword queries +5. Read the \`inputSchema\` from results — note \`required\` fields +6. Call \`composio-execute-tool\` with slug, toolkit, and all required arguments + +**NEVER call composio-connect-toolkit for a service that's already connected.** This creates duplicate connect cards in the UI. + +## Search Query Tips + +Use **short keyword queries**, not full sentences: + +| ✅ Good | ❌ Bad | +|---------|--------| +| "list issues" | "get all open issues for a GitHub repository" | +| "send email" | "send an email to someone using Gmail" | +| "get profile" | "fetch the authenticated user's profile details" | +| "create spreadsheet" | "create a new Google Sheets spreadsheet with data" | + +If the first search returns 0 results, try a different short query (e.g., "issues" instead of "list issues"). + +## Passing Arguments + +**ALWAYS include the \`arguments\` field** when calling \`composio-execute-tool\`, even if the tool has no required parameters. + +- Read the \`inputSchema\` from search results carefully +- Extract user-provided values into the correct fields (e.g., "rowboatlabs/rowboat" → \`owner: "rowboatlabs", repo: "rowboat"\`) +- For tools with empty \`properties: {}\`, pass \`arguments: {}\` +- For tools with required fields, pass all of them + +### Example: GitHub Issues + +User says: "Get me the open issues on rowboatlabs/rowboat" + +1. \`composio-search-tools({ query: "list issues", toolkitSlug: "github" })\` + → finds \`GITHUB_ISSUES_LIST_FOR_REPO\` with required: ["owner", "repo"] +2. \`composio-execute-tool({ toolSlug: "GITHUB_ISSUES_LIST_FOR_REPO", toolkitSlug: "github", arguments: { owner: "rowboatlabs", repo: "rowboat", state: "open", per_page: 100 } })\` + +### Example: Gmail Fetch + +User says: "What's my latest email?" + +1. \`composio-search-tools({ query: "fetch emails", toolkitSlug: "gmail" })\` + → finds \`GMAIL_FETCH_EMAILS\` +2. \`composio-execute-tool({ toolSlug: "GMAIL_FETCH_EMAILS", toolkitSlug: "gmail", arguments: { user_id: "me", max_results: 5 } })\` + +### Example: LinkedIn Profile (no-arg tool) + +User says: "Get my LinkedIn profile" + +1. \`composio-search-tools({ query: "get profile", toolkitSlug: "linkedin" })\` + → finds \`LINKEDIN_GET_MY_INFO\` with properties: {} +2. \`composio-execute-tool({ toolSlug: "LINKEDIN_GET_MY_INFO", toolkitSlug: "linkedin", arguments: {} })\` + +## Error Recovery + +- **If a tool call fails** (missing fields, 500 error): Fix the arguments and retry IMMEDIATELY. Do NOT stop and narrate the error to the user. +- **If search returns 0 results**: Try a different short query. If still 0, the tool may not exist for that service. +- **If a tool requires connection**: Call \`composio-connect-toolkit\` once, then retry after connection. + +## Multi-Part Requests + +When the user says "connect X and then do Y" — complete BOTH parts in one turn: +1. If X is already connected (check the connected list), skip to Y immediately +2. If X needs connecting, connect it, then proceed to Y after authentication + +## Confirmation Rules + +- **Read-only actions** (fetch, list, get, search): Execute without asking +- **Mutating actions** (send email, create issue, post, delete): Show the user what you're about to do and confirm before executing +- **Connecting a toolkit**: Always safe — just do it when needed +`; + +export default skill; diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index 44774d6e..ee51ecb3 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -12,6 +12,7 @@ import backgroundAgentsSkill from "./background-agents/skill.js"; import createPresentationsSkill from "./create-presentations/skill.js"; import appNavigationSkill from "./app-navigation/skill.js"; +import composioIntegrationSkill from "./composio-integration/skill.js"; const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)); const CATALOG_PREFIX = "src/application/assistant/skills"; @@ -84,6 +85,12 @@ const definitions: SkillDefinition[] = [ summary: "Discovering, executing, and integrating MCP tools. Use this to check what external capabilities are available and execute MCP tools on behalf of users.", content: mcpIntegrationSkill, }, + { + id: "composio-integration", + title: "Composio Integration", + summary: "Interact with third-party services (Gmail, GitHub, Slack, LinkedIn, Notion, Jira, Google Sheets, etc.) via Composio. Search, connect, and execute tools.", + content: composioIntegrationSkill, + }, { id: "deletion-guardrails", title: "Deletion Guardrails", diff --git a/apps/x/packages/core/src/application/assistant/skills/mcp-integration/skill.ts b/apps/x/packages/core/src/application/assistant/skills/mcp-integration/skill.ts index eed51898..cdebbfdd 100644 --- a/apps/x/packages/core/src/application/assistant/skills/mcp-integration/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/mcp-integration/skill.ts @@ -3,9 +3,13 @@ export const skill = String.raw` **Load this skill proactively** when a user asks for ANY task that might require external capabilities (web search, internet access, APIs, data fetching, time/date, etc.). This skill provides complete guidance on discovering and executing MCP tools. -## CRITICAL: Always Check MCP Tools First +## CRITICAL: Composio Tools Take Priority Over MCP -**IMPORTANT**: When a user asks for ANY task that might require external capabilities (web search, API calls, data fetching, etc.), ALWAYS: +**If a Composio toolkit is connected for the service the user wants (GitHub, Gmail, Slack, etc.), use the \`composio-search-tools\` and \`composio-execute-tool\` builtin tools — NOT MCP tools.** Composio integrations are already authenticated and ready to use. Only fall back to MCP tools if the service is NOT available through Composio. + +## When to Check MCP Tools + +**IMPORTANT**: When a user asks for a task that requires external capabilities AND no Composio toolkit covers it, check MCP tools: 1. **First check**: Call \`listMcpServers\` to see what's available 2. **Then list tools**: Call \`listMcpTools\` on relevant servers @@ -18,14 +22,12 @@ export const skill = String.raw` | User Request | Check For | Likely Tool | |--------------|-----------|-------------| -| "Search the web/internet" | firecrawl, composio, fetch | \`firecrawl_search\`, \`COMPOSIO_SEARCH_WEB\` | +| "Search the web/internet" | firecrawl, fetch | \`firecrawl_search\` | | "Scrape this website" | firecrawl | \`firecrawl_scrape\` | | "Read/write files" | filesystem | \`read_file\`, \`write_file\` | | "Get current time/date" | time | \`get_current_time\` | | "Make HTTP request" | fetch | \`fetch\`, \`post\` | -| "GitHub operations" | github | \`create_issue\`, \`search_repos\` | | "Generate audio/speech" | elevenLabs | \`text_to_speech\` | -| "Tweet/social media" | twitter, composio | Various social tools | ## Key concepts - MCP servers expose tools (web scraping, APIs, databases, etc.) declared in \`config/mcp.json\`. @@ -242,7 +244,7 @@ The schema tells you: **Example schema from listMcpTools:** \`\`\`json { - "name": "COMPOSIO_SEARCH_WEB", + "name": "firecrawl_search", "inputSchema": { "type": "object", "properties": { @@ -263,10 +265,10 @@ The schema tells you: **Correct executeMcpTool call:** \`\`\`json { - "serverName": "composio", - "toolName": "COMPOSIO_SEARCH_WEB", + "serverName": "firecrawl", + "toolName": "firecrawl_search", "arguments": { - "query": "elon musk latest news" + "query": "latest AI news" } } \`\`\` @@ -274,18 +276,18 @@ The schema tells you: **WRONG - Missing arguments:** \`\`\`json { - "serverName": "composio", - "toolName": "COMPOSIO_SEARCH_WEB" + "serverName": "firecrawl", + "toolName": "firecrawl_search" } \`\`\` **WRONG - Wrong parameter name:** \`\`\`json { - "serverName": "composio", - "toolName": "COMPOSIO_SEARCH_WEB", + "serverName": "firecrawl", + "toolName": "firecrawl_search", "arguments": { - "search": "elon musk" // Wrong! Should be "query" + "search": "latest AI news" // Wrong! Should be "query" } } \`\`\` 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 069cf7ef..404db713 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -12,6 +12,9 @@ import { McpServerDefinition } from "@x/shared/dist/mcp.js"; 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 { executeAction as executeComposioAction, isConfigured as isComposioConfigured, searchTools as searchComposioTools } from "../../composio/client.js"; +import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js"; import type { ToolContext } from "./exec-tool.js"; import { generateText } from "ai"; import { createProvider } from "../../models/models.js"; @@ -1173,4 +1176,152 @@ export const BuiltinTools: z.infer = { }; }, }, + + // ======================================================================== + // Composio Meta-Tools + // ======================================================================== + + 'composio-list-toolkits': { + description: 'List available Composio integrations (Gmail, Slack, GitHub, etc.) and their connection status. Use this to show the user what services they can connect to.', + inputSchema: z.object({ + category: z.enum(['all', 'communication', 'productivity', 'development', 'crm', 'social', 'storage', 'support']).optional() + .describe('Filter by category. Defaults to "all".'), + }), + execute: async ({ category }: { category?: string }) => { + const toolkits = CURATED_TOOLKITS + .filter(t => !category || category === 'all' || t.category === category) + .map(t => ({ + slug: t.slug, + name: t.displayName, + category: t.category, + isConnected: composioAccountsRepo.isConnected(t.slug), + })); + + const connectedCount = toolkits.filter(t => t.isConnected).length; + return { + toolkits, + connectedCount, + totalCount: toolkits.length, + }; + }, + isAvailable: async () => isComposioConfigured(), + }, + + 'composio-search-tools': { + description: 'Search for Composio tools by use case across connected services. Returns tool slugs, descriptions, and input schemas so you can call composio-execute-tool with the right parameters. Example: search "send email" to find Gmail tools, "create issue" to find GitHub/Jira tools.', + inputSchema: z.object({ + query: z.string().describe('Natural language description of what you want to do (e.g., "send an email", "create a GitHub issue", "schedule a meeting")'), + toolkitSlug: z.string().optional().describe('Optional: limit search to a specific toolkit (e.g., "gmail", "github")'), + }), + execute: async ({ query, toolkitSlug }: { query: string; toolkitSlug?: string }) => { + try { + const toolkitFilter = toolkitSlug ? [toolkitSlug] : undefined; + const result = await searchComposioTools(query, toolkitFilter); + + // Filter to curated toolkits only (skip if a specific toolkit was requested — + // the API already filtered server-side) + const filtered = toolkitSlug + ? result.items + : result.items.filter(t => CURATED_TOOLKIT_SLUGS.has(t.toolkitSlug)); + + // Annotate with connection status + const tools = filtered.map(t => ({ + slug: t.slug, + name: t.name, + description: t.description, + toolkitSlug: t.toolkitSlug, + isConnected: composioAccountsRepo.isConnected(t.toolkitSlug), + inputSchema: t.inputParameters, + })); + + return { + tools, + resultCount: tools.length, + hint: tools.some(t => !t.isConnected) + ? 'Some tools require connecting the toolkit first. Use composio-connect-toolkit to help the user authenticate.' + : undefined, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { tools: [], resultCount: 0, error: message }; + } + }, + isAvailable: async () => isComposioConfigured(), + }, + + 'composio-execute-tool': { + description: 'Execute a Composio tool by its slug. You MUST pass the arguments field with all required parameters from the search results inputSchema. Example: composio-execute-tool({ toolSlug: "GITHUB_ISSUES_LIST_FOR_REPO", toolkitSlug: "github", arguments: { owner: "rowboatlabs", repo: "rowboat", state: "open", per_page: 100 } })', + inputSchema: z.object({ + toolSlug: z.string().describe('EXACT tool slug from search results (e.g., "GITHUB_ISSUES_LIST_FOR_REPO"). Copy it exactly — do not modify it.'), + toolkitSlug: z.string().describe('The toolkit slug (e.g., "gmail", "github")'), + arguments: z.record(z.string(), z.unknown()).describe('REQUIRED: Tool input parameters as key-value pairs. Get the required fields from the inputSchema returned by composio-search-tools. Never omit this.'), + }), + execute: async ({ toolSlug, toolkitSlug, arguments: args }: { toolSlug: string; toolkitSlug: string; arguments?: Record }) => { + // Default arguments to {} if the LLM omits the field entirely + const toolArgs = args ?? {}; + + // Check connection + const account = composioAccountsRepo.getAccount(toolkitSlug); + if (!account || account.status !== 'ACTIVE') { + return { + successful: false, + data: null, + error: `Toolkit "${toolkitSlug}" is not connected. Use composio-connect-toolkit to help the user connect it first.`, + }; + } + + try { + return await executeComposioAction(toolSlug, { + connected_account_id: account.id, + user_id: 'rowboat-user', + version: 'latest', + arguments: toolArgs, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[Composio] Tool execution failed for ${toolSlug}:`, message); + return { + successful: false, + data: null, + error: `Failed to execute ${toolSlug}: ${message}. If fields are missing, check the inputSchema and retry with the correct arguments.`, + }; + } + }, + isAvailable: async () => isComposioConfigured(), + }, + + 'composio-connect-toolkit': { + description: 'Connect a Composio service (Gmail, Slack, GitHub, etc.) via OAuth. Shows a connect card for the user to authenticate.', + inputSchema: z.object({ + toolkitSlug: z.string().describe('The toolkit slug to connect (e.g., "gmail", "github", "slack", "notion")'), + }), + execute: async ({ toolkitSlug }: { toolkitSlug: string }) => { + // Validate against curated list + if (!CURATED_TOOLKIT_SLUGS.has(toolkitSlug)) { + const available = CURATED_TOOLKITS.map(t => `${t.slug} (${t.displayName})`).join(', '); + return { + success: false, + error: `Unknown toolkit "${toolkitSlug}". Available toolkits: ${available}`, + }; + } + + // Check if already connected + if (composioAccountsRepo.isConnected(toolkitSlug)) { + return { + success: true, + message: `${toolkitSlug} is already connected. You can search for and execute its tools.`, + alreadyConnected: true, + }; + } + + // Return signal — the UI renders a ComposioConnectCard with a Connect button. + // OAuth only starts when the user clicks that button. + const toolkit = CURATED_TOOLKITS.find(t => t.slug === toolkitSlug); + return { + success: true, + message: `Please connect ${toolkit?.displayName ?? toolkitSlug} to continue.`, + }; + }, + isAvailable: async () => isComposioConfigured(), + }, }; diff --git a/apps/x/packages/core/src/composio/client.ts b/apps/x/packages/core/src/composio/client.ts index 3602bef6..2844fc28 100644 --- a/apps/x/packages/core/src/composio/client.ts +++ b/apps/x/packages/core/src/composio/client.ts @@ -14,8 +14,9 @@ import { ZExecuteActionRequest, ZExecuteActionResponse, ZListResponse, - ZTool, + ZSearchResultTool, ZToolkit, + type NormalizedToolResult, } from "./types.js"; import { isSignedIn } from "../account/account.js"; import { getAccessToken } from "../auth/tokens.js"; @@ -72,7 +73,7 @@ function loadConfig(): ComposioConfig { /** * Save Composio configuration */ -export function saveConfig(config: ComposioConfig): void { +function saveConfig(config: ComposioConfig): void { const dir = path.dirname(CONFIG_FILE); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); @@ -167,7 +168,15 @@ export async function composioApiCall( } if (!response.ok) { - throw new Error(`Composio API error: ${response.status} ${response.statusText}`); + // Try to extract a human-readable message from the JSON body + let detail = ''; + try { + const body = JSON.parse(rawText); + if (typeof body?.error === 'string') detail = body.error; + else if (typeof body?.message === 'string') detail = body.message; + } catch { /* body isn't JSON or has no message field */ } + const suffix = detail ? `: ${detail}` : ''; + throw new Error(`Composio API error: ${response.status} ${response.statusText}${suffix}`); } if (!contentType.includes('application/json')) { @@ -246,15 +255,6 @@ export async function createAuthConfig( }); } -/** - * Delete an auth config - */ -export async function deleteAuthConfig(authConfigId: string): Promise> { - return composioApiCall(ZDeleteOperationResponse, `/auth_configs/${authConfigId}`, {}, { - method: 'DELETE', - }); -} - /** * Create a connected account */ @@ -284,20 +284,39 @@ export async function deleteConnectedAccount(connectedAccountId: string): Promis } /** - * List available tools for a toolkit + * Search for tools across all toolkits (or optionally filtered by specific toolkit slugs). + * Returns tools with full input_parameters so the agent knows what params to pass. + * + * Uses a limit of 50 (not 15) to avoid the curated-filter-after-limit problem where + * in-scope results at position 16+ would be discarded if earlier results are out-of-scope. */ -export async function listToolkitTools( - toolkitSlug: string, - searchQuery: string | null = null, -): Promise>>> { +export async function searchTools( + searchQuery: string, + toolkitSlugs?: string[], +): Promise<{ items: NormalizedToolResult[] }> { const params: Record = { - toolkit_slug: toolkitSlug, - limit: '200', + query: searchQuery, + limit: '50', }; - if (searchQuery) { - params.search = searchQuery; + if (toolkitSlugs && toolkitSlugs.length === 1) { + params.toolkit_slug = toolkitSlugs[0]; } - return composioApiCall(ZListResponse(ZTool), "/tools", params); + + const result = await composioApiCall(ZListResponse(ZSearchResultTool), "/tools", params); + + const items: NormalizedToolResult[] = result.items.map((item) => ({ + slug: item.slug, + name: item.name, + description: item.description, + toolkitSlug: item.toolkit.slug, + inputParameters: { + type: 'object' as const, + properties: item.input_parameters?.properties ?? {}, + required: item.input_parameters?.required, + }, + })); + + return { items }; } /** diff --git a/apps/x/packages/core/src/composio/types.ts b/apps/x/packages/core/src/composio/types.ts index 5f6faa34..71a5c2df 100644 --- a/apps/x/packages/core/src/composio/types.ts +++ b/apps/x/packages/core/src/composio/types.ts @@ -1,4 +1,8 @@ import { z } from "zod"; +import { ZToolkitMeta as ZSharedToolkitMeta, ZToolkitItem } from "@x/shared/dist/composio.js"; + +// Re-export the shared toolkit schemas so existing imports continue to work +export const ZToolkitMeta = ZSharedToolkitMeta; /** * Composio authentication schemes @@ -29,26 +33,9 @@ export const ZConnectedAccountStatus = z.enum([ ]); /** - * Toolkit metadata + * Toolkit schema — same shape as ZToolkitItem from shared, re-exported for convenience. */ -export const ZToolkitMeta = z.object({ - description: z.string(), - logo: z.string(), - tools_count: z.number(), - triggers_count: z.number(), -}); - -/** - * Toolkit schema - */ -export const ZToolkit = z.object({ - slug: z.string(), - name: z.string(), - meta: ZToolkitMeta, - no_auth: z.boolean().optional(), - auth_schemes: z.array(ZAuthScheme).optional(), - composio_managed_auth_schemes: z.array(ZAuthScheme).optional(), -}); +export const ZToolkit = ZToolkitItem; /** * Tool schema @@ -147,7 +134,7 @@ export const ZCreateConnectedAccountRequest = z.object({ */ export const ZCreateConnectedAccountResponse = z.object({ id: z.string(), - connectionData: ZConnectionData, + connectionData: ZConnectionData.optional(), }); /** @@ -227,12 +214,44 @@ export const ZLocalConnectedAccount = z.object({ lastUpdatedAt: z.string(), }); -export type AuthScheme = z.infer; -export type ConnectedAccountStatus = z.infer; export type Toolkit = z.infer; -export type Tool = z.infer; -export type AuthConfig = z.infer; -export type ConnectedAccount = z.infer; export type LocalConnectedAccount = z.infer; -export type ExecuteActionRequest = z.infer; -export type ExecuteActionResponse = z.infer; +export type ConnectedAccountStatus = z.infer; + +/** + * Tool schema for search results. + * Unlike ZTool, `toolkit` is optional because the Composio /tools search endpoint + * sometimes omits the toolkit object from results. `input_parameters` uses + * lenient defaults so tools with no params (e.g. LINKEDIN_GET_MY_INFO) parse cleanly. + */ +export const ZSearchResultTool = z.object({ + slug: z.string(), + name: z.string(), + description: z.string(), + toolkit: z.object({ + slug: z.string(), + name: z.string(), + logo: z.string(), + }), + input_parameters: 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: {} }), +}).passthrough(); + +/** + * Normalized tool result returned from searchTools(). + */ +export const ZNormalizedToolResult = z.object({ + slug: z.string(), + name: z.string(), + description: z.string(), + toolkitSlug: z.string(), + inputParameters: z.object({ + type: z.literal('object'), + properties: z.record(z.string(), z.unknown()), + required: z.array(z.string()).optional(), + }), +}); +export type NormalizedToolResult = z.infer; diff --git a/apps/x/packages/shared/src/composio.ts b/apps/x/packages/shared/src/composio.ts new file mode 100644 index 00000000..8a15e8d2 --- /dev/null +++ b/apps/x/packages/shared/src/composio.ts @@ -0,0 +1,78 @@ +import { z } from 'zod'; + +/** + * Zod schemas for Composio IPC responses. + * Defined here in shared so both ipc.ts and core/composio/types.ts can reference them. + */ +export const ZToolkitMeta = z.object({ + description: z.string(), + logo: z.string(), + tools_count: z.number(), + triggers_count: z.number(), +}); + +export const ZToolkitItem = z.object({ + slug: z.string(), + name: z.string(), + meta: ZToolkitMeta, + no_auth: z.boolean().optional(), + auth_schemes: z.array(z.string()).optional(), + composio_managed_auth_schemes: z.array(z.string()).optional(), +}); + +export const ZListToolkitsResponse = z.object({ + items: z.array(ZToolkitItem), + nextCursor: z.string().nullable(), + totalItems: z.number(), +}); + +/** + * Curated Composio toolkits available to Rowboat users. + * Single source of truth for slugs, display names, and categories. + * Sorted by slug (ASC) for maintainability. + */ + +export type ToolkitCategory = 'communication' | 'productivity' | 'development' | 'crm' | 'social' | 'storage' | 'support'; + +export interface CuratedToolkit { + slug: string; + displayName: string; + category: ToolkitCategory; +} + +export const CURATED_TOOLKITS: CuratedToolkit[] = [ + { slug: 'airtable', displayName: 'Airtable', category: 'productivity' }, + { slug: 'asana', displayName: 'Asana', category: 'productivity' }, + { slug: 'cal', displayName: 'Cal.com', category: 'productivity' }, + { slug: 'calendly', displayName: 'Calendly', category: 'productivity' }, + { slug: 'dropbox', displayName: 'Dropbox', category: 'storage' }, + { slug: 'github', displayName: 'GitHub', category: 'development' }, + { slug: 'gmail', displayName: 'Gmail', category: 'communication' }, + { slug: 'googlecalendar', displayName: 'Google Calendar', category: 'productivity' }, + { slug: 'googledocs', displayName: 'Google Docs', category: 'productivity' }, + { slug: 'googledrive', displayName: 'Google Drive', category: 'storage' }, + { slug: 'googlesheets', displayName: 'Google Sheets', category: 'productivity' }, + { slug: 'hubspot', displayName: 'HubSpot', category: 'crm' }, + { slug: 'intercom', displayName: 'Intercom', category: 'support' }, + { slug: 'jira', displayName: 'Jira', category: 'development' }, + { slug: 'linear', displayName: 'Linear', category: 'development' }, + { slug: 'linkedin', displayName: 'LinkedIn', category: 'social' }, + { slug: 'microsoft_outlook', displayName: 'Microsoft Outlook', category: 'communication' }, + { slug: 'microsoft_teams', displayName: 'Microsoft Teams', category: 'communication' }, + { slug: 'notion', displayName: 'Notion', category: 'productivity' }, + { slug: 'onedrive', displayName: 'OneDrive', category: 'storage' }, + { slug: 'reddit', displayName: 'Reddit', category: 'social' }, + { slug: 'salesforce', displayName: 'Salesforce', category: 'crm' }, + { slug: 'slack', displayName: 'Slack', category: 'communication' }, + { slug: 'trello', displayName: 'Trello', category: 'productivity' }, + { slug: 'twitter', displayName: 'X', category: 'social' }, + { slug: 'zendesk', displayName: 'Zendesk', category: 'support' }, +]; + +/** Slug → display-name lookup. */ +export const COMPOSIO_DISPLAY_NAMES: Record = Object.fromEntries( + CURATED_TOOLKITS.map(t => [t.slug, t.displayName]) +); + +/** Set of curated slugs for fast membership checks. */ +export const CURATED_TOOLKIT_SLUGS = new Set(CURATED_TOOLKITS.map(t => t.slug)); diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 28718db3..7e32a4fc 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -8,6 +8,7 @@ import { AgentScheduleState } from './agent-schedule-state.js'; import { ServiceEvent } from './service-events.js'; import { UserMessageContent } from './message.js'; import { RowboatApiConfig } from './rowboat-account.js'; +import { ZListToolkitsResponse } from './composio.js'; // ============================================================================ // Runtime Validation Schemas (Single Source of Truth) @@ -377,18 +378,6 @@ const ipcSchemas = { toolkits: z.array(z.string()), }), }, - 'composio:execute-action': { - req: z.object({ - actionSlug: z.string(), - toolkitSlug: z.string(), - input: z.record(z.string(), z.unknown()), - }), - res: z.object({ - data: z.unknown(), - successful: z.boolean(), - error: z.string().nullable(), - }), - }, 'composio:use-composio-for-google': { req: z.null(), res: z.object({ @@ -409,6 +398,11 @@ const ipcSchemas = { }), res: z.null(), }, + // Composio Tools Library channels + 'composio:list-toolkits': { + req: z.object({}), + res: ZListToolkitsResponse, + }, // Agent schedule channels 'agent-schedule:getConfig': { req: z.null(),