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:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 15:48:19 -04:00
1 parent 7cd386a38c
commit d92bdbad90
8 files changed
+224 -97

No files matched your search

+11 -6
View File
@@ -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 {
+1 -1
View File
@@ -4,7 +4,7 @@ import type { ToolCall } from "./provider-tool-turn";
/** Tools that only look; everything else changes the company or the world and is journaled. */
const READ_ONLY_TOOLS = new Set([
"company_status", "fetch_url", "list_connectors", "list_documents", "list_files", "list_skills",
"company_status", "composio_find_actions", "fetch_url", "list_connectors", "list_documents", "list_files", "list_skills",
"read_attachment", "read_document", "read_file", "read_messages", "search_knowledge", "web_search",
]);
const JOURNAL_LINES = 40;
+1 -1
View File
@@ -207,7 +207,7 @@ export class TaskExecutionService {
const language = this.appLanguage();
// Baseline taken before any await, so a goal closed while tools load still reaches the session.
const goalChanges = createGoalChangePoller(this.db, task.goal_id);
const externalTools = await loadComposioTools(this.db, this.providers.integrationKeys, orgId, agent.id);
const externalTools = loadComposioTools(this.db, this.providers.integrationKeys, orgId, agent.id);
const result = await runAgentLoop(
{
providers: this.providers,
+1 -1
View File
@@ -128,7 +128,7 @@ function capabilityBriefing(input: AgentInstructionContext): string {
const lines: string[] = [];
if (input.connectorNames.length > 0) {
lines.push(
`Connected external services you carry in this run: ${input.connectorNames.join(", ")}. Their tools arrive with the composio_ prefix — use them directly whenever the work touches those platforms instead of describing what you would do.`,
`Connected external services you carry in this run: ${input.connectorNames.join(", ")}. Reach every action they offer with composio_find_actions (search in a few words) and composio_run_action (run the one you found) whenever the work touches those platforms, instead of describing what you would do.`,
);
}
lines.push(
+1 -1
View File
@@ -79,7 +79,7 @@ describe("agent instructions", () => {
language: "en",
});
expect(text).toContain("Connected external services you carry in this run: Stripe");
expect(text).toContain("composio_ prefix");
expect(text).toContain("composio_find_actions");
expect(text).toContain("list_connectors");
expect(text).toContain("list_skills");
expect(text).not.toContain("You run this company");
+2 -2
View File
@@ -31,7 +31,7 @@ function ceoHarness() {
describe("composio tool loading", () => {
test("an agent without connector access loads no tools and needs no key", async () => {
const { connection, orgId, ceoId } = ceoHarness();
await expect(loadComposioTools(connection.db, new IntegrationKeyStore(connection.db, plainVault), orgId, ceoId)).resolves.toEqual([]);
expect(loadComposioTools(connection.db, new IntegrationKeyStore(connection.db, plainVault), orgId, ceoId)).toEqual([]);
connection.close();
});
@@ -42,7 +42,7 @@ describe("composio tool loading", () => {
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at)
values ('ca_1', ?, 'gmail', 'Gmail', 'ac_1', 'ACTIVE', ?, ?)
`).run(orgId, now, now);
await expect(loadComposioTools(connection.db, new IntegrationKeyStore(connection.db, plainVault), orgId, ceoId)).rejects.toThrow("no Composio API key is saved");
expect(() => loadComposioTools(connection.db, new IntegrationKeyStore(connection.db, plainVault), orgId, ceoId)).toThrow("no Composio API key is saved");
connection.close();
});
});
+112
View File
@@ -0,0 +1,112 @@
import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test, vi } from "vitest";
const requested: string[] = [];
const written: string[] = [];
const pages = new Map<string, Record<string, unknown>>();
vi.mock("electron", () => ({
net: {
request: ({ url }: { url: string }) => {
const request = new EventEmitter() as EventEmitter & { setHeader: () => void; write: (body: string) => void; end: () => void; abort: () => void };
request.setHeader = () => undefined;
request.write = (body: string) => { written.push(`${url} ${body}`); };
request.abort = () => undefined;
request.end = () => {
requested.push(url);
const query = new URL(url).searchParams;
const page = url.includes("/tools/execute/") ? { data: { number: 42 }, successful: true } : pages.get(`${query.get("toolkit_slug")}:${query.get("cursor") ?? ""}`);
const response = new EventEmitter() as EventEmitter & { statusCode: number };
response.statusCode = page ? 200 : 404;
request.emit("response", response);
response.emit("data", Buffer.from(JSON.stringify(page ?? { error: "no such page" })));
response.emit("end");
};
return request;
},
},
}));
const { ComposioClient } = await import("../../src/main/services/composio/composio-client");
const { executeComposioTool, loadComposioTools } = await import("../../src/main/services/composio/composio-tool-bridge");
const { DatabaseConnection } = await import("../../src/main/database/connection");
const { OrganizationService } = await import("../../src/main/services/organization-service");
const { IntegrationKeyStore } = await import("../../src/main/services/integration-key-store");
const { ConnectorGrantsService } = await import("../../src/main/services/composio/connector-grants");
const { recordRunAction } = await import("../../src/main/services/run-journal");
const tool = (slug: string) => ({ slug, name: slug, description: "", input_parameters: {}, toolkit: { slug: "github" } });
describe("a toolkit's tools are all listed", () => {
test("every page of a toolkit's tools is read, not just the first twenty", async () => {
const first = Array.from({ length: 100 }, (_, index) => tool(`GITHUB_A${index}`));
pages.set("github:", { items: first, next_cursor: "page2", total_items: 101 });
pages.set("github:page2", { items: [tool("GITHUB_CREATE_PULL_REQUEST")], next_cursor: null, total_items: 101 });
pages.set("slack:", { items: [tool("SLACK_SEND_MESSAGE")], next_cursor: null, total_items: 1 });
const tools = await new ComposioClient("ak_test").getTools(["github", "slack"]);
expect(tools).toHaveLength(102);
expect(tools.map((schema) => schema.name)).toContain("GITHUB_CREATE_PULL_REQUEST");
expect(requested.map((url) => new URL(url).searchParams.get("limit"))).toEqual(["100", "100", "100"]);
});
});
describe("agents reach every action through search and run", () => {
const vault = { encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8") };
function company(apiKey = "ak_actions") {
const connection = new DatabaseConnection(join(tmpdir(), `ac-actions-${randomUUID()}.db`));
let orgId = "";
let ceoId = "";
connection.transaction(() => {
orgId = new OrganizationService(connection.db).createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" });
const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
ceoId = new OrganizationService(connection.db).createAgent({ orgId, name: "CEO", role: "CEO", department: "Executive", modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: null, roleLevel: "c_suite" });
const now = new Date().toISOString();
connection.db.prepare("insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at) values ('ca_gh', ?, 'github', 'GitHub', 'ac_gh', 'ACTIVE', ?, ?)").run(orgId, now, now);
});
const keys = new IntegrationKeyStore(connection.db, vault);
keys.write("composio", apiKey);
const call = (name: string, args: Record<string, unknown>) => executeComposioTool(connection.db, keys, orgId, ceoId, { id: "c1", name, arguments: args });
return { connection, keys, orgId, ceoId, call };
}
test("the agent carries a search and a runner, not a slice of the list", () => {
const h = company();
expect(loadComposioTools(h.connection.db, h.keys, h.orgId, h.ceoId).map((tool) => tool.name)).toEqual(["composio_find_actions", "composio_run_action"]);
h.connection.close();
});
test("an action past the first page is found with its input and runs on the connected account", async () => {
pages.set("github:", { items: Array.from({ length: 100 }, (_, index) => tool(`GITHUB_LIST_THING_${index}`)), next_cursor: "page2" });
pages.set("github:page2", { items: [{ ...tool("GITHUB_CREATE_A_PULL_REQUEST"), description: "Create a pull request", input_parameters: { properties: { title: { type: "string" } }, required: ["title"] } }], next_cursor: null });
const h = company();
const found = JSON.parse(await h.call("composio_find_actions", { query: "create pull request" })) as { matches: Array<{ action: string; input: unknown }>; searched: number };
expect(found.searched).toBe(101);
expect(found.matches[0]).toMatchObject({ action: "GITHUB_CREATE_A_PULL_REQUEST", input: { properties: { title: { type: "string" } }, required: ["title"] } });
expect(await h.call("composio_run_action", { action: "GITHUB_CREATE_A_PULL_REQUEST", arguments: { title: "Fix" } })).toBe(JSON.stringify({ data: { number: 42 }, successful: true }));
expect(written.at(-1)).toContain("/tools/execute/GITHUB_CREATE_A_PULL_REQUEST");
expect(written.at(-1)).toContain("\"connected_account_id\":\"ca_gh\"");
expect(await h.call("composio_run_action", { action: "SLACK_SEND_MESSAGE", arguments: {} })).toContain("is not an action of your connected services (github)");
h.connection.close();
});
test("a grant revoked while the catalog loads stops the action, and a search is not journaled as work", async () => {
pages.set("github:", { items: [tool("GITHUB_STAR_A_REPOSITORY")], next_cursor: null });
const h = company("ak_revoke");
const staff = h.connection.transaction(() => {
const providerId = (h.connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(h.orgId) as { id: string }).id;
const id = new OrganizationService(h.connection.db).createAgent({ orgId: h.orgId, name: "Ada", role: "Engineer", department: "Engineering", modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: h.ceoId });
new ConnectorGrantsService(h.connection.db).grant({ orgId: h.orgId, agentId: id, toolkitSlug: "github", grantedByType: "agent", grantedById: h.ceoId });
return id;
});
const running = executeComposioTool(h.connection.db, h.keys, h.orgId, staff, { id: "c2", name: "composio_run_action", arguments: { action: "GITHUB_STAR_A_REPOSITORY", arguments: {} } });
new ConnectorGrantsService(h.connection.db).revoke({ orgId: h.orgId, agentId: staff, toolkitSlug: "github" });
expect(await running).toContain('You have no grant for the "github" connector');
recordRunAction(h.connection.db, { orgId: h.orgId, runId: "run_x", taskId: "task_x", call: { id: "c3", name: "composio_find_actions", arguments: { query: "star" } }, output: "{}" });
expect(h.connection.db.prepare("select count(*) as n from run_actions").get()).toEqual({ n: 0 });
h.connection.close();
});
});