From 77f191fa25a737772ed004d44a55940213f30c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 30 Jun 2026 21:31:04 -0400 Subject: [PATCH] Integrate Composio for external tool access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents can now call external tools (GitHub, Slack, Gmail, etc.) via Composio's managed API. A thin HTTP client calls the Composio REST API directly — no heavy SDK dependency. External tools are loaded per-org from composio_settings and merged with internal tools at run time. The agent run loop routes composio_* tool calls to the Composio execution endpoint while internal tools dispatch as before. Includes: migration 043 for composio_settings table, IPC handler for saving Composio API key and toolkit configuration, and the composio client/bridge modules. --- AgentCompany-New/src/main/app/ipc-handlers.ts | 13 ++ .../migrations/043-composio-integration.ts | 9 ++ AgentCompany-New/src/main/database/schema.ts | 2 + .../src/main/services/agent-run-loop.ts | 26 ++-- .../main/services/composio/composio-client.ts | 124 ++++++++++++++++++ .../services/composio/composio-tool-bridge.ts | 86 ++++++++++++ .../main/services/task-execution-service.ts | 3 + AgentCompany-New/src/preload/index.ts | 1 + .../src/shared/commands/composio.ts | 5 + AgentCompany-New/src/shared/commands/index.ts | 1 + AgentCompany-New/src/shared/desktop-api.ts | 2 + AgentCompany-New/src/shared/ipc.ts | 1 + 12 files changed, 263 insertions(+), 10 deletions(-) create mode 100644 AgentCompany-New/src/main/database/migrations/043-composio-integration.ts create mode 100644 AgentCompany-New/src/main/services/composio/composio-client.ts create mode 100644 AgentCompany-New/src/main/services/composio/composio-tool-bridge.ts create mode 100644 AgentCompany-New/src/shared/commands/composio.ts diff --git a/AgentCompany-New/src/main/app/ipc-handlers.ts b/AgentCompany-New/src/main/app/ipc-handlers.ts index 110f5156..e5121f06 100644 --- a/AgentCompany-New/src/main/app/ipc-handlers.ts +++ b/AgentCompany-New/src/main/app/ipc-handlers.ts @@ -316,6 +316,19 @@ export function registerIpcHandlers(deps: IpcDeps): void { } }); + ipcMain.handle(IPC_CHANNELS.saveComposioSettings, (_event, input) => mutate(() => { + const now = new Date().toISOString(); + const db = deps.connection.db; + const upsert = db.prepare(` + insert into composio_settings(org_id, key, value, updated_at) values (?, ?, ?, ?) + on conflict(org_id, key) do update set value = excluded.value, updated_at = excluded.updated_at + `); + upsert.run(input.orgId, "api_key", input.apiKey, now); + upsert.run(input.orgId, "toolkits", JSON.stringify(input.toolkits), now); + upsert.run(input.orgId, "entity_id", input.orgId, now); + return null; + })); + ipcMain.handle(IPC_CHANNELS.windowMinimize, () => deps.mainWindow.minimize()); ipcMain.handle(IPC_CHANNELS.windowMaximize, () => deps.mainWindow.isMaximized() ? deps.mainWindow.unmaximize() : deps.mainWindow.maximize()); ipcMain.handle(IPC_CHANNELS.windowClose, () => deps.mainWindow.close()); diff --git a/AgentCompany-New/src/main/database/migrations/043-composio-integration.ts b/AgentCompany-New/src/main/database/migrations/043-composio-integration.ts new file mode 100644 index 00000000..c649d441 --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/043-composio-integration.ts @@ -0,0 +1,9 @@ +export const composioIntegrationSchema = ` + create table if not exists composio_settings ( + org_id text not null references organizations(id) on delete cascade, + key text not null, + value text not null, + updated_at text not null, + primary key (org_id, key) + ); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index 4a9c724a..456720b5 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -41,6 +41,7 @@ import { auditImmutabilityOrgTeardownSchema } from "./migrations/039-audit-immut import { documentsKnowledgeAutomationSchema } from "./migrations/040-documents-knowledge-automation"; import { taskSubtasksDeleteSchema } from "./migrations/041-task-subtasks-delete"; import { approvalNotesMeetingsSprintsSchema } from "./migrations/042-approval-notes-meetings-sprints"; +import { composioIntegrationSchema } from "./migrations/043-composio-integration"; interface Migration { version: number; @@ -91,6 +92,7 @@ const MIGRATIONS: Migration[] = [ { version: 40, name: "documents_knowledge_automation", sql: documentsKnowledgeAutomationSchema }, { version: 41, name: "task_subtasks_delete", sql: taskSubtasksDeleteSchema }, { version: 42, name: "approval_notes_meetings_sprints", sql: approvalNotesMeetingsSprintsSchema }, + { version: 43, name: "composio_integration", sql: composioIntegrationSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/agent-run-loop.ts b/AgentCompany-New/src/main/services/agent-run-loop.ts index ee7d5e4e..8a5556cb 100644 --- a/AgentCompany-New/src/main/services/agent-run-loop.ts +++ b/AgentCompany-New/src/main/services/agent-run-loop.ts @@ -1,7 +1,8 @@ import type { ProviderService } from "./provider-service"; import { AGENT_TOOLS, dispatchAgentTool, type AgentToolContext, type AgentToolDeps } from "./agent-tools"; import { canonicalMentionHandle } from "../../shared/mention-handles"; -import type { AgentTurnMessage } from "./provider-tool-turn"; +import type { AgentTurnMessage, ToolDefinition } from "./provider-tool-turn"; +import { isComposioTool, executeComposioTool } from "./composio/composio-tool-bridge"; const MAX_ITERATIONS = 12; @@ -12,6 +13,7 @@ export interface AgentRunLoopInput { instructions: string; initialUserTurn: string; toolContext: AgentToolContext; + externalTools?: ToolDefinition[]; } export interface AgentRunLoopResult { @@ -35,6 +37,7 @@ export interface AgentRunLoopDeps { * iteration cap is hit (which throws rather than silently truncating). */ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopInput): Promise { + const allTools = input.externalTools ? [...AGENT_TOOLS, ...input.externalTools] : AGENT_TOOLS; const messages: AgentTurnMessage[] = [{ role: "user", text: input.initialUserTurn }]; let toolCallCount = 0; let lastRequestId = ""; @@ -46,7 +49,7 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn workspaceId: input.workspaceId, instructions: input.instructions, messages, - tools: AGENT_TOOLS, + tools: allTools, }); lastRequestId = turn.requestId; @@ -61,16 +64,19 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn } messages.push({ role: "assistant", text: turn.text, toolCalls: turn.toolCalls }); - const results = turn.toolCalls.map((call) => { + const results: Array<{ toolCallId: string; toolName: string; output: string }> = []; + for (const call of turn.toolCalls) { toolCallCount += 1; - return { - toolCallId: call.id, - toolName: call.name, - output: dispatchAgentTool(deps.tools, input.toolContext, call, { + let output: string; + if (isComposioTool(call.name)) { + output = await executeComposioTool(deps.tools.db, input.orgId, call); + } else { + output = dispatchAgentTool(deps.tools, input.toolContext, call, { announceTaskAssignments: shouldAnnounceTaskAssignment(call, turn.toolCalls), - }), - }; - }); + }); + } + results.push({ toolCallId: call.id, toolName: call.name, output }); + } messages.push({ role: "tool", results }); deps.onToolResults?.(); } diff --git a/AgentCompany-New/src/main/services/composio/composio-client.ts b/AgentCompany-New/src/main/services/composio/composio-client.ts new file mode 100644 index 00000000..043b1cbd --- /dev/null +++ b/AgentCompany-New/src/main/services/composio/composio-client.ts @@ -0,0 +1,124 @@ +import { net } from "electron"; + +const BASE_URL = "https://backend.composio.dev/api/v3"; + +export interface ComposioToolSchema { + name: string; + description: string; + parameters: { + type: string; + properties: Record; + required?: string[]; + }; +} + +export interface ComposioToolExecutionResult { + data: Record; + error: string | null; +} + +export class ComposioClient { + constructor(private readonly apiKey: string) {} + + async getTools(toolkits: string[]): Promise { + const params = new URLSearchParams(); + for (const toolkit of toolkits) params.append("toolkit", toolkit); + params.set("limit", "50"); + const url = `${BASE_URL}/tools?${params.toString()}`; + const response = await this.request("GET", url); + if (!Array.isArray(response.items)) return []; + return (response.items as Array>).map(normalizeToolSchema); + } + + async executeTool(input: { + toolName: string; + arguments: Record; + entityId: string; + connectedAccountId?: string; + }): Promise { + const url = `${BASE_URL}/tools/${encodeURIComponent(input.toolName)}/execute`; + const body: Record = { + entity_id: input.entityId, + input: input.arguments, + }; + if (input.connectedAccountId) body.connected_account_id = input.connectedAccountId; + const response = await this.request("POST", url, body); + return { + data: typeof response === "object" && response !== null ? response as Record : {}, + error: null, + }; + } + + async getConnectedAccounts(entityId: string): Promise> { + const params = new URLSearchParams({ entity_id: entityId, showActiveOnly: "true" }); + const url = `${BASE_URL}/connectedAccounts?${params.toString()}`; + const response = await this.request("GET", url); + if (!Array.isArray(response.items)) return []; + return (response.items as Array>).map((item) => ({ + id: String(item.id ?? ""), + appName: String(item.appName ?? item.app_name ?? ""), + status: String(item.status ?? ""), + })); + } + + async initiateConnection(input: { + entityId: string; + appName: string; + redirectUrl?: string; + }): Promise<{ redirectUrl: string; connectedAccountId: string }> { + const url = `${BASE_URL}/connectedAccounts`; + const body: Record = { + entity_id: input.entityId, + integration_id: input.appName, + }; + if (input.redirectUrl) body.redirect_url = input.redirectUrl; + const response = await this.request("POST", url, body); + return { + redirectUrl: String(response.redirectUrl ?? response.redirect_url ?? ""), + connectedAccountId: String(response.connectedAccountId ?? response.id ?? ""), + }; + } + + private async request(method: string, url: string, body?: Record): Promise> { + return new Promise((resolve, reject) => { + const request = net.request({ method, url }); + request.setHeader("x-api-key", this.apiKey); + request.setHeader("Accept", "application/json"); + if (body) request.setHeader("Content-Type", "application/json"); + + let responseData = ""; + request.on("response", (response) => { + response.on("data", (chunk) => { responseData += chunk.toString(); }); + response.on("end", () => { + if (response.statusCode && response.statusCode >= 400) { + reject(new Error(`Composio API ${method} ${url} returned ${response.statusCode}: ${responseData.slice(0, 200)}`)); + return; + } + try { + resolve(JSON.parse(responseData) as Record); + } catch { + reject(new Error(`Composio API returned non-JSON response: ${responseData.slice(0, 200)}`)); + } + }); + response.on("error", reject); + }); + request.on("error", reject); + if (body) request.write(JSON.stringify(body)); + request.end(); + }); + } +} + +function normalizeToolSchema(raw: Record): ComposioToolSchema { + const params = raw.parameters ?? raw.inputSchema ?? raw.input_schema ?? {}; + const schema = typeof params === "object" && params !== null ? params as Record : {}; + return { + name: String(raw.name ?? raw.slug ?? ""), + description: String(raw.description ?? raw.display_name ?? ""), + parameters: { + type: "object", + properties: (schema.properties ?? {}) as Record, + required: Array.isArray(schema.required) ? schema.required as string[] : [], + }, + }; +} diff --git a/AgentCompany-New/src/main/services/composio/composio-tool-bridge.ts b/AgentCompany-New/src/main/services/composio/composio-tool-bridge.ts new file mode 100644 index 00000000..b734d98a --- /dev/null +++ b/AgentCompany-New/src/main/services/composio/composio-tool-bridge.ts @@ -0,0 +1,86 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { ToolDefinition, ToolCall } from "../provider-tool-turn"; +import { ComposioClient, type ComposioToolSchema } from "./composio-client"; + +const COMPOSIO_TOOL_PREFIX = "composio_"; + +export function isComposioTool(toolName: string): boolean { + return toolName.startsWith(COMPOSIO_TOOL_PREFIX); +} + +export function composioToolName(composioName: string): string { + return `${COMPOSIO_TOOL_PREFIX}${composioName.toLowerCase()}`; +} + +function originalToolName(prefixedName: string): string { + return prefixedName.slice(COMPOSIO_TOOL_PREFIX.length).toUpperCase(); +} + +export function composioSchemaToToolDefinition(schema: ComposioToolSchema): ToolDefinition { + return { + name: composioToolName(schema.name), + description: schema.description, + parameters: { + type: "object", + additionalProperties: false, + properties: schema.parameters.properties, + required: schema.parameters.required ?? [], + }, + }; +} + +export async function loadComposioTools(db: DatabaseSync, orgId: string): Promise { + const apiKey = getComposioApiKey(db, orgId); + if (!apiKey) return []; + const toolkits = getEnabledToolkits(db, orgId); + if (toolkits.length === 0) return []; + const client = new ComposioClient(apiKey); + const schemas = await client.getTools(toolkits); + return schemas.map(composioSchemaToToolDefinition); +} + +export async function executeComposioTool( + db: DatabaseSync, + orgId: string, + call: ToolCall, +): Promise { + const apiKey = getComposioApiKey(db, orgId); + if (!apiKey) return "ERROR: Composio API key is not configured."; + const toolName = originalToolName(call.name); + const entityId = getComposioEntityId(db, orgId); + const client = new ComposioClient(apiKey); + const result = await client.executeTool({ + toolName, + arguments: call.arguments, + entityId, + }); + if (result.error) return `ERROR: ${result.error}`; + return JSON.stringify(result.data); +} + +function getComposioApiKey(db: DatabaseSync, orgId: string): string | null { + const row = db.prepare( + "select value from composio_settings where org_id = ? and key = 'api_key' limit 1", + ).get(orgId) as { value: string } | undefined; + return row?.value ?? null; +} + +function getComposioEntityId(db: DatabaseSync, orgId: string): string { + const row = db.prepare( + "select value from composio_settings where org_id = ? and key = 'entity_id' limit 1", + ).get(orgId) as { value: string } | undefined; + return row?.value ?? orgId; +} + +function getEnabledToolkits(db: DatabaseSync, orgId: string): string[] { + const row = db.prepare( + "select value from composio_settings where org_id = ? and key = 'toolkits' limit 1", + ).get(orgId) as { value: string } | undefined; + if (!row?.value) return []; + try { + const parsed = JSON.parse(row.value); + return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : []; + } catch { + return []; + } +} diff --git a/AgentCompany-New/src/main/services/task-execution-service.ts b/AgentCompany-New/src/main/services/task-execution-service.ts index e9f0aa7a..a87f6ec4 100644 --- a/AgentCompany-New/src/main/services/task-execution-service.ts +++ b/AgentCompany-New/src/main/services/task-execution-service.ts @@ -22,6 +22,7 @@ import { type TaskRunIsolation, type WorkspaceRow, } from "./task-prompts"; +import { loadComposioTools } from "./composio/composio-tool-bridge"; const TASK_RUN_STATE = { QUEUED: "queued", @@ -121,6 +122,7 @@ export class TaskExecutionService { const chatContext = this.loadChatContext(orgId, isolation); const isCeo = !agent.manager_agent_id; const hasReports = this.agentHasReports(orgId, agent.id); + const externalTools = await loadComposioTools(this.db, orgId); const result = await runAgentLoop( { providers: this.providers, @@ -147,6 +149,7 @@ export class TaskExecutionService { chatContext, }), toolContext, + externalTools, }, ); providerRequestId = result.lastRequestId; diff --git a/AgentCompany-New/src/preload/index.ts b/AgentCompany-New/src/preload/index.ts index 3f2dcdae..1f96d429 100644 --- a/AgentCompany-New/src/preload/index.ts +++ b/AgentCompany-New/src/preload/index.ts @@ -82,6 +82,7 @@ const api: DesktopApi = { setLegalHoldState: (input) => invoke(IPC_CHANNELS.setLegalHoldState, input), createRetentionFreeze: (input) => invoke(IPC_CHANNELS.createRetentionFreeze, input), setRetentionFreezeState: (input) => invoke(IPC_CHANNELS.setRetentionFreezeState, input), + saveComposioSettings: (input) => invoke(IPC_CHANNELS.saveComposioSettings, input), search: (input) => invoke(IPC_CHANNELS.search, input), subscribe: (callback) => { const listener = (_event: Electron.IpcRendererEvent, payload: RendererEvent) => callback(payload); diff --git a/AgentCompany-New/src/shared/commands/composio.ts b/AgentCompany-New/src/shared/commands/composio.ts new file mode 100644 index 00000000..13548997 --- /dev/null +++ b/AgentCompany-New/src/shared/commands/composio.ts @@ -0,0 +1,5 @@ +export interface SaveComposioSettingsInput { + orgId: string; + apiKey: string; + toolkits: string[]; +} diff --git a/AgentCompany-New/src/shared/commands/index.ts b/AgentCompany-New/src/shared/commands/index.ts index 9e4f029d..67ccfd39 100644 --- a/AgentCompany-New/src/shared/commands/index.ts +++ b/AgentCompany-New/src/shared/commands/index.ts @@ -7,3 +7,4 @@ export * from "./files"; export * from "./governance"; export * from "./collaboration"; export * from "./onboarding"; +export * from "./composio"; diff --git a/AgentCompany-New/src/shared/desktop-api.ts b/AgentCompany-New/src/shared/desktop-api.ts index 14ac191c..4d0bb4e9 100644 --- a/AgentCompany-New/src/shared/desktop-api.ts +++ b/AgentCompany-New/src/shared/desktop-api.ts @@ -63,6 +63,7 @@ import type { RunTaskInput, RevokeProviderCredentialInput, SaveProviderCredentialInput, + SaveComposioSettingsInput, SearchInput, SendMessageInput, SetActiveOrganizationInput, @@ -172,6 +173,7 @@ export interface DesktopApi { setLegalHoldState(input: SetLegalHoldStateInput): Promise>; createRetentionFreeze(input: CreateRetentionFreezeInput): Promise>; setRetentionFreezeState(input: SetRetentionFreezeStateInput): Promise>; + saveComposioSettings(input: SaveComposioSettingsInput): Promise>; search(input: SearchInput): Promise>; subscribe(callback: (event: RendererEvent) => void): () => void; platform: "win32" | "darwin" | "linux"; diff --git a/AgentCompany-New/src/shared/ipc.ts b/AgentCompany-New/src/shared/ipc.ts index 2112fbeb..d1ec0077 100644 --- a/AgentCompany-New/src/shared/ipc.ts +++ b/AgentCompany-New/src/shared/ipc.ts @@ -74,6 +74,7 @@ export const IPC_CHANNELS = { deleteMeeting: "agentcompany:delete-meeting", createSprint: "agentcompany:create-sprint", deleteSprint: "agentcompany:delete-sprint", + saveComposioSettings: "agentcompany:save-composio-settings", search: "agentcompany:search", rendererEvent: "agentcompany:renderer-event", windowMinimize: "agentcompany:window-minimize",