Q94: agents search and run every connector action instead of the first twenty
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7cd386a38c
commit
d92bdbad90
8 files changed
+224
-97
No files matched your search
@@ -63,15 +63,20 @@ export class ComposioClient {
|
||||
return configs;
|
||||
}
|
||||
|
||||
/** Every tool of each toolkit, page by page; an agent's actions must not depend on where a list happens to stop. */
|
||||
async getTools(toolkits: string[]): Promise<ComposioToolSchema[]> {
|
||||
const all: ComposioToolSchema[] = [];
|
||||
for (const toolkit of toolkits) {
|
||||
const params = new URLSearchParams({ toolkit_slug: toolkit, limit: "20" });
|
||||
const url = `${BASE_URL}/tools?${params.toString()}`;
|
||||
const response = await this.request("GET", url);
|
||||
if (Array.isArray(response.items)) {
|
||||
all.push(...(response.items as Array<Record<string, unknown>>).map(normalizeToolSchema));
|
||||
}
|
||||
let cursor: string | null = null;
|
||||
do {
|
||||
const params = new URLSearchParams({ toolkit_slug: toolkit, limit: "100" });
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
const response = await this.request("GET", `${BASE_URL}/tools?${params.toString()}`);
|
||||
const items = Array.isArray(response.items) ? response.items as Array<Record<string, unknown>> : [];
|
||||
all.push(...items.map(normalizeToolSchema));
|
||||
cursor = typeof response.next_cursor === "string" && response.next_cursor ? response.next_cursor : null;
|
||||
if (items.length === 0) break;
|
||||
} while (cursor);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
@@ -5,79 +5,53 @@ import type { IntegrationKeyStore } from "../integration-key-store";
|
||||
import { ConnectorGrantsService } from "./connector-grants";
|
||||
|
||||
const COMPOSIO_TOOL_PREFIX = "composio_";
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
export const FIND_ACTIONS_TOOL = "composio_find_actions";
|
||||
export const RUN_ACTION_TOOL = "composio_run_action";
|
||||
const CATALOG_TTL_MS = 5 * 60 * 1000;
|
||||
const MATCHES_SHOWN = 10;
|
||||
|
||||
interface CacheEntry {
|
||||
tools: ToolDefinition[];
|
||||
fetchedAt: number;
|
||||
toolkitsKey: string;
|
||||
}
|
||||
|
||||
const toolCache = new Map<string, CacheEntry>();
|
||||
/** tool slug (upper case) → toolkit slug, learned from the tools listing. */
|
||||
const toolkitByTool = new Map<string, string>();
|
||||
/** Every action of the granted toolkits, by key and toolkit set, refreshed after the TTL. */
|
||||
const catalogCache = new Map<string, { actions: ComposioToolSchema[]; fetchedAt: number }>();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/** Toolkit a tool belongs to: learned mapping first, slug prefix as contract. */
|
||||
function toolkitForTool(toolSlug: string): string {
|
||||
return toolkitByTool.get(toolSlug) ?? toolSlug.split("_")[0].toLowerCase();
|
||||
}
|
||||
|
||||
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 ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The connector tools one agent is allowed to carry into a run: the
|
||||
* intersection of the company's active connections and the toolkits the CEO
|
||||
* granted this agent.
|
||||
*/
|
||||
export async function loadComposioTools(db: DatabaseSync, keys: IntegrationKeyStore, orgId: string, agentId: string): Promise<ToolDefinition[]> {
|
||||
const grants = new ConnectorGrantsService(db);
|
||||
const toolkits = grants.toolkitsForAgent(orgId, agentId);
|
||||
/** An agent's connector tools: search every action of its granted toolkits, then run one; hundreds of actions would not fit as tools. */
|
||||
export function loadComposioTools(db: DatabaseSync, keys: IntegrationKeyStore, orgId: string, agentId: string): ToolDefinition[] {
|
||||
const toolkits = new ConnectorGrantsService(db).toolkitsForAgent(orgId, agentId);
|
||||
if (toolkits.length === 0) return [];
|
||||
const apiKey = keys.read("composio");
|
||||
if (!apiKey) {
|
||||
if (!keys.read("composio")) {
|
||||
throw new Error(`Agent ${agentId} holds connector grants (${toolkits.join(", ")}) but no Composio API key is saved in Settings > Integrations.`);
|
||||
}
|
||||
const toolkitsKey = toolkits.join(",");
|
||||
const cacheKey = `${apiKey}:${orgId}:${toolkitsKey}`;
|
||||
const cached = toolCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||
return cached.tools;
|
||||
}
|
||||
try {
|
||||
const client = new ComposioClient(apiKey);
|
||||
const schemas = await client.getTools(toolkits);
|
||||
for (const schema of schemas) {
|
||||
if (schema.toolkitSlug) toolkitByTool.set(schema.name.toUpperCase(), schema.toolkitSlug.toLowerCase());
|
||||
}
|
||||
const tools = schemas.map(composioSchemaToToolDefinition);
|
||||
toolCache.set(cacheKey, { tools, fetchedAt: Date.now(), toolkitsKey });
|
||||
return tools;
|
||||
} catch {
|
||||
return cached?.tools ?? [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
name: FIND_ACTIONS_TOOL,
|
||||
description: `Search every action of your connected services (${toolkits.join(", ")}). Returns the best matches with the exact input each takes; then call ${RUN_ACTION_TOOL}.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
query: { type: "string", description: "What you want to do, in a few words, e.g. \"create pull request\"." },
|
||||
toolkit: { type: "string", enum: toolkits, description: "Search one service only." },
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: RUN_ACTION_TOOL,
|
||||
description: `Run one action found with ${FIND_ACTIONS_TOOL}.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
action: { type: "string", description: "The action's name exactly as found, e.g. GITHUB_CREATE_A_PULL_REQUEST." },
|
||||
arguments: { type: "object", description: "The action's input, following the schema the search returned." },
|
||||
},
|
||||
required: ["action", "arguments"],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function executeComposioTool(
|
||||
@@ -89,32 +63,68 @@ export async function executeComposioTool(
|
||||
): Promise<string> {
|
||||
const apiKey = keys.read("composio");
|
||||
if (!apiKey) return "ERROR: No Composio API key is saved. Ask the board to add one in Settings > Integrations.";
|
||||
const toolSlug = originalToolName(call.name);
|
||||
const toolkit = toolkitForTool(toolSlug);
|
||||
const grants = new ConnectorGrantsService(db);
|
||||
const allowed = grants.toolkitsForAgent(orgId, agentId);
|
||||
if (!allowed.includes(toolkit)) {
|
||||
const toolkits = new ConnectorGrantsService(db).toolkitsForAgent(orgId, agentId);
|
||||
if (toolkits.length === 0) return "ERROR: You hold no connector grants. Ask the CEO to grant you one (grant_connector).";
|
||||
try {
|
||||
if (call.name === FIND_ACTIONS_TOOL) return await findActions(apiKey, toolkits, call.arguments);
|
||||
if (call.name === RUN_ACTION_TOOL) return await runAction(db, apiKey, orgId, agentId, toolkits, call.arguments);
|
||||
return `ERROR: ${call.name} is not a connector tool; use ${FIND_ACTIONS_TOOL} and ${RUN_ACTION_TOOL}.`;
|
||||
} catch (error) {
|
||||
return `ERROR: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function findActions(apiKey: string, toolkits: string[], args: Record<string, unknown>): Promise<string> {
|
||||
const query = typeof args.query === "string" ? args.query.trim().toLowerCase() : "";
|
||||
if (!query) return "ERROR: query is required: say in a few words what you want to do.";
|
||||
const toolkit = typeof args.toolkit === "string" ? args.toolkit.toLowerCase() : null;
|
||||
if (toolkit && !toolkits.includes(toolkit)) return `ERROR: You hold no grant for "${toolkit}". Your services: ${toolkits.join(", ")}.`;
|
||||
const actions = (await actionCatalog(apiKey, toolkits)).filter((action) => !toolkit || action.toolkitSlug?.toLowerCase() === toolkit);
|
||||
const words = query.split(/[^a-z0-9]+/).filter(Boolean);
|
||||
const scored = actions
|
||||
.map((action) => {
|
||||
const text = `${action.name} ${action.description}`.toLowerCase();
|
||||
return { action, score: words.filter((word) => text.includes(word)).length };
|
||||
})
|
||||
.filter((entry) => entry.score > 0)
|
||||
.sort((a, b) => b.score - a.score || a.action.name.length - b.action.name.length)
|
||||
.slice(0, MATCHES_SHOWN);
|
||||
if (scored.length === 0) return `No action of ${toolkit ?? toolkits.join(", ")} matches "${query}" among ${actions.length}. Try other words.`;
|
||||
return JSON.stringify({
|
||||
matches: scored.map(({ action }) => ({ action: action.name, toolkit: action.toolkitSlug, description: action.description, input: action.parameters })),
|
||||
searched: actions.length,
|
||||
});
|
||||
}
|
||||
|
||||
async function runAction(db: DatabaseSync, apiKey: string, orgId: string, agentId: string, toolkits: string[], args: Record<string, unknown>): Promise<string> {
|
||||
const name = typeof args.action === "string" ? args.action.trim().toUpperCase() : "";
|
||||
if (!name) return `ERROR: action is required; find one with ${FIND_ACTIONS_TOOL}.`;
|
||||
const action = (await actionCatalog(apiKey, toolkits)).find((candidate) => candidate.name.toUpperCase() === name);
|
||||
if (!action?.toolkitSlug) return `ERROR: ${name} is not an action of your connected services (${toolkits.join(", ")}); find one with ${FIND_ACTIONS_TOOL}.`;
|
||||
const toolkit = action.toolkitSlug.toLowerCase();
|
||||
// Grants are read again after the catalog load: one revoked meanwhile must stop the action.
|
||||
if (!new ConnectorGrantsService(db).toolkitsForAgent(orgId, agentId).includes(toolkit)) {
|
||||
return `ERROR: You have no grant for the "${toolkit}" connector. Ask the CEO to grant it to you (grant_connector).`;
|
||||
}
|
||||
const account = db.prepare(
|
||||
"select id from connector_accounts where org_id = ? and toolkit_slug = ? and status = 'ACTIVE'",
|
||||
).get(orgId, toolkit) as { id: string } | undefined;
|
||||
if (!account) {
|
||||
return `ERROR: The "${toolkit}" connector is no longer connected. Ask the board to reconnect it on the Connectors page.`;
|
||||
}
|
||||
const client = new ComposioClient(apiKey);
|
||||
try {
|
||||
const result = await client.executeTool({
|
||||
toolName: toolSlug,
|
||||
arguments: call.arguments,
|
||||
userId: getComposioUserId(db, orgId),
|
||||
connectedAccountId: account.id,
|
||||
});
|
||||
if (result.error) return `ERROR: ${result.error}`;
|
||||
return JSON.stringify(result.data);
|
||||
} catch (error) {
|
||||
return `ERROR: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
if (!account) return `ERROR: The "${toolkit}" connector is no longer connected. Ask the board to reconnect it on the Connectors page.`;
|
||||
const input = typeof args.arguments === "object" && args.arguments !== null && !Array.isArray(args.arguments) ? args.arguments as Record<string, unknown> : null;
|
||||
if (!input) return "ERROR: arguments must be an object matching the action's input.";
|
||||
const result = await new ComposioClient(apiKey).executeTool({ toolName: action.name, arguments: input, userId: getComposioUserId(db, orgId), connectedAccountId: account.id });
|
||||
if (result.error) return `ERROR: ${result.error}`;
|
||||
return JSON.stringify(result.data);
|
||||
}
|
||||
|
||||
/** The full action list of the granted toolkits, every page of it; a failed listing is an error the agent sees. */
|
||||
async function actionCatalog(apiKey: string, toolkits: string[]): Promise<ComposioToolSchema[]> {
|
||||
const cacheKey = `${apiKey}:${[...toolkits].sort().join(",")}`;
|
||||
const cached = catalogCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.fetchedAt < CATALOG_TTL_MS) return cached.actions;
|
||||
const actions = await new ComposioClient(apiKey).getTools(toolkits);
|
||||
catalogCache.set(cacheKey, { actions, fetchedAt: Date.now() });
|
||||
return actions;
|
||||
}
|
||||
|
||||
export function getComposioUserId(db: DatabaseSync, orgId: string): string {
|
||||
|
||||
Reference in new issue
Block a user