Refactor Composio integration handling and improve UI components

- Updated composio-handler.ts to invalidate the copilot instructions cache upon connection and disconnection.
- Removed unused functions related to tool management in composio-handler.ts.
- Enhanced IPC handlers in ipc.ts to streamline Composio connection processes.
- Introduced ComposioConnectCard in the renderer to display connection status and handle events.
- Refactored tool rendering in App.tsx and chat-sidebar.tsx to utilize new tabbed content for parameters and results.
- Improved Composio tools prompt generation in instructions.ts to clarify integration usage and discovery flow.
- Cleaned up unused code and improved overall structure for better maintainability.
This commit is contained in:
tusharmagar 2026-04-02 15:35:31 +05:30
parent abf6901cc9
commit 7f8d2e64af
18 changed files with 864 additions and 812 deletions

View file

@ -2,11 +2,9 @@ import { shell, BrowserWindow } from 'electron';
import { createAuthServer } from './auth-server.js'; import { createAuthServer } from './auth-server.js';
import * as composioClient from '@x/core/dist/composio/client.js'; import * as composioClient from '@x/core/dist/composio/client.js';
import { composioAccountsRepo } from '@x/core/dist/composio/repo.js'; import { composioAccountsRepo } from '@x/core/dist/composio/repo.js';
import { composioEnabledToolsRepo } from '@x/core/dist/composio/enabled-tools-repo.js'; import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
import type { EnabledTool } from '@x/core/dist/composio/enabled-tools-repo.js'; import { CURATED_TOOLKIT_SLUGS } from '@x/core/dist/composio/curated-toolkits.js';
import type { LocalConnectedAccount, ZExecuteActionResponse } from '@x/core/dist/composio/types.js'; import type { LocalConnectedAccount } from '@x/core/dist/composio/types.js';
import { refreshComposioTools } from '@x/core/dist/application/lib/builtin-tools.js';
import { z } from 'zod';
import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.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'; import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js';
@ -163,6 +161,8 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status); composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status);
if (accountStatus.status === 'ACTIVE') { if (accountStatus.status === 'ACTIVE') {
// Invalidate instructions cache so the copilot knows about the new connection
invalidateCopilotInstructionsCache();
emitComposioEvent({ toolkitSlug, success: true }); emitComposioEvent({ toolkitSlug, success: true });
if (toolkitSlug === 'gmail') { if (toolkitSlug === 'gmail') {
triggerGmailSync(); triggerGmailSync();
@ -273,23 +273,16 @@ export async function disconnect(toolkitSlug: string): Promise<{ success: boolea
try { try {
const account = composioAccountsRepo.getAccount(toolkitSlug); const account = composioAccountsRepo.getAccount(toolkitSlug);
if (account) { if (account) {
// Delete from Composio
await composioClient.deleteConnectedAccount(account.id); await composioClient.deleteConnectedAccount(account.id);
// Delete local record
composioAccountsRepo.deleteAccount(toolkitSlug);
} }
// Clean up enabled tools for this toolkit
composioEnabledToolsRepo.disableAllForToolkit(toolkitSlug);
refreshComposioTools();
return { success: true };
} catch (error) { } catch (error) {
console.error('[Composio] Disconnect failed:', 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); composioAccountsRepo.deleteAccount(toolkitSlug);
composioEnabledToolsRepo.disableAllForToolkit(toolkitSlug); invalidateCopilotInstructionsCache();
refreshComposioTools();
return { success: true };
} }
return { success: true };
} }
/** /**
@ -314,42 +307,7 @@ export async function useComposioForGoogleCalendar(): Promise<{ enabled: boolean
} }
/** /**
* Execute a Composio action * List available Composio toolkits filtered to curated list only
*/
export async function executeAction(
actionSlug: string,
toolkitSlug: string,
input: Record<string, unknown>
): Promise<z.infer<typeof ZExecuteActionResponse>> {
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',
};
}
}
/**
* List available Composio toolkits
*/ */
export async function listToolkits(cursor?: string): Promise<{ export async function listToolkits(cursor?: string): Promise<{
items: Array<{ items: Array<{
@ -363,79 +321,12 @@ export async function listToolkits(cursor?: string): Promise<{
nextCursor: string | null; nextCursor: string | null;
totalItems: number; totalItems: number;
}> { }> {
// Fetch all toolkits and filter to curated list
const result = await composioClient.listToolkits(cursor || null); const result = await composioClient.listToolkits(cursor || null);
const filtered = result.items.filter(item => CURATED_TOOLKIT_SLUGS.has(item.slug));
return { return {
items: result.items, items: filtered,
nextCursor: result.next_cursor, nextCursor: result.next_cursor,
totalItems: result.total_items, totalItems: filtered.length,
}; };
} }
/**
* 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<string, unknown>; required?: string[] };
}>;
}> {
return composioClient.listToolkitToolsDetailed(toolkitSlug, search || null);
}
/**
* Get all enabled tools
*/
export function getEnabledTools(): {
tools: Record<string, { slug: string; name: string; description: string; toolkitSlug: string }>;
} {
const all = composioEnabledToolsRepo.getAll();
const tools: Record<string, { slug: string; name: string; description: string; toolkitSlug: string }> = {};
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<string, unknown>; 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 };
}

View file

@ -34,6 +34,7 @@ import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granol
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js'; import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
import * as composioHandler from './composio-handler.js'; import * as composioHandler from './composio-handler.js';
import { setConnectionInitiator } from '@x/core/dist/composio/connection-bridge.js';
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js'; import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js'; import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js'; import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
@ -376,6 +377,9 @@ export function setupIpcHandlers() {
// Forward knowledge commit events to renderer for panel refresh // Forward knowledge commit events to renderer for panel refresh
versionHistory.onCommit(() => emitKnowledgeCommitEvent()); versionHistory.onCommit(() => emitKnowledgeCommitEvent());
// Wire the connection bridge so builtin tools (in core) can trigger OAuth (in main)
setConnectionInitiator(composioHandler.initiateConnection);
registerIpcHandlers({ registerIpcHandlers({
'app:getVersions': async () => { 'app:getVersions': async () => {
// args is null for this channel (no request payload) // args is null for this channel (no request payload)
@ -559,25 +563,10 @@ export function setupIpcHandlers() {
'composio:list-connected': async () => { 'composio:list-connected': async () => {
return composioHandler.listConnected(); return composioHandler.listConnected();
}, },
'composio:execute-action': async (_event, args) => {
return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input);
},
// Composio Tools Library handlers // Composio Tools Library handlers
'composio:list-toolkits': async (_event, args) => { 'composio:list-toolkits': async (_event, args) => {
return composioHandler.listToolkits(args.cursor); 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);
},
'composio:use-composio-for-google': async () => { 'composio:use-composio-for-google': async () => {
return composioHandler.useComposioForGoogle(); return composioHandler.useComposioForGoogle();
}, },

View file

@ -34,9 +34,10 @@ import {
import { Shimmer } from '@/components/ai-elements/shimmer'; import { Shimmer } from '@/components/ai-elements/shimmer';
import { useSmoothedText } from './hooks/useSmoothedText'; 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 { WebSearchResult } from '@/components/ai-elements/web-search-result';
import { AppActionCard } from '@/components/ai-elements/app-action-card'; 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 { PermissionRequest } from '@/components/ai-elements/permission-request';
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
import { Suggestions } from '@/components/ai-elements/suggestions'; import { Suggestions } from '@/components/ai-elements/suggestions';
@ -67,6 +68,8 @@ import {
createEmptyChatTabViewState, createEmptyChatTabViewState,
getWebSearchCardData, getWebSearchCardData,
getAppActionCardData, getAppActionCardData,
getComposioConnectCardData,
getComposioActionCardData,
inferRunTitleFromMessage, inferRunTitleFromMessage,
isChatMessage, isChatMessage,
isErrorMessage, isErrorMessage,
@ -76,6 +79,7 @@ import {
parseAttachedFiles, parseAttachedFiles,
toToolState, toToolState,
} from '@/lib/chat-conversation' } 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 { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js'
import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js' import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
import { toast } from "sonner" import { toast } from "sonner"
@ -2242,6 +2246,12 @@ function App() {
} }
handlePromptSubmitRef.current = handlePromptSubmit 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 () => { const handleStop = useCallback(async () => {
if (!runId) return if (!runId) return
const now = Date.now() const now = Date.now()
@ -3826,6 +3836,21 @@ function App() {
/> />
) )
} }
const composioConnectData = getComposioConnectCardData(item)
if (composioConnectData) {
return (
<ComposioConnectCard
key={item.id}
toolkitSlug={composioConnectData.toolkitSlug}
toolkitDisplayName={composioConnectData.toolkitDisplayName}
status={item.status}
alreadyConnected={composioConnectData.alreadyConnected}
onConnected={handleComposioConnected}
/>
)
}
const composioActionData = getComposioActionCardData(item)
const toolTitle = composioActionData ? composioActionData.label : item.name
const errorText = item.status === 'error' ? 'Tool error' : '' const errorText = item.status === 'error' ? 'Tool error' : ''
const output = normalizeToolOutput(item.result, item.status) const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input) const input = normalizeToolInput(item.input)
@ -3836,15 +3861,12 @@ function App() {
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)} onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
> >
<ToolHeader <ToolHeader
title={item.name} title={toolTitle}
type={`tool-${item.name}`} type={`tool-${item.name}`}
state={toToolState(item.status)} state={toToolState(item.status)}
/> />
<ToolContent> <ToolContent>
<ToolInput input={input} /> <ToolTabbedContent input={input} output={output} errorText={errorText} />
{output !== null ? (
<ToolOutput output={output} errorText={errorText} />
) : null}
</ToolContent> </ToolContent>
</Tool> </Tool>
) )
@ -4470,6 +4492,7 @@ function App() {
ttsMode={ttsMode} ttsMode={ttsMode}
onToggleTts={handleToggleTts} onToggleTts={handleToggleTts}
onTtsModeChange={handleTtsModeChange} onTtsModeChange={handleTtsModeChange}
onComposioConnected={handleComposioConnected}
/> />
)} )}
{/* Rendered last so its no-drag region paints over the sidebar drag region */} {/* Rendered last so its no-drag region paints over the sidebar drag region */}

View file

@ -0,0 +1,150 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import {
CheckCircleIcon,
Link2Icon,
LoaderIcon,
WrenchIcon,
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<string | null>(null);
const [didFireCallback, setDidFireCallback] = useState(false);
// If the tool result already says connected, reflect that
useEffect(() => {
if (alreadyConnected) {
setConnectionState("connected");
}
}, [alreadyConnected]);
// 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);
} else {
setConnectionState("error");
setErrorMessage(event.error || "Connection failed");
}
}
);
return cleanup;
}, [toolkitSlug]);
// Fire onConnected callback once when connected
useEffect(() => {
if (connectionState === "connected" && !didFireCallback && !alreadyConnected) {
setDidFireCallback(true);
onConnected?.(toolkitSlug);
}
}, [connectionState, didFireCallback, alreadyConnected, onConnected, toolkitSlug]);
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");
}
// Success will be handled by composio:didConnect event
} catch {
setConnectionState("error");
setErrorMessage("Failed to initiate connection");
}
}, [toolkitSlug]);
const isToolRunning = status === "pending" || status === "running";
return (
<div className="not-prose mb-4 flex items-center gap-3 rounded-lg border px-3 py-2.5">
{/* Icon */}
<div className="size-7 rounded bg-muted flex items-center justify-center flex-shrink-0">
<WrenchIcon className="size-3.5 text-muted-foreground" />
</div>
{/* Name & status text */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium truncate">
{toolkitDisplayName || toolkitSlug}
</span>
{connectionState === "connected" && (
<span className="rounded-full bg-green-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-green-600">
Connected
</span>
)}
</div>
{connectionState === "error" && errorMessage && (
<p className="text-xs text-destructive truncate">{errorMessage}</p>
)}
{connectionState === "idle" && isToolRunning && (
<p className="text-xs text-muted-foreground">
Waiting to connect...
</p>
)}
</div>
{/* Action area */}
{connectionState === "connected" ? (
<CheckCircleIcon className="size-4 text-green-600 flex-shrink-0" />
) : connectionState === "connecting" ? (
<Button size="sm" disabled className="text-xs h-7 flex-shrink-0">
<LoaderIcon className="size-3 animate-spin mr-1" />
Connecting...
</Button>
) : connectionState === "error" ? (
<div className="flex items-center gap-1.5 flex-shrink-0">
<XCircleIcon className="size-3.5 text-destructive" />
<Button
size="sm"
variant="outline"
onClick={handleConnect}
className="text-xs h-7"
>
Retry
</Button>
</div>
) : isToolRunning ? (
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground flex-shrink-0" />
) : (
<Button
size="sm"
onClick={handleConnect}
className="text-xs h-7 flex-shrink-0"
>
<Link2Icon className="size-3 mr-1" />
Connect
</Button>
)}
</div>
);
}

