mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-30 19:06:23 +02:00
Enhance Composio OAuth flow management and improve tool handling
- Updated the activeFlows management to prevent concurrent OAuth flows for the same toolkit by using toolkitSlug as the key. - Implemented cleanup logic for existing flows, ensuring proper resource management by aborting and closing servers as needed. - Introduced a timeout mechanism for abandoned flows, enhancing reliability. - Refactored the Composio tools repository to use an in-memory cache for improved performance and added methods for persisting changes to disk. - Updated the detailed tools listing to use a consistent API call structure and improved input parameter handling. - Made connectionData in the response optional for better flexibility in handling connected accounts.
This commit is contained in:
parent
013f6bdf17
commit
8ea1500ab9
7 changed files with 126 additions and 67 deletions
|
|
@ -12,11 +12,13 @@ import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_
|
||||||
|
|
||||||
const REDIRECT_URI = 'http://localhost:8081/oauth/callback';
|
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<string, {
|
const activeFlows = new Map<string, {
|
||||||
toolkitSlug: string;
|
toolkitSlug: string;
|
||||||
connectedAccountId: string;
|
connectedAccountId: string;
|
||||||
authConfigId: string;
|
authConfigId: string;
|
||||||
|
server: import('http').Server;
|
||||||
|
timeout: NodeJS.Timeout;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -128,13 +130,14 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store flow state
|
// Abort any existing flow for this toolkit before starting a new one
|
||||||
const flowKey = `${toolkitSlug}-${Date.now()}`;
|
const existingFlow = activeFlows.get(toolkitSlug);
|
||||||
activeFlows.set(flowKey, {
|
if (existingFlow) {
|
||||||
toolkitSlug,
|
console.log(`[Composio] Aborting existing flow for ${toolkitSlug}`);
|
||||||
connectedAccountId,
|
clearTimeout(existingFlow.timeout);
|
||||||
authConfigId,
|
existingFlow.server.close();
|
||||||
});
|
activeFlows.delete(toolkitSlug);
|
||||||
|
}
|
||||||
|
|
||||||
// Save initial account state
|
// Save initial account state
|
||||||
const account: LocalConnectedAccount = {
|
const account: LocalConnectedAccount = {
|
||||||
|
|
@ -148,7 +151,7 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
|
||||||
composioAccountsRepo.saveAccount(account);
|
composioAccountsRepo.saveAccount(account);
|
||||||
|
|
||||||
// Set up callback server
|
// Set up callback server
|
||||||
let cleanupTimeout: NodeJS.Timeout;
|
const timeoutRef: { current: NodeJS.Timeout | null } = { current: null };
|
||||||
let callbackHandled = false;
|
let callbackHandled = false;
|
||||||
const { server } = await createAuthServer(8081, async () => {
|
const { server } = await createAuthServer(8081, async () => {
|
||||||
// Guard against duplicate callbacks (browser may send multiple requests)
|
// Guard against duplicate callbacks (browser may send multiple requests)
|
||||||
|
|
@ -182,17 +185,17 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
activeFlows.delete(flowKey);
|
activeFlows.delete(toolkitSlug);
|
||||||
server.close();
|
server.close();
|
||||||
clearTimeout(cleanupTimeout);
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Timeout for abandoned flows (5 minutes)
|
// Timeout for abandoned flows (5 minutes)
|
||||||
cleanupTimeout = setTimeout(() => {
|
const cleanupTimeout = setTimeout(() => {
|
||||||
if (activeFlows.has(flowKey)) {
|
if (activeFlows.has(toolkitSlug)) {
|
||||||
console.log(`[Composio] Cleaning up abandoned flow for ${toolkitSlug}`);
|
console.log(`[Composio] Cleaning up abandoned flow for ${toolkitSlug}`);
|
||||||
activeFlows.delete(flowKey);
|
activeFlows.delete(toolkitSlug);
|
||||||
server.close();
|
server.close();
|
||||||
emitComposioEvent({
|
emitComposioEvent({
|
||||||
toolkitSlug,
|
toolkitSlug,
|
||||||
|
|
@ -201,6 +204,16 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, 5 * 60 * 1000);
|
}, 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
|
// Open browser for OAuth
|
||||||
shell.openExternal(redirectUrl);
|
shell.openExternal(redirectUrl);
|
||||||
|
|
|
||||||
|
|
@ -962,6 +962,15 @@ function ToolsLibrarySettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
}
|
}
|
||||||
}, [expandedToolkit])
|
}, [expandedToolkit])
|
||||||
|
|
||||||
|
// Clean up pending search timer on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (toolSearchTimerRef.current) {
|
||||||
|
clearTimeout(toolSearchTimerRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Toggle toolkit expansion
|
// Toggle toolkit expansion
|
||||||
const handleToggleToolkit = (toolkitSlug: string) => {
|
const handleToggleToolkit = (toolkitSlug: string) => {
|
||||||
if (expandedToolkit === toolkitSlug) {
|
if (expandedToolkit === toolkitSlug) {
|
||||||
|
|
|
||||||
|
|
@ -299,12 +299,29 @@ This renders as an interactive card in the UI that the user can click to open th
|
||||||
|
|
||||||
Never output raw file paths in plain text when they could be wrapped in a filepath block — unless the file does not exist yet.`;
|
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 tools section. Call this after enabling/disabling tools
|
||||||
|
* or connecting/disconnecting a toolkit.
|
||||||
|
*/
|
||||||
|
export function invalidateCopilotInstructionsCache(): void {
|
||||||
|
cachedInstructions = null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build full copilot instructions with dynamic Composio tools section.
|
* Build full copilot instructions with dynamic Composio tools section.
|
||||||
* Called each time the agent is loaded to reflect currently enabled tools.
|
* Results are cached and reused until invalidated via invalidateCopilotInstructionsCache().
|
||||||
*/
|
*/
|
||||||
export async function buildCopilotInstructions(): Promise<string> {
|
export async function buildCopilotInstructions(): Promise<string> {
|
||||||
|
if (cachedInstructions !== null) return cachedInstructions;
|
||||||
const composioPrompt = await getComposioToolsPrompt();
|
const composioPrompt = await getComposioToolsPrompt();
|
||||||
if (!composioPrompt) return CopilotInstructions;
|
cachedInstructions = composioPrompt
|
||||||
return CopilotInstructions + '\n' + composioPrompt;
|
? CopilotInstructions + '\n' + composioPrompt
|
||||||
|
: CopilotInstructions;
|
||||||
|
return cachedInstructions;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ 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 { composioEnabledToolsRepo } from "../../composio/enabled-tools-repo.js";
|
||||||
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured } from "../../composio/client.js";
|
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured } from "../../composio/client.js";
|
||||||
|
import { invalidateCopilotInstructionsCache } from "../assistant/instructions.js";
|
||||||
import type { ToolContext } from "./exec-tool.js";
|
import type { ToolContext } from "./exec-tool.js";
|
||||||
import { generateText, jsonSchema } from "ai";
|
import { generateText, jsonSchema } from "ai";
|
||||||
import { createProvider } from "../../models/models.js";
|
import { createProvider } from "../../models/models.js";
|
||||||
|
|
@ -1256,10 +1257,12 @@ function registerComposioTools(): void {
|
||||||
/**
|
/**
|
||||||
* Refresh dynamic Composio tools by unregistering all and re-registering from the repo.
|
* Refresh dynamic Composio tools by unregistering all and re-registering from the repo.
|
||||||
* Called after enabling/disabling tools or disconnecting a toolkit.
|
* Called after enabling/disabling tools or disconnecting a toolkit.
|
||||||
|
* Also invalidates the cached agent instructions so they reflect the new tool set.
|
||||||
*/
|
*/
|
||||||
export function refreshComposioTools(): void {
|
export function refreshComposioTools(): void {
|
||||||
unregisterComposioTools();
|
unregisterComposioTools();
|
||||||
registerComposioTools();
|
registerComposioTools();
|
||||||
|
invalidateCopilotInstructionsCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register on module load
|
// Register on module load
|
||||||
|
|
|
||||||
|
|
@ -301,49 +301,50 @@ export async function listToolkitTools(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List available tools for a toolkit with full details including input_parameters
|
* 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(
|
export async function listToolkitToolsDetailed(
|
||||||
toolkitSlug: string,
|
toolkitSlug: string,
|
||||||
searchQuery: string | null = null,
|
searchQuery: string | null = null,
|
||||||
): Promise<{ items: Array<{ slug: string; name: string; description: string; toolkitSlug: string; inputParameters: { type: 'object'; properties: Record<string, unknown>; required?: string[] } }> }> {
|
): Promise<{ items: Array<{ slug: string; name: string; description: string; toolkitSlug: string; inputParameters: { type: 'object'; properties: Record<string, unknown>; required?: string[] } }> }> {
|
||||||
const authHeaders = await getAuthHeaders();
|
const params: Record<string, string> = {
|
||||||
const baseURL = await getBaseUrl();
|
toolkit_slug: toolkitSlug,
|
||||||
|
limit: '200',
|
||||||
const url = new URL(`${baseURL}/tools`);
|
};
|
||||||
url.searchParams.set('toolkit_slug', toolkitSlug);
|
|
||||||
url.searchParams.set('limit', '200');
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
url.searchParams.set('query', searchQuery);
|
params.search = searchQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Composio] Listing tools (detailed) for toolkit: ${toolkitSlug}`);
|
const result = await composioApiCall(ZListResponse(ZDetailedTool), "/tools", params);
|
||||||
|
|
||||||
const response = await fetch(url.toString(), {
|
|
||||||
headers: authHeaders,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to list tools: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json() as { items?: Array<Record<string, unknown>> };
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: (data.items || []).map((item) => {
|
items: result.items.map((item) => ({
|
||||||
const inputParams = item.input_parameters as Record<string, unknown> | undefined;
|
slug: item.slug,
|
||||||
return {
|
name: item.name,
|
||||||
slug: String(item.slug ?? ''),
|
description: item.description,
|
||||||
name: String(item.name ?? ''),
|
|
||||||
description: String(item.description ?? ''),
|
|
||||||
toolkitSlug,
|
toolkitSlug,
|
||||||
inputParameters: {
|
inputParameters: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: (inputParams?.properties as Record<string, unknown>) ?? {},
|
properties: item.input_parameters?.properties ?? {},
|
||||||
required: Array.isArray(inputParams?.required) ? inputParams.required as string[] : undefined,
|
required: item.input_parameters?.required,
|
||||||
},
|
},
|
||||||
};
|
})),
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ function ensureStorageDir(): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadStorage(): EnabledToolsStorage {
|
function loadStorageFromDisk(): EnabledToolsStorage {
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(ENABLED_TOOLS_FILE)) {
|
if (fs.existsSync(ENABLED_TOOLS_FILE)) {
|
||||||
const data = fs.readFileSync(ENABLED_TOOLS_FILE, 'utf-8');
|
const data = fs.readFileSync(ENABLED_TOOLS_FILE, 'utf-8');
|
||||||
|
|
@ -64,64 +64,80 @@ function loadStorage(): EnabledToolsStorage {
|
||||||
return { tools: {} };
|
return { tools: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveStorage(storage: EnabledToolsStorage): void {
|
function saveStorageToDisk(storage: EnabledToolsStorage): void {
|
||||||
ensureStorageDir();
|
ensureStorageDir();
|
||||||
fs.writeFileSync(ENABLED_TOOLS_FILE, JSON.stringify(storage, null, 2));
|
fs.writeFileSync(ENABLED_TOOLS_FILE, JSON.stringify(storage, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repository for managing enabled Composio tools
|
* Repository for managing enabled Composio tools.
|
||||||
|
* Uses an in-memory cache loaded once from disk. Mutations write through to disk.
|
||||||
*/
|
*/
|
||||||
export class ComposioEnabledToolsRepo implements IComposioEnabledToolsRepo {
|
export class ComposioEnabledToolsRepo implements IComposioEnabledToolsRepo {
|
||||||
|
private cache: EnabledToolsStorage | null = null;
|
||||||
|
|
||||||
|
private getStorage(): EnabledToolsStorage {
|
||||||
|
if (!this.cache) {
|
||||||
|
this.cache = loadStorageFromDisk();
|
||||||
|
}
|
||||||
|
return this.cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(): void {
|
||||||
|
if (this.cache) {
|
||||||
|
saveStorageToDisk(this.cache);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getAll(): Record<string, EnabledTool> {
|
getAll(): Record<string, EnabledTool> {
|
||||||
return loadStorage().tools;
|
return this.getStorage().tools;
|
||||||
}
|
}
|
||||||
|
|
||||||
getByToolkit(toolkitSlug: string): EnabledTool[] {
|
getByToolkit(toolkitSlug: string): EnabledTool[] {
|
||||||
const storage = loadStorage();
|
const storage = this.getStorage();
|
||||||
return Object.values(storage.tools).filter(t => t.toolkitSlug === toolkitSlug);
|
return Object.values(storage.tools).filter(t => t.toolkitSlug === toolkitSlug);
|
||||||
}
|
}
|
||||||
|
|
||||||
enable(tool: EnabledTool): void {
|
enable(tool: EnabledTool): void {
|
||||||
const storage = loadStorage();
|
const storage = this.getStorage();
|
||||||
storage.tools[tool.slug] = tool;
|
storage.tools[tool.slug] = tool;
|
||||||
saveStorage(storage);
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
enableBatch(tools: EnabledTool[]): void {
|
enableBatch(tools: EnabledTool[]): void {
|
||||||
const storage = loadStorage();
|
const storage = this.getStorage();
|
||||||
for (const tool of tools) {
|
for (const tool of tools) {
|
||||||
storage.tools[tool.slug] = tool;
|
storage.tools[tool.slug] = tool;
|
||||||
}
|
}
|
||||||
saveStorage(storage);
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
disable(toolSlug: string): void {
|
disable(toolSlug: string): void {
|
||||||
const storage = loadStorage();
|
const storage = this.getStorage();
|
||||||
delete storage.tools[toolSlug];
|
delete storage.tools[toolSlug];
|
||||||
saveStorage(storage);
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
disableBatch(toolSlugs: string[]): void {
|
disableBatch(toolSlugs: string[]): void {
|
||||||
const storage = loadStorage();
|
const storage = this.getStorage();
|
||||||
for (const slug of toolSlugs) {
|
for (const slug of toolSlugs) {
|
||||||
delete storage.tools[slug];
|
delete storage.tools[slug];
|
||||||
}
|
}
|
||||||
saveStorage(storage);
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
disableAllForToolkit(toolkitSlug: string): void {
|
disableAllForToolkit(toolkitSlug: string): void {
|
||||||
const storage = loadStorage();
|
const storage = this.getStorage();
|
||||||
for (const [slug, tool] of Object.entries(storage.tools)) {
|
for (const [slug, tool] of Object.entries(storage.tools)) {
|
||||||
if (tool.toolkitSlug === toolkitSlug) {
|
if (tool.toolkitSlug === toolkitSlug) {
|
||||||
delete storage.tools[slug];
|
delete storage.tools[slug];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
saveStorage(storage);
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
isEnabled(toolSlug: string): boolean {
|
isEnabled(toolSlug: string): boolean {
|
||||||
const storage = loadStorage();
|
const storage = this.getStorage();
|
||||||
return toolSlug in storage.tools;
|
return toolSlug in storage.tools;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,7 @@ export const ZCreateConnectedAccountRequest = z.object({
|
||||||
*/
|
*/
|
||||||
export const ZCreateConnectedAccountResponse = z.object({
|
export const ZCreateConnectedAccountResponse = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
connectionData: ZConnectionData,
|
connectionData: ZConnectionData.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue