@frockbot/plugin-composio 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-composio",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -22,11 +22,11 @@
22
22
  "typecheck": "tsc --noEmit -p tsconfig.json"
23
23
  },
24
24
  "dependencies": {
25
- "@frockbot/configuration-core": "0.3.1",
26
- "@frockbot/connection-core": "0.3.1",
27
- "@frockbot/kernel-contracts": "0.3.1",
28
- "@frockbot/plugin-settings": "0.3.1",
29
- "@frockbot/plugin-tools": "0.3.1",
25
+ "@frockbot/configuration-core": "0.3.3",
26
+ "@frockbot/connection-core": "0.3.3",
27
+ "@frockbot/kernel-contracts": "0.3.3",
28
+ "@frockbot/plugin-settings": "0.3.3",
29
+ "@frockbot/plugin-tools": "0.3.3",
30
30
  "cordis": "4.0.0-rc.8"
31
31
  },
32
32
  "devDependencies": {
package/src/agent.test.ts CHANGED
@@ -2,10 +2,32 @@ import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { type SessionEvent, SessionStore } from "@frockbot/kernel-contracts";
3
3
  import { ToolRegistry } from "@frockbot/plugin-tools";
4
4
  import { Context } from "cordis";
5
- import { createComposioRouterPlugin } from "./agent.js";
5
+ import {
6
+ composioNamespaceV1,
7
+ createComposioPlugin,
8
+ createComposioRouterPlugin,
9
+ } from "./agent.js";
6
10
  import { ComposioClient } from "./composio-client.js";
7
11
 
8
12
  const roots: Context[] = [];
13
+ const namespace = composioNamespaceV1("gmail", "ca_123");
14
+
15
+ function dynamicCall(
16
+ id: string,
17
+ toolName: string,
18
+ arguments_: Record<string, unknown>,
19
+ ) {
20
+ return {
21
+ id,
22
+ name: "call_dynamic_tool",
23
+ input: {
24
+ namespace,
25
+ toolName,
26
+ arguments: arguments_,
27
+ mcpDetails: { description: `Use ${toolName} for this test.` },
28
+ },
29
+ };
30
+ }
9
31
 
10
32
  afterEach(async () => {
11
33
  await Promise.all(roots.splice(0).map((root) => root.fiber.dispose()));
@@ -65,11 +87,10 @@ describe("Composio router Plugin", () => {
65
87
  };
66
88
 
67
89
  const invented = await root.tools.prepare(
68
- {
69
- id: "invented",
70
- name: "composio_execute_tool",
71
- input: { toolSlug: "GMAIL_FETCH_EMAILS", arguments: {} },
72
- },
90
+ dynamicCall("invented", "composio_execute_tool", {
91
+ toolSlug: "GMAIL_FETCH_EMAILS",
92
+ arguments: {},
93
+ }),
73
94
  context,
74
95
  );
75
96
  if (invented.kind !== "ready") throw new Error("execute tool was denied");
@@ -79,11 +100,9 @@ describe("Composio router Plugin", () => {
79
100
  expect(calls).toEqual([]);
80
101
 
81
102
  const search = await root.tools.prepare(
82
- {
83
- id: "search",
84
- name: "composio_search_tools",
85
- input: { query: "fetch emails" },
86
- },
103
+ dynamicCall("search", "composio_search_tools", {
104
+ query: "fetch emails",
105
+ }),
87
106
  context,
88
107
  );
89
108
  if (search.kind !== "ready") throw new Error("search tool was denied");
@@ -92,11 +111,10 @@ describe("Composio router Plugin", () => {
92
111
  });
93
112
 
94
113
  const exact = await root.tools.prepare(
95
- {
96
- id: "exact",
97
- name: "composio_execute_tool",
98
- input: { toolSlug: "GMAIL_FETCH_EMAILS", arguments: {} },
99
- },
114
+ dynamicCall("exact", "composio_execute_tool", {
115
+ toolSlug: "GMAIL_FETCH_EMAILS",
116
+ arguments: {},
117
+ }),
100
118
  context,
101
119
  );
102
120
  if (exact.kind !== "ready") throw new Error("execute tool was denied");
@@ -104,7 +122,7 @@ describe("Composio router Plugin", () => {
104
122
  isError: false,
105
123
  });
106
124
  expect(calls).toHaveLength(2);
107
- expect(authorizationCalls).toBe(2);
125
+ expect(authorizationCalls).toBe(3);
108
126
  });
109
127
 
110
128
  test("restores searched slug authorization from the durable session", async () => {
@@ -123,8 +141,12 @@ describe("Composio router Plugin", () => {
123
141
  toolCalls: [
124
142
  {
125
143
  id: "search-before-eviction",
126
- name: "composio_search_tools",
127
- input: {},
144
+ name: "call_dynamic_tool",
145
+ input: dynamicCall(
146
+ "search-before-eviction",
147
+ "composio_search_tools",
148
+ {},
149
+ ).input,
128
150
  },
129
151
  ],
130
152
  },
@@ -133,15 +155,19 @@ describe("Composio router Plugin", () => {
133
155
  turn: 1,
134
156
  step: 1,
135
157
  occurrenceId: "tool:1:1:0",
136
- name: "composio_search_tools",
137
- input: {},
158
+ name: "call_dynamic_tool",
159
+ input: dynamicCall(
160
+ "search-before-eviction",
161
+ "composio_search_tools",
162
+ {},
163
+ ).input,
138
164
  },
139
165
  {
140
166
  type: "tool/result",
141
167
  turn: 1,
142
168
  step: 1,
143
169
  occurrenceId: "tool:1:1:0",
144
- name: "composio_search_tools",
170
+ name: "call_dynamic_tool",
145
171
  content: JSON.stringify([
146
172
  {
147
173
  slug: "GMAIL_FETCH_EMAILS",
@@ -190,11 +216,10 @@ describe("Composio router Plugin", () => {
190
216
  signal: new AbortController().signal,
191
217
  };
192
218
  const execution = await root.tools.prepare(
193
- {
194
- id: "execute-after-eviction",
195
- name: "composio_execute_tool",
196
- input: { toolSlug: "GMAIL_FETCH_EMAILS", arguments: {} },
197
- },
219
+ dynamicCall("execute-after-eviction", "composio_execute_tool", {
220
+ toolSlug: "GMAIL_FETCH_EMAILS",
221
+ arguments: {},
222
+ }),
198
223
  context,
199
224
  );
200
225
  if (execution.kind !== "ready") throw new Error("execute tool was denied");
@@ -204,4 +229,60 @@ describe("Composio router Plugin", () => {
204
229
  ).resolves.toMatchObject({ isError: false });
205
230
  expect(calls).toHaveLength(1);
206
231
  });
232
+
233
+ test("derives a stable GrokBot-style namespace from toolkit and account", () => {
234
+ expect(composioNamespaceV1("github", "acct_acme-42")).toBe(
235
+ "user-Github--acct-acme-42",
236
+ );
237
+ });
238
+
239
+ test("registers static per-tool integrations under the account namespace", async () => {
240
+ const root = new Context();
241
+ roots.push(root);
242
+ await root.plugin(ToolRegistry);
243
+ await root.plugin(
244
+ createComposioPlugin({
245
+ client: new ComposioClient({ apiKey: "secret" }),
246
+ userId: "user-1",
247
+ toolkitSlug: "github",
248
+ connectedAccountId: "acct_acme-42",
249
+ tools: [
250
+ {
251
+ slug: "GITHUB_SEARCH_ISSUES",
252
+ name: "search_issues",
253
+ description: "Search issues.",
254
+ inputSchema: { type: "object" },
255
+ },
256
+ ],
257
+ }),
258
+ );
259
+
260
+ expect(root.tools.registeredNames?.()).toContain(
261
+ "user-Github--acct-acme-42/search_issues",
262
+ );
263
+ const call = {
264
+ id: "static",
265
+ name: "call_dynamic_tool",
266
+ input: {
267
+ namespace: "user-Github--acct-acme-42",
268
+ toolName: "search_issues",
269
+ },
270
+ };
271
+ const preparation = await root.tools.prepare(call, {
272
+ botId: "primary",
273
+ agentId: "primary",
274
+ compositionGenerationId: "bootstrap",
275
+ turnType: "chat",
276
+ sessionId: "user-1:primary",
277
+ effectId: "tool:1:1:0",
278
+ signal: new AbortController().signal,
279
+ });
280
+ expect(preparation).toMatchObject({
281
+ kind: "denied",
282
+ result: {
283
+ content:
284
+ 'External namespace "user-Github--acct-acme-42" requires mcpDetails.description',
285
+ },
286
+ });
287
+ });
207
288
  });
package/src/agent.ts CHANGED
@@ -14,6 +14,7 @@ export interface ComposioPluginConfig {
14
14
  client: ComposioClient;
15
15
  userId: string;
16
16
  connectedAccountId: string;
17
+ toolkitSlug: string;
17
18
  tools: ComposioToolDeclaration[];
18
19
  }
19
20
 
@@ -21,6 +22,28 @@ function isObject(value: unknown): value is Record<string, unknown> {
21
22
  return typeof value === "object" && value !== null && !Array.isArray(value);
22
23
  }
23
24
 
25
+ function slugSegment(value: string): string {
26
+ return value
27
+ .trim()
28
+ .replace(/[^A-Za-z0-9]+/g, "-")
29
+ .replace(/^-+|-+$/g, "");
30
+ }
31
+
32
+ /** The stable, account-specific namespace exposed to the model. */
33
+ export function composioNamespaceV1(
34
+ toolkitSlug: string,
35
+ connectedAccountId: string,
36
+ ): string {
37
+ const toolkit = slugSegment(toolkitSlug);
38
+ const account = slugSegment(connectedAccountId);
39
+ if (!toolkit || !account) {
40
+ throw new Error("Composio toolkit and connected account must be slug-like");
41
+ }
42
+ const displayToolkit =
43
+ toolkit.charAt(0).toUpperCase() + toolkit.slice(1).toLowerCase();
44
+ return `user-${displayToolkit}--${account}`;
45
+ }
46
+
24
47
  export interface ComposioRouterPluginConfig {
25
48
  client: ComposioClient;
26
49
  userId: string;
@@ -75,6 +98,7 @@ export function createComposioRouterPlugin(
75
98
  ): Plugin.Function {
76
99
  const allowedToolSlugs = new Set<string>();
77
100
  let runtimeContext: Context | undefined;
101
+ let runtimeNamespace: string | undefined;
78
102
  const search: ToolDefinition = {
79
103
  name: "composio_search_tools",
80
104
  // A general work tool: the full toolset an `executor` subagent gets, and
@@ -130,29 +154,37 @@ export function createComposioRouterPlugin(
130
154
  ) {
131
155
  return { content: "Invalid Composio tool input", isError: true };
132
156
  }
133
- const wasDurablySearched = runtimeContext?.sessions
134
- .get(context.sessionId)
135
- ?.events.some((event) => {
136
- if (
137
- event.type !== "tool/result" ||
138
- event.name !== "composio_search_tools" ||
139
- event.isError
140
- ) {
141
- return false;
142
- }
143
- try {
144
- const result: unknown = JSON.parse(event.content);
145
- return (
146
- Array.isArray(result) &&
147
- result.some(
148
- (candidate) =>
149
- isObject(candidate) && candidate.slug === input.toolSlug,
150
- )
151
- );
152
- } catch {
153
- return false;
154
- }
155
- });
157
+ const events = runtimeContext?.sessions.get(context.sessionId)?.events;
158
+ const wasDurablySearched = events?.some((event) => {
159
+ if (event.type !== "tool/result" || event.isError) {
160
+ return false;
161
+ }
162
+ const isSearchResult =
163
+ event.name === "composio_search_tools" ||
164
+ (event.name === "call_dynamic_tool" &&
165
+ events?.some(
166
+ (candidate) =>
167
+ candidate.type === "tool/call" &&
168
+ candidate.occurrenceId === event.occurrenceId &&
169
+ candidate.name === "call_dynamic_tool" &&
170
+ isObject(candidate.input) &&
171
+ candidate.input.namespace === runtimeNamespace &&
172
+ candidate.input.toolName === "composio_search_tools",
173
+ ));
174
+ if (!isSearchResult) return false;
175
+ try {
176
+ const result: unknown = JSON.parse(event.content);
177
+ return (
178
+ Array.isArray(result) &&
179
+ result.some(
180
+ (candidate) =>
181
+ isObject(candidate) && candidate.slug === input.toolSlug,
182
+ )
183
+ );
184
+ } catch {
185
+ return false;
186
+ }
187
+ });
156
188
  if (!allowedToolSlugs.has(input.toolSlug) && !wasDurablySearched) {
157
189
  return {
158
190
  content:
@@ -176,14 +208,35 @@ export function createComposioRouterPlugin(
176
208
  return { content: JSON.stringify(result), isError: false };
177
209
  },
178
210
  };
179
- const plugin: Plugin.Function = (ctx: Context) => {
211
+ const plugin: Plugin.Function = async (ctx: Context) => {
212
+ const grant = await config.authorizeEffect();
213
+ if (grant.toolkitSlug !== config.toolkitSlug) {
214
+ throw new Error("Composio effect grant changed toolkit");
215
+ }
216
+ runtimeNamespace = composioNamespaceV1(
217
+ grant.toolkitSlug,
218
+ grant.connectedAccountId,
219
+ );
180
220
  runtimeContext = ctx;
181
- const removeSearch = ctx.tools.register(search);
182
- const removeExecute = ctx.tools.register(execute);
221
+ const removeNamespace = ctx.tools.registerNamespace({
222
+ name: runtimeNamespace,
223
+ external: true,
224
+ status: "ready",
225
+ });
226
+ const removeSearch = ctx.tools.register({
227
+ ...search,
228
+ namespace: runtimeNamespace,
229
+ });
230
+ const removeExecute = ctx.tools.register({
231
+ ...execute,
232
+ namespace: runtimeNamespace,
233
+ });
183
234
  return () => {
184
235
  runtimeContext = undefined;
236
+ runtimeNamespace = undefined;
185
237
  removeExecute();
186
238
  removeSearch();
239
+ removeNamespace();
187
240
  };
188
241
  };
189
242
  plugin.inject = ["tools", "sessions"];
@@ -194,9 +247,19 @@ export function createComposioPlugin(
194
247
  config: ComposioPluginConfig,
195
248
  ): Plugin.Function {
196
249
  const plugin: Plugin.Function = (ctx: Context) => {
250
+ const namespace = composioNamespaceV1(
251
+ config.toolkitSlug,
252
+ config.connectedAccountId,
253
+ );
254
+ const removeNamespace = ctx.tools.registerNamespace({
255
+ name: namespace,
256
+ external: true,
257
+ status: "ready",
258
+ });
197
259
  const disposers = config.tools.map((declaration) => {
198
260
  const tool: ToolDefinition = {
199
261
  name: declaration.name,
262
+ namespace,
200
263
  description: declaration.description,
201
264
  inputSchema: declaration.inputSchema,
202
265
  validate: isObject,
@@ -218,6 +281,7 @@ export function createComposioPlugin(
218
281
  });
219
282
  return () => {
220
283
  for (const dispose of disposers.toReversed()) dispose();
284
+ removeNamespace();
221
285
  };
222
286
  };
223
287
  plugin.inject = ["tools"];