View file

@ -16,8 +16,8 @@ import {
WrenchIcon, WrenchIcon,
XCircleIcon, XCircleIcon,
} from "lucide-react"; } from "lucide-react";
import type { ComponentProps, ReactNode } from "react"; import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
import { isValidElement } from "react";
const formatToolValue = (value: unknown) => { const formatToolValue = (value: unknown) => {
if (typeof value === "string") return value; if (typeof value === "string") return value;
try { try {
@ -37,7 +37,7 @@ const ToolCode = ({
}) => ( }) => (
<pre <pre
className={cn( className={cn(
"whitespace-pre-wrap text-xs font-mono", "whitespace-pre-wrap text-xs font-mono break-all",
className className
)} )}
> >
@ -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"]; input: ToolUIPart["input"];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn("space-y-2 overflow-hidden p-4", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
<div className="rounded-md border bg-muted/50 p-4 text-foreground">
<ToolCode code={formatToolValue(input ?? {})} />
</div>
</div>
);
export type ToolOutputProps = ComponentProps<"div"> & {
output: ToolUIPart["output"]; output: ToolUIPart["output"];
errorText: ToolUIPart["errorText"]; errorText?: ToolUIPart["errorText"];
}; };
export const ToolOutput = ({ export const ToolTabbedContent = ({
className, input,
output, output,
errorText, errorText,
...props }: ToolTabbedContentProps) => {
}: ToolOutputProps) => { const [activeTab, setActiveTab] = useState<"parameters" | "result">("parameters");
if (!(output || errorText)) { const hasOutput = output != null || !!errorText;
return null;
}
let Output = <div>{output as ReactNode}</div>;
let OutputNode: ReactNode = null;
if (errorText) {
OutputNode = <ToolCode code={errorText} className="text-destructive" />;
} else if (output != null) {
if (typeof output === "object" && !isValidElement(output)) { if (typeof output === "object" && !isValidElement(output)) {
Output = <ToolCode code={formatToolValue(output ?? null)} />; OutputNode = <ToolCode code={formatToolValue(output)} />;
} else if (typeof output === "string") { } else if (typeof output === "string") {
Output = <ToolCode code={formatToolValue(output)} />; OutputNode = <ToolCode code={output} />;
} else {
OutputNode = <div>{output as ReactNode}</div>;
}
} }
return ( return (
<div className={cn("space-y-2 p-4", className)} {...props}> <div className="border-t">
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide"> {/* Tabs */}
{errorText ? "Error" : "Result"} <div className="flex">
</h4> <button
<div type="button"
className={cn( className={cn(
"overflow-x-auto rounded-md border p-4 text-xs [&_table]:w-full", "px-4 py-2 text-xs font-medium transition-colors border-b-2",
errorText activeTab === "parameters"
? "bg-destructive/10 text-destructive" ? "border-foreground text-foreground"
: "bg-muted/50 text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"
)} )}
onClick={() => setActiveTab("parameters")}
> >
{errorText && ( Parameters
<div className="mb-2 font-sans text-xs text-destructive"> </button>
{errorText} <button
type="button"
className={cn(
"px-4 py-2 text-xs font-medium transition-colors border-b-2",
activeTab === "result"
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
onClick={() => setActiveTab("result")}
>
Result
</button>
</div>
{/* Tab content */}
<div className="p-3">
{activeTab === "parameters" && (
<div className="rounded-md border bg-muted/50 p-3 max-h-64 overflow-auto">
<ToolCode code={formatToolValue(input ?? {})} />
</div>
)}
{activeTab === "result" && (
<div
className={cn(
"rounded-md border p-3 max-h-64 overflow-auto",
errorText ? "bg-destructive/10" : "bg-muted/50"
)}
>
{hasOutput ? (
<div className={cn(errorText && "text-destructive")}>
{OutputNode}
</div>
) : (
<span className="text-xs text-muted-foreground">(pending...)</span>
)}
</div> </div>
)} )}
{Output}
</div> </div>
</div> </div>
); );
}; };

View file

@ -16,8 +16,9 @@ import {
MessageResponse, MessageResponse,
} from '@/components/ai-elements/message' } from '@/components/ai-elements/message'
import { Shimmer } from '@/components/ai-elements/shimmer' 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 { 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 { PermissionRequest } from '@/components/ai-elements/permission-request'
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request' import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
import { Suggestions } from '@/components/ai-elements/suggestions' import { Suggestions } from '@/components/ai-elements/suggestions'
@ -34,6 +35,8 @@ import {
type PermissionResponse, type PermissionResponse,
createEmptyChatTabViewState, createEmptyChatTabViewState,
getWebSearchCardData, getWebSearchCardData,
getComposioConnectCardData,
getComposioActionCardData,
isChatMessage, isChatMessage,
isErrorMessage, isErrorMessage,
isToolCall, isToolCall,
@ -121,6 +124,7 @@ interface ChatSidebarProps {
ttsMode?: 'summary' | 'full' ttsMode?: 'summary' | 'full'
onToggleTts?: () => void onToggleTts?: () => void
onTtsModeChange?: (mode: 'summary' | 'full') => void onTtsModeChange?: (mode: 'summary' | 'full') => void
onComposioConnected?: (toolkitSlug: string) => void
} }
export function ChatSidebar({ export function ChatSidebar({
@ -171,6 +175,7 @@ export function ChatSidebar({
ttsMode, ttsMode,
onToggleTts, onToggleTts,
onTtsModeChange, onTtsModeChange,
onComposioConnected,
}: ChatSidebarProps) { }: ChatSidebarProps) {
const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth)) const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth))
const [isResizing, setIsResizing] = useState(false) const [isResizing, setIsResizing] = useState(false)
@ -337,6 +342,21 @@ export function ChatSidebar({
/> />
) )
} }
const composioConnectData = getComposioConnectCardData(item)
if (composioConnectData) {
return (
<ComposioConnectCard
key={item.id}
toolkitSlug={composioConnectData.toolkitSlug}
toolkitDisplayName={composioConnectData.toolkitDisplayName}
status={item.status}
alreadyConnected={composioConnectData.alreadyConnected}
onConnected={onComposioConnected}
/>
)
}
const composioActionData = getComposioActionCardData(item)
const toolTitle = composioActionData ? composioActionData.label : item.name
const errorText = item.status === 'error' ? 'Tool error' : '' const errorText = item.status === 'error' ? 'Tool error' : ''
const output = normalizeToolOutput(item.result, item.status) const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input) const input = normalizeToolInput(item.input)
@ -346,10 +366,9 @@ export function ChatSidebar({
open={isToolOpenForTab?.(tabId, item.id) ?? false} open={isToolOpenForTab?.(tabId, item.id) ?? false}
onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)} onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
> >
<ToolHeader title={item.name} type={`tool-${item.name}`} state={toToolState(item.status)} /> <ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
<ToolContent> <ToolContent>
<ToolInput input={input} /> <ToolTabbedContent input={input} output={output} errorText={errorText} />
{output !== null ? <ToolOutput output={output} errorText={errorText} /> : null}
</ToolContent> </ToolContent>
</Tool> </Tool>
) )

