Integrate Composio for external tool access
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.
This commit is contained in:
1 parent
65c5bec9a3
commit
77f191fa25
12 files changed
+263
-10
No files matched your search
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AgentRunLoopResult> {
|
||||
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?.();
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComposioToolExecutionResult {
|
||||
data: Record<string, unknown>;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export class ComposioClient {
|
||||
constructor(private readonly apiKey: string) {}
|
||||
|
||||
async getTools(toolkits: string[]): Promise<ComposioToolSchema[]> {
|
||||
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<Record<string, unknown>>).map(normalizeToolSchema);
|
||||
}
|
||||
|
||||
async executeTool(input: {
|
||||
toolName: string;
|
||||
arguments: Record<string, unknown>;
|
||||
entityId: string;
|
||||
connectedAccountId?: string;
|
||||
}): Promise<ComposioToolExecutionResult> {
|
||||
const url = `${BASE_URL}/tools/${encodeURIComponent(input.toolName)}/execute`;
|
||||
const body: Record<string, unknown> = {
|
||||
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<string, unknown> : {},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
async getConnectedAccounts(entityId: string): Promise<Array<{ id: string; appName: string; status: string }>> {
|
||||
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<Record<string, unknown>>).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<string, unknown> = {
|
||||
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<string, unknown>): Promise<Record<string, unknown>> {
|
||||
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<string, unknown>);
|
||||
} 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<string, unknown>): ComposioToolSchema {
|
||||
const params = raw.parameters ?? raw.inputSchema ?? raw.input_schema ?? {};
|
||||
const schema = typeof params === "object" && params !== null ? params as Record<string, unknown> : {};
|
||||
return {
|
||||
name: String(raw.name ?? raw.slug ?? ""),
|
||||
description: String(raw.description ?? raw.display_name ?? ""),
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: (schema.properties ?? {}) as Record<string, unknown>,
|
||||
required: Array.isArray(schema.required) ? schema.required as string[] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<ToolDefinition[]> {
|
||||
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<string> {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface SaveComposioSettingsInput {
|
||||
orgId: string;
|
||||
apiKey: string;
|
||||
toolkits: string[];
|
||||
}
|
||||
@@ -7,3 +7,4 @@ export * from "./files";
|
||||
export * from "./governance";
|
||||
export * from "./collaboration";
|
||||
export * from "./onboarding";
|
||||
export * from "./composio";
|
||||
@@ -63,6 +63,7 @@ import type {
|
||||
RunTaskInput,
|
||||
RevokeProviderCredentialInput,
|
||||
SaveProviderCredentialInput,
|
||||
SaveComposioSettingsInput,
|
||||
SearchInput,
|
||||
SendMessageInput,
|
||||
SetActiveOrganizationInput,
|
||||
@@ -172,6 +173,7 @@ export interface DesktopApi {
|
||||
setLegalHoldState(input: SetLegalHoldStateInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createRetentionFreeze(input: CreateRetentionFreezeInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
setRetentionFreezeState(input: SetRetentionFreezeStateInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
saveComposioSettings(input: SaveComposioSettingsInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
search(input: SearchInput): Promise<DesktopResult<SearchResultRecord[]>>;
|
||||
subscribe(callback: (event: RendererEvent) => void): () => void;
|
||||
platform: "win32" | "darwin" | "linux";
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user