View file

@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import { useState, useEffect, useCallback, useMemo } from "react" import { useState, useEffect, useCallback, useMemo } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronDown, ChevronRight, Check, Link2, Unlink, Tags, Mail, BookOpen, 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 { import {
Dialog, Dialog,
@ -724,14 +724,6 @@ interface ToolkitInfo {
composio_managed_auth_schemes: string[] composio_managed_auth_schemes: string[]
} }
interface ToolInfo {
slug: string
name: string
description: string
toolkitSlug: string
inputParameters?: { type?: string; properties?: Record<string, unknown>; required?: string[] }
}
function ToolsLibrarySettings({ dialogOpen }: { dialogOpen: boolean }) { function ToolsLibrarySettings({ dialogOpen }: { dialogOpen: boolean }) {
// API key state // API key state
const [apiKeyConfigured, setApiKeyConfigured] = useState(false) const [apiKeyConfigured, setApiKeyConfigured] = useState(false)
@ -748,18 +740,6 @@ function ToolsLibrarySettings({ dialogOpen }: { dialogOpen: boolean }) {
const [connectedToolkits, setConnectedToolkits] = useState<Set<string>>(new Set()) const [connectedToolkits, setConnectedToolkits] = useState<Set<string>>(new Set())
const [connectingToolkit, setConnectingToolkit] = useState<string | null>(null) const [connectingToolkit, setConnectingToolkit] = useState<string | null>(null)
// Tool selection state
const [expandedToolkit, setExpandedToolkit] = useState<string | null>(null)
const [toolkitTools, setToolkitTools] = useState<Record<string, ToolInfo[]>>({})
const [toolsLoading, setToolsLoading] = useState<string | null>(null)
const [enabledToolSlugs, setEnabledToolSlugs] = useState<Set<string>>(new Set())
// Tool search state (per-toolkit, server-side via Composio API)
const [toolSearchQuery, setToolSearchQuery] = useState("")
const [toolSearchResults, setToolSearchResults] = useState<ToolInfo[] | null>(null)
const [toolSearchLoading, setToolSearchLoading] = useState(false)
const toolSearchTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
// Check API key configuration // Check API key configuration
const checkApiKey = useCallback(async () => { const checkApiKey = useCallback(async () => {
try { try {
@ -783,16 +763,6 @@ function ToolsLibrarySettings({ dialogOpen }: { dialogOpen: boolean }) {
} }
}, []) }, [])
// 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 // Load toolkits
const loadToolkits = useCallback(async () => { const loadToolkits = useCallback(async () => {
setToolkitsLoading(true) setToolkitsLoading(true)
@ -811,8 +781,7 @@ function ToolsLibrarySettings({ dialogOpen }: { dialogOpen: boolean }) {
if (!dialogOpen) return if (!dialogOpen) return
checkApiKey() checkApiKey()
loadConnected() loadConnected()
loadEnabledTools() }, [dialogOpen, checkApiKey, loadConnected])
}, [dialogOpen, checkApiKey, loadConnected, loadEnabledTools])
// Load toolkits when API key is configured // Load toolkits when API key is configured
useEffect(() => { useEffect(() => {
@ -883,151 +852,12 @@ function ToolsLibrarySettings({ dialogOpen }: { dialogOpen: boolean }) {
next.delete(toolkitSlug) next.delete(toolkitSlug)
return next 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}`) toast.success(`Disconnected from ${toolkitSlug}`)
} catch { } catch {
toast.error("Failed to disconnect") 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)
}
}
// Search tools within a toolkit (debounced, server-side)
const handleToolSearch = useCallback((toolkitSlug: string, query: string) => {
setToolSearchQuery(query)
// Clear pending timer
if (toolSearchTimerRef.current) {
clearTimeout(toolSearchTimerRef.current)
toolSearchTimerRef.current = null
}
// Empty query: clear search results, show all tools
if (!query.trim()) {
setToolSearchResults(null)
setToolSearchLoading(false)
return
}
setToolSearchLoading(true)
// Debounce 350ms before hitting the API
toolSearchTimerRef.current = setTimeout(async () => {
try {
const result = await window.ipc.invoke("composio:list-toolkit-tools", {
toolkitSlug,
search: query.trim(),
})
setToolSearchResults(result.items)
} catch {
toast.error("Search failed")
setToolSearchResults(null)
} finally {
setToolSearchLoading(false)
}
}, 350)
}, [])
// Clear tool search when switching toolkits
useEffect(() => {
setToolSearchQuery("")
setToolSearchResults(null)
setToolSearchLoading(false)
if (toolSearchTimerRef.current) {
clearTimeout(toolSearchTimerRef.current)
toolSearchTimerRef.current = null
}
}, [expandedToolkit])
// Clean up pending search timer on unmount
useEffect(() => {
return () => {
if (toolSearchTimerRef.current) {
clearTimeout(toolSearchTimerRef.current)
}
}
}, [])
// 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 // Filter toolkits by search
const filteredToolkits = searchQuery.trim() const filteredToolkits = searchQuery.trim()
? toolkits.filter(t => ? toolkits.filter(t =>
@ -1125,29 +955,10 @@ function ToolsLibrarySettings({ dialogOpen }: { dialogOpen: boolean }) {
{filteredToolkits.map((toolkit) => { {filteredToolkits.map((toolkit) => {
const isConnected = connectedToolkits.has(toolkit.slug) const isConnected = connectedToolkits.has(toolkit.slug)
const isConnecting = connectingToolkit === 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
// Use search results when actively searching, otherwise show all tools
const displayTools = (isExpanded && toolSearchResults !== null) ? toolSearchResults : tools
const isSearching = isExpanded && toolSearchQuery.trim().length > 0
return ( return (
<div key={toolkit.slug} className={cn( <div key={toolkit.slug} className="border rounded-lg overflow-hidden">
"border rounded-lg overflow-hidden transition-colors", <div className="flex items-center gap-3 px-3 py-2.5">
isExpanded && "border-border/80 shadow-sm"
)}>
{/* Toolkit card header */}
<button
onClick={() => handleToggleToolkit(toolkit.slug)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-accent/50",
isExpanded && "bg-accent/30"
)}
>
{/* Logo */} {/* Logo */}
{toolkit.meta.logo ? ( {toolkit.meta.logo ? (
<img <img
@ -1166,54 +977,33 @@ function ToolsLibrarySettings({ dialogOpen }: { dialogOpen: boolean }) {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-sm font-medium truncate">{toolkit.name}</span> <span className="text-sm font-medium truncate">{toolkit.name}</span>
<span className="text-[10px] text-muted-foreground tabular-nums">
{toolkit.meta.tools_count} tools
</span>
{isConnected && ( {isConnected && (
<span className="rounded-full bg-green-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-green-600"> <span className="rounded-full bg-green-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-green-600">
Connected Connected
</span> </span>
)} )}
{enabledCount > 0 && (
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary tabular-nums">
{enabledCount} enabled
</span>
)}
</div> </div>
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{toolkit.meta.description} {toolkit.meta.description}
</p> </p>
</div> </div>
{/* Expand icon */} {/* Connect / Disconnect button */}
{isExpanded ? (
<ChevronDown className="size-4 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="size-4 text-muted-foreground flex-shrink-0" />
)}
</button>
{/* Expanded content */}
{isExpanded && (
<div className="border-t px-3 py-2.5 space-y-2.5 bg-muted/20">
{/* Connection controls */}
<div className="flex items-center gap-2">
{isConnected ? ( {isConnected ? (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={(e) => { e.stopPropagation(); handleDisconnect(toolkit.slug) }} onClick={() => handleDisconnect(toolkit.slug)}
className="text-xs h-7" className="text-xs h-7 flex-shrink-0"
> >
<Unlink className="size-3 mr-1" />
Disconnect Disconnect
</Button> </Button>
) : ( ) : (
<Button <Button
size="sm" size="sm"
onClick={(e) => { e.stopPropagation(); handleConnect(toolkit.slug) }} onClick={() => handleConnect(toolkit.slug)}
disabled={isConnecting} disabled={isConnecting}
className="text-xs h-7" className="text-xs h-7 flex-shrink-0"
> >
{isConnecting ? ( {isConnecting ? (
<><Loader2 className="size-3 animate-spin mr-1" />Connecting...</> <><Loader2 className="size-3 animate-spin mr-1" />Connecting...</>
@ -1222,132 +1012,7 @@ function ToolsLibrarySettings({ dialogOpen }: { dialogOpen: boolean }) {
)} )}
</Button> </Button>
)} )}
{/* Enable/Disable all (only if connected and tools loaded) */}
{isConnected && tools.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
handleToggleAllTools(toolkit.slug, !allEnabled)
}}
className="text-xs h-7 ml-auto"
>
{allEnabled ? "Disable All" : "Enable All"}
</Button>
)}
</div> </div>
{/* Tools section (only if connected) */}
{isConnected && (
<div className="space-y-2">
{/* Tool search input — shown when toolkit has many tools */}
{!isLoadingTools && tools.length > 5 && (
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<input
type="text"
value={toolSearchQuery}
onChange={(e) => handleToolSearch(toolkit.slug, e.target.value)}
placeholder={`Search ${toolkit.meta.tools_count} tools...`}
className="w-full h-7 pl-7 pr-7 text-xs rounded-md border border-border bg-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
{toolSearchLoading && (
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 size-3 animate-spin text-muted-foreground" />
)}
{!toolSearchLoading && toolSearchQuery && (
<button
onClick={() => handleToolSearch(toolkit.slug, "")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
</button>
)}
</div>
)}
{/* Search results summary */}
{isSearching && !toolSearchLoading && toolSearchResults !== null && (
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="tabular-nums">{toolSearchResults.length}</span>
<span>{toolSearchResults.length === 1 ? 'tool' : 'tools'} matching &ldquo;{toolSearchQuery}&rdquo;</span>
</div>
)}
{/* Tool list */}
{isLoadingTools ? (
<div className="flex items-center gap-2 py-3 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Loading tools...
</div>
) : displayTools.length === 0 ? (
<div className="py-3 text-center">
<p className="text-xs text-muted-foreground">
{isSearching
? `No tools found for "${toolSearchQuery}"`
: "No tools found"
}
</p>
{isSearching && (
<button
onClick={() => handleToolSearch(toolkit.slug, "")}
className="mt-1 text-[11px] text-primary hover:underline"
>
Clear search
</button>
)}
</div>
) : (
<div className="max-h-[240px] overflow-y-auto space-y-0.5 -mx-1 px-1">
{displayTools.map((tool) => {
const isEnabled = enabledToolSlugs.has(tool.slug)
return (
<label
key={tool.slug}
className={cn(
"flex items-start gap-2 px-2 py-1.5 rounded-md cursor-pointer transition-colors",
isEnabled ? "bg-primary/5" : "hover:bg-accent/50"
)}
>
<div className="pt-0.5">
<div
onClick={(e) => {
e.preventDefault()
handleToggleTool(tool, !isEnabled)
}}
className={cn(
"size-4 rounded border flex items-center justify-center transition-colors cursor-pointer",
isEnabled
? "bg-primary border-primary"
: "border-border hover:border-primary/50"
)}
>
{isEnabled && <Check className="size-3 text-primary-foreground" />}
</div>
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium">{tool.name}</div>
<div className="text-[11px] text-muted-foreground line-clamp-2">
{tool.description}
</div>
</div>
</label>
)
})}
</div>
)}
</div>
)}
{/* Not connected hint */}
{!isConnected && (
<p className="text-xs text-muted-foreground">
Connect this toolkit to browse and enable its tools.
</p>
)}
</div>
)}
</div> </div>
) )
})} })}

View file

@ -1,6 +1,7 @@
import type { ToolUIPart } from 'ai' import type { ToolUIPart } from 'ai'
import z from 'zod' import z from 'zod'
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js' import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js'
export interface MessageAttachment { export interface MessageAttachment {
path: string path: string
@ -253,6 +254,94 @@ export const parseAttachedFiles = (content: string): { message: string; files: s
return { message: cleanMessage.trim(), files } return { message: cleanMessage.trim(), files }
} }
// Composio connect card data
export type ComposioConnectCardData = {
toolkitSlug: string
toolkitDisplayName: string
alreadyConnected: boolean
}
// Display names imported from @x/shared/composio (single source of truth)
const composioDisplayNames = COMPOSIO_DISPLAY_NAMES
export const getComposioConnectCardData = (tool: ToolCall): ComposioConnectCardData | null => {
if (tool.name !== 'composio-connect-toolkit') return null
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
const result = tool.result as Record<string, unknown> | undefined
const toolkitSlug = (input?.toolkitSlug as string) || ''
const alreadyConnected = result?.alreadyConnected === true
return {
toolkitSlug,
toolkitDisplayName: composioDisplayNames[toolkitSlug] || toolkitSlug,
alreadyConnected,
}
}
// 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<string, unknown> | undefined
const result = tool.result as Record<string, unknown> | undefined
if (tool.name === 'composio-search-tools') {
const query = (input?.query as string) || 'tools'
const toolkitSlug = input?.toolkitSlug as string | undefined
const toolkit = toolkitSlug ? composioDisplayNames[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 = composioDisplayNames[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 => { export const inferRunTitleFromMessage = (content: string): string | undefined => {
const { message } = parseAttachedFiles(content) const { message } = parseAttachedFiles(content)
const normalized = message.replace(/\s+/g, ' ').trim() const normalized = message.replace(/\s+/g, ' ').trim()

View file

@ -370,8 +370,7 @@ function formatLlmStreamError(rawError: unknown): string {
export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> { export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
if (id === "copilot" || id === "rowboatx") { if (id === "copilot" || id === "rowboatx") {
// Rebuild tools from current BuiltinTools to pick up dynamically // Rebuild tools from current BuiltinTools (includes Composio meta-tools).
// registered Composio tools (added via refreshComposioTools).
const tools: Record<string, z.infer<typeof ToolAttachment>> = {}; const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
for (const name of Object.keys(BuiltinTools)) { for (const name of Object.keys(BuiltinTools)) {
tools[name] = { type: "builtin", name }; tools[name] = { type: "builtin", name };

View file

@ -1,51 +1,55 @@
import { skillCatalog } from "./skills/index.js"; // eslint-disable-line @typescript-eslint/no-unused-vars -- used in template literal 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 { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
import { composioEnabledToolsRepo } from "../../composio/enabled-tools-repo.js";
import { composioAccountsRepo } from "../../composio/repo.js"; import { composioAccountsRepo } from "../../composio/repo.js";
import { isConfigured as isComposioConfigured } from "../../composio/client.js"; import { isConfigured as isComposioConfigured } from "../../composio/client.js";
import { CURATED_TOOLKITS } from "../../composio/curated-toolkits.js";
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext()); const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
/** /**
* Generate dynamic instructions section for Composio tools. * Generate dynamic instructions section for Composio integrations.
* Returns empty string if no tools are enabled. * Lists connected toolkits and explains the meta-tool discovery flow.
*/ */
async function getComposioToolsPrompt(): Promise<string> { async function getComposioToolsPrompt(): Promise<string> {
if (!(await isComposioConfigured())) return ''; if (!(await isComposioConfigured())) return '';
const enabledTools = composioEnabledToolsRepo.getAll(); const connectedToolkits = composioAccountsRepo.getConnectedToolkits();
const toolEntries = Object.values(enabledTools);
if (toolEntries.length === 0) return '';
// Group tools by toolkit let prompt = `\n## Composio Integrations\n\n`;
const byToolkit: Record<string, Array<{ slug: string; name: string; description: string }>> = {}; prompt += `You can connect to external services (Gmail, Slack, GitHub, Notion, etc.) via Composio.\n\n`;
for (const tool of toolEntries) {
if (!byToolkit[tool.toolkitSlug]) { if (connectedToolkits.length > 0) {
byToolkit[tool.toolkitSlug] = []; const connectedNames = connectedToolkits
} .map(slug => CURATED_TOOLKITS.find(t => t.slug === slug)?.displayName ?? slug)
byToolkit[tool.toolkitSlug].push(tool); .join(', ');
prompt += `**Currently connected:** ${connectedNames}\n\n`;
} else {
prompt += `**No services connected yet.** Use \`composio-list-toolkits\` to show available integrations, or \`composio-connect-toolkit\` to help the user connect one.\n\n`;
} }
// Check which toolkits are connected prompt += `**CRITICAL: NEVER say "I can't access [service]" or "I don't have access to [service]" without FIRST trying Composio.** If a user asks about ANY third-party service (LinkedIn, Gmail, GitHub, Slack, etc.), your FIRST action must be to check \`composio-list-toolkits\` or try \`composio-connect-toolkit\`. Never give up before trying.\n\n`;
const connectedToolkits = new Set(composioAccountsRepo.getConnectedToolkits());
let prompt = `\n## Composio Integration Tools\n\n`; prompt += `**Discovery & Execution Flow:**\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 += `1. When the user asks to interact with a service (e.g., "get my LinkedIn profile", "check my email", "list GitHub issues"):\n`;
prompt += `**How to use Composio tools:**\n`; prompt += ` a. Check if the service is connected (via \`composio-list-toolkits\` or the connected list above)\n`;
prompt += `- Each tool is named \`composio-{ACTION_SLUG}\` (e.g., \`composio-GMAIL_SEND_EMAIL\`)\n`; prompt += ` b. If NOT connected, call \`composio-connect-toolkit\` immediately — do NOT ask for confirmation or tell the user you can't do it\n`;
prompt += `- Call them like any other builtin tool — pass the required parameters and they execute via the connected account\n`; prompt += ` c. If connected, proceed to search and execute\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 += `2. Use \`composio-search-tools\` with SHORT keyword queries (e.g., "list issues", "send email", "get profile") — avoid long sentences.\n`;
prompt += `- Always confirm with the user before taking actions that send messages, create items, or modify data in external services\n\n`; prompt += `3. Read the \`inputSchema\` from search results carefully — note which fields are in \`required\`.\n`;
prompt += `4. Call \`composio-execute-tool\` with the tool slug, toolkit slug, AND all required \`arguments\`. For tools with empty \`properties: {}\`, pass \`arguments: {}\`.\n`;
for (const [toolkitSlug, tools] of Object.entries(byToolkit)) { prompt += `**Example — fetching GitHub issues for owner/repo:**\n`;
const isConnected = connectedToolkits.has(toolkitSlug); prompt += `1. \`composio-search-tools({ query: "list issues", toolkitSlug: "github" })\` → finds \`GITHUB_ISSUES_LIST_FOR_REPO\`\n`;
const statusBadge = isConnected ? '(Connected)' : '(Not Connected)'; prompt += `2. Schema shows required: \`["owner", "repo"]\` — extract from user's request (e.g., "rowboatlabs/rowboat" → owner: "rowboatlabs", repo: "rowboat")\n`;
prompt += `### ${toolkitSlug.charAt(0).toUpperCase() + toolkitSlug.slice(1)} ${statusBadge}\n`; prompt += `3. \`composio-execute-tool({ toolSlug: "GITHUB_ISSUES_LIST_FOR_REPO", toolkitSlug: "github", arguments: { owner: "rowboatlabs", repo: "rowboat", state: "open", per_page: 100 } })\`\n\n`;
for (const tool of tools) {
prompt += `- \`composio-${tool.slug}\`${tool.description}\n`; prompt += `**Important:**\n`;
} prompt += `- Use short keyword search queries, NOT full sentences (good: "list issues", bad: "get all open issues for a GitHub repository")\n`;
prompt += `\n`; prompt += `- ALWAYS pass required arguments to composio-execute-tool — read the inputSchema from search results\n`;
} prompt += `- **If a tool call fails (e.g., missing fields), fix the arguments and retry IMMEDIATELY — do NOT stop and narrate the error to the user. Just fix it and continue.**\n`;
prompt += `- **Multi-part requests:** When the user asks to "connect X and then do Y", complete BOTH parts in one turn. If part 1 (connect) is already done, proceed directly to part 2 (the actual task).\n`;
prompt += `- Confirm with the user before executing tools that send messages, create items, or modify data (NOT for read-only queries or connecting)\n`;
prompt += `- Connecting a toolkit is always safe — just do it when needed, don't ask permission\n`;
return prompt; return prompt;
} }
@ -71,7 +75,9 @@ You're an insightful, encouraging assistant who combines meticulous clarity with
## What Rowboat Is ## 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. 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 Composio tools for that instead.
**Live Email/Calendar/Service Queries:** When users ask to **read**, **fetch**, **check**, or **view** emails, calendar events, or any data from a connected service (e.g., "what's my latest email?", "check my inbox", "what meetings do I have today?"), use \`composio-search-tools\` and \`composio-execute-tool\` to query the connected service directly. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders — use Composio 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. **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.
@ -212,13 +218,23 @@ 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. - 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. - Avoid deeply nested bullets. If nesting beyond 2 levels, restructure.
## MCP Tool Discovery (CRITICAL) ## Tool Priority: Composio First, Then MCP
**ALWAYS check for MCP tools BEFORE saying you can't do something.** **When the user wants to interact with a third-party service (GitHub, Gmail, Slack, Notion, Jira, etc.):**
1. **FIRST** use the \`composio-*\` builtin tools — they are already authenticated and ready. Do NOT load the mcp-integration skill or draft-emails skill for service queries.
2. **ONLY** if the service is NOT available through Composio, fall back to MCP tools.
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. **Common Composio tasks (use composio-search-tools + composio-execute-tool):**
- "What's my latest email?" search "fetch emails" in gmail toolkit
- "Check my inbox" search "fetch emails" in gmail toolkit
- "What meetings do I have?" search "list events" in googlecalendar toolkit
- "Create a GitHub issue" search "create issue" in github toolkit
- "Send a Slack message" search "send message" in slack toolkit
**DO NOT** immediately respond with "I can't access the internet" or "I don't have that capability" without checking MCP tools first! **When the user wants capabilities that Composio does NOT cover** (web search, file scraping, audio generation, etc.):
- Check MCP tools using \`listMcpServers\` and \`listMcpTools\`. Load the "mcp-integration" skill for guidance.
**DO NOT** immediately respond with "I can't access the internet" or "I don't have that capability" without checking both Composio and MCP tools first!
## Execution Reminders ## Execution Reminders
- Explore existing files and structure before creating new assets. - Explore existing files and structure before creating new assets.
@ -258,7 +274,10 @@ ${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\`. - \`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.** - \`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. - \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations.
- **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. - \`composio-list-toolkits\` — List available integrations (Gmail, Slack, GitHub, etc.) and their connection status
- \`composio-search-tools\` — Search for tools by use case (e.g., "send email", "create issue"); returns tool slugs and input schemas
- \`composio-execute-tool\` — Execute a Composio tool by slug with parameters from search results
- \`composio-connect-toolkit\` — Connect a service (Gmail, Slack, GitHub, etc.) via OAuth directly from chat
**Prefer these tools whenever possible** they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`. **Prefer these tools whenever possible** they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`.
@ -306,8 +325,7 @@ let cachedInstructions: string | null = null;
/** /**
* Invalidate the cached instructions so the next buildCopilotInstructions() call * Invalidate the cached instructions so the next buildCopilotInstructions() call
* regenerates the Composio tools section. Call this after enabling/disabling tools * regenerates the Composio section. Call this after connecting/disconnecting a toolkit.
* or connecting/disconnecting a toolkit.
*/ */
export function invalidateCopilotInstructionsCache(): void { export function invalidateCopilotInstructionsCache(): void {
cachedInstructions = null; cachedInstructions = null;

View file

@ -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. **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 1. **First check**: Call \`listMcpServers\` to see what's available
2. **Then list tools**: Call \`listMcpTools\` on relevant servers 2. **Then list tools**: Call \`listMcpTools\` on relevant servers
@ -23,9 +27,7 @@ export const skill = String.raw`
| "Read/write files" | filesystem | \`read_file\`, \`write_file\` | | "Read/write files" | filesystem | \`read_file\`, \`write_file\` |
| "Get current time/date" | time | \`get_current_time\` | | "Get current time/date" | time | \`get_current_time\` |
| "Make HTTP request" | fetch | \`fetch\`, \`post\` | | "Make HTTP request" | fetch | \`fetch\`, \`post\` |
| "GitHub operations" | github | \`create_issue\`, \`search_repos\` |
| "Generate audio/speech" | elevenLabs | \`text_to_speech\` | | "Generate audio/speech" | elevenLabs | \`text_to_speech\` |
| "Tweet/social media" | twitter, composio | Various social tools |
## Key concepts ## Key concepts
- MCP servers expose tools (web scraping, APIs, databases, etc.) declared in \`config/mcp.json\`. - MCP servers expose tools (web scraping, APIs, databases, etc.) declared in \`config/mcp.json\`.

View file

@ -13,11 +13,11 @@ import * as workspace from "../../workspace/workspace.js";
import { IAgentsRepo } from "../../agents/repo.js"; import { IAgentsRepo } from "../../agents/repo.js";
import { WorkDir } from "../../config/config.js"; import { WorkDir } from "../../config/config.js";
import { composioAccountsRepo } from "../../composio/repo.js"; import { composioAccountsRepo } from "../../composio/repo.js";
import { composioEnabledToolsRepo } from "../../composio/enabled-tools-repo.js"; import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, searchTools as searchComposioTools } from "../../composio/client.js";
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured } from "../../composio/client.js"; import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "../../composio/curated-toolkits.js";
import { invalidateCopilotInstructionsCache } from "../assistant/instructions.js"; import { getConnectionInitiator } from "../../composio/connection-bridge.js";
import type { ToolContext } from "./exec-tool.js"; import type { ToolContext } from "./exec-tool.js";
import { generateText, jsonSchema } from "ai"; import { generateText } from "ai";
import { createProvider } from "../../models/models.js"; import { createProvider } from "../../models/models.js";
import { IModelConfigRepo } from "../../models/repo.js"; import { IModelConfigRepo } from "../../models/repo.js";
import { isSignedIn } from "../../account/account.js"; import { isSignedIn } from "../../account/account.js";
@ -1177,93 +1177,174 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}; };
}, },
}, },
// ========================================================================
// 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': {
// Dynamic Composio Tool Registration 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);
const COMPOSIO_TOOL_PREFIX = 'composio-'; // 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
* Unregister all dynamically registered Composio tools const tools = filtered.map(t => ({
*/ slug: t.slug,
function unregisterComposioTools(): void { name: t.name,
for (const key of Object.keys(BuiltinTools)) { description: t.description,
if (key.startsWith(COMPOSIO_TOOL_PREFIX)) { toolkitSlug: t.toolkitSlug,
delete BuiltinTools[key]; 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': {
* Register enabled Composio tools as builtin tools. 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 } })',
* Each enabled tool gets a generic execute function that routes inputSchema: z.object({
* to the Composio API via the connected account. 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")'),
function registerComposioTools(): void { 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.'),
const enabledTools = composioEnabledToolsRepo.getAll(); }),
execute: async ({ toolSlug, toolkitSlug, arguments: args }: { toolSlug: string; toolkitSlug: string; arguments?: Record<string, unknown> }) => {
// Default arguments to {} if the LLM omits the field entirely
const toolArgs = args ?? {};
for (const [slug, tool] of Object.entries(enabledTools)) { // Check connection
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<string, unknown>) => {
const account = composioAccountsRepo.getAccount(toolkitSlug); const account = composioAccountsRepo.getAccount(toolkitSlug);
if (!account || account.status !== 'ACTIVE') { if (!account || account.status !== 'ACTIVE') {
return { return {
success: false, successful: false,
error: `Toolkit "${toolkitSlug}" is not connected. Please connect it in Settings > Tools Library.`, data: null,
error: `Toolkit "${toolkitSlug}" is not connected. Use composio-connect-toolkit to help the user connect it first.`,
}; };
} }
try { try {
return await executeComposioAction(slug, { return await executeComposioAction(toolSlug, {
connected_account_id: account.id, connected_account_id: account.id,
user_id: 'rowboat-user', user_id: 'rowboat-user',
version: 'latest', version: 'latest',
arguments: input, arguments: toolArgs,
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
console.error(`[Composio] Tool execution failed for ${slug}:`, message); 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. Opens the user\'s browser for authentication. After authenticating, the user can use tools from that service.',
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 { return {
success: false, success: false,
error: `Failed to execute ${slug}: ${message}`, error: `Unknown toolkit "${toolkitSlug}". Available toolkits: ${available}`,
};
}
},
isAvailable: async () => {
return (await isComposioConfigured()) && composioAccountsRepo.isConnected(toolkitSlug);
},
}; };
} }
const count = Object.keys(enabledTools).length; // Check if already connected
if (count > 0) { if (composioAccountsRepo.isConnected(toolkitSlug)) {
console.log(`[Composio] Registered ${count} dynamic tool(s)`); return {
} success: true,
message: `${toolkitSlug} is already connected. You can search for and execute its tools.`,
alreadyConnected: true,
};
} }
/** // Use the connection bridge to trigger OAuth
* Refresh dynamic Composio tools by unregistering all and re-registering from the repo. const initiator = getConnectionInitiator();
* Called after enabling/disabling tools or disconnecting a toolkit. if (!initiator) {
* Also invalidates the cached agent instructions so they reflect the new tool set. return {
*/ success: false,
export function refreshComposioTools(): void { error: 'Connection system not available. Please try connecting via Settings > Tools Library instead.',
unregisterComposioTools(); };
registerComposioTools();
invalidateCopilotInstructionsCache();
} }
// Register on module load try {
refreshComposioTools(); const result = await initiator(toolkitSlug);
if (result.success) {
const toolkit = CURATED_TOOLKITS.find(t => t.slug === toolkitSlug);
return {
success: true,
message: `Opening browser to authenticate with ${toolkit?.displayName ?? toolkitSlug}. Please complete the authentication in your browser, then let me know when you're done.`,
};
}
return {
success: false,
error: result.error || 'Failed to initiate connection',
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
success: false,
error: `Connection failed: ${message}`,
};
}
},
isAvailable: async () => isComposioConfigured(),
},
};

View file

@ -291,6 +291,79 @@ export async function deleteConnectedAccount(connectedAccountId: string): Promis
}); });
} }
/**
* Schema for search results: includes toolkit info and full input_parameters.
*/
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(),
}).optional(),
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();
/**
* Infer toolkit slug from a tool slug.
* Tool naming convention: TOOLKIT_ACTION (e.g., GITHUB_CREATE_ISSUE github).
* For multi-word toolkit slugs (GOOGLECALENDAR_CREATE_EVENT), the prefix before
* the first underscore matches the toolkit slug.
*/
function inferToolkitSlug(toolSlug: string): string {
// Tool slugs are uppercase: GITHUB_CREATE_ISSUE, GMAIL_SEND_EMAIL, etc.
// The toolkit prefix is everything before the first action word.
// Strategy: lowercase the slug and try progressively shorter prefixes.
const lower = toolSlug.toLowerCase();
const parts = lower.split('_');
// Try joining from 1 part up to all-but-1 to find a known prefix
// Most common: single-word prefix (github, gmail, slack)
return parts[0] ?? lower;
}
/**
* 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.
*/
export async function searchTools(
searchQuery: string,
toolkitSlugs?: string[],
): Promise<{ items: Array<{ slug: string; name: string; description: string; toolkitSlug: string; inputParameters: { type: 'object'; properties: Record<string, unknown>; required?: string[] } }> }> {
const params: Record<string, string> = {
search: searchQuery,
limit: '15',
};
if (toolkitSlugs && toolkitSlugs.length === 1) {
params.toolkit_slug = toolkitSlugs[0];
}
const result = await composioApiCall(ZListResponse(ZSearchResultTool), "/tools", params);
const items = result.items.map((item) => ({
slug: item.slug,
name: item.name,
description: item.description,
// Use toolkit.slug from API response, fall back to inferring from tool slug,
// and finally fall back to the requested toolkit slug if one was provided.
toolkitSlug: item.toolkit?.slug
|| inferToolkitSlug(item.slug)
|| (toolkitSlugs?.length === 1 ? toolkitSlugs[0] : ''),
inputParameters: {
type: 'object' as const,
properties: item.input_parameters?.properties ?? {},
required: item.input_parameters?.required,
},
}));
return { items };
}
/** /**
* List available tools for a toolkit * List available tools for a toolkit
*/ */
@ -308,54 +381,6 @@ export async function listToolkitTools(
return composioApiCall(ZListResponse(ZTool), "/tools", params); return composioApiCall(ZListResponse(ZTool), "/tools", params);
} }
/**
* Schema for the detailed tools response (preserves input_parameters).
* Uses passthrough so extra API fields don't cause validation failures.
*/
const ZDetailedTool = z.object({
slug: z.string(),
name: z.string(),
description: 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();
/**
* List available tools for a toolkit with full details including input_parameters.
* Uses composioApiCall for consistent error handling, logging, and validation.
*/
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<string, unknown>; required?: string[] } }> }> {
const params: Record<string, string> = {
toolkit_slug: toolkitSlug,
limit: '200',
};
if (searchQuery) {
params.search = searchQuery;
}
const result = await composioApiCall(ZListResponse(ZDetailedTool), "/tools", params);
return {
items: result.items.map((item) => ({
slug: item.slug,
name: item.name,
description: item.description,
toolkitSlug,
inputParameters: {
type: 'object' as const,
properties: item.input_parameters?.properties ?? {},
required: item.input_parameters?.required,
},
})),
};
}
/** /**
* Execute a tool action * Execute a tool action
*/ */

View file

@ -0,0 +1,33 @@
/**
* Connection bridge for Composio toolkit OAuth.
*
* Builtin tools run in the core package which cannot import Electron-specific
* code from the main process. This module provides a callback registry so the
* main process can register its `initiateConnection` function at startup, and
* builtin tools can call it at runtime.
*/
type ConnectionInitiator = (toolkitSlug: string) => Promise<{
success: boolean;
redirectUrl?: string;
connectedAccountId?: string;
error?: string;
}>;
let connectionInitiator: ConnectionInitiator | null = null;
/**
* Register the connection initiator callback.
* Called once by the main process at startup.
*/
export function setConnectionInitiator(fn: ConnectionInitiator): void {
connectionInitiator = fn;
}
/**
* Get the registered connection initiator.
* Returns null if not yet registered (app not fully initialized).
*/
export function getConnectionInitiator(): ConnectionInitiator | null {
return connectionInitiator;
}

View file

@ -0,0 +1,74 @@
/**
* Curated list of Composio toolkits available to Rowboat users.
* Only these toolkits are shown in the UI and discoverable via chat.
* Exact slugs match Composio API naming convention.
*
* Display names come from @x/shared/composio (single source of truth).
*/
import { COMPOSIO_DISPLAY_NAMES } from "@x/shared/dist/composio.js";
export { COMPOSIO_DISPLAY_NAMES } from "@x/shared/dist/composio.js";
export type ToolkitCategory = 'communication' | 'productivity' | 'development' | 'crm' | 'social' | 'storage' | 'support';
export interface CuratedToolkit {
slug: string;
displayName: string;
category: ToolkitCategory;
}
const toolkit = (slug: string, category: ToolkitCategory): CuratedToolkit => ({
slug,
displayName: COMPOSIO_DISPLAY_NAMES[slug] ?? slug,
category,
});
export const CURATED_TOOLKITS: CuratedToolkit[] = [
// Communication
toolkit('gmail', 'communication'),
toolkit('slack', 'communication'),
toolkit('microsoft_outlook', 'communication'),
toolkit('microsoft_teams', 'communication'),
// Productivity
toolkit('googlecalendar', 'productivity'),
toolkit('googledocs', 'productivity'),
toolkit('googlesheets', 'productivity'),
toolkit('notion', 'productivity'),
toolkit('airtable', 'productivity'),
toolkit('calendly', 'productivity'),
toolkit('cal', 'productivity'),
// Storage
toolkit('googledrive', 'storage'),
toolkit('dropbox', 'storage'),
toolkit('onedrive', 'storage'),
// Development
toolkit('github', 'development'),
toolkit('linear', 'development'),
toolkit('jira', 'development'),
// Project Management
toolkit('asana', 'productivity'),
toolkit('trello', 'productivity'),
// CRM & Sales
toolkit('hubspot', 'crm'),
toolkit('salesforce', 'crm'),
// Social
toolkit('linkedin', 'social'),
toolkit('twitter', 'social'),
toolkit('reddit', 'social'),
// Support
toolkit('intercom', 'support'),
toolkit('zendesk', 'support'),
];
/**
* Set of curated toolkit slugs for fast lookup.
*/
export const CURATED_TOOLKIT_SLUGS = new Set(CURATED_TOOLKITS.map(t => t.slug));

View file

@ -0,0 +1,32 @@
/**
* Composio display-name map: toolkit slug human-readable name.
* Single source of truth used by both core and renderer.
*/
export const COMPOSIO_DISPLAY_NAMES: Record<string, string> = {
gmail: 'Gmail',
slack: 'Slack',
microsoft_outlook: 'Microsoft Outlook',
microsoft_teams: 'Microsoft Teams',
googlecalendar: 'Google Calendar',
googledocs: 'Google Docs',
googlesheets: 'Google Sheets',
notion: 'Notion',
airtable: 'Airtable',
calendly: 'Calendly',
cal: 'Cal.com',
googledrive: 'Google Drive',
dropbox: 'Dropbox',
onedrive: 'OneDrive',
github: 'GitHub',
linear: 'Linear',
jira: 'Jira',
asana: 'Asana',
trello: 'Trello',
hubspot: 'HubSpot',
salesforce: 'Salesforce',
linkedin: 'LinkedIn',
twitter: 'X',
reddit: 'Reddit',
intercom: 'Intercom',
zendesk: 'Zendesk',
};

View file

@ -377,18 +377,6 @@ const ipcSchemas = {
toolkits: z.array(z.string()), 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': { 'composio:use-composio-for-google': {
req: z.null(), req: z.null(),
res: z.object({ res: z.object({
@ -432,58 +420,6 @@ const ipcSchemas = {
totalItems: z.number(), 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 channels
'agent-schedule:getConfig': { 'agent-schedule:getConfig': {
req: z.null(), req: z.null(),