@composio/experimental 0.1.0
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/README.md +190 -0
- package/dist/index.d.mts +266 -0
- package/dist/index.mjs +694 -0
- package/dist/workbench/index.d.mts +24 -0
- package/dist/workbench/index.mjs +40 -0
- package/package.json +72 -0
- package/src/index.ts +10 -0
package/README.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# `@composio/experimental`
|
|
2
|
+
|
|
3
|
+
Experimental Composio integrations and helpers.
|
|
4
|
+
|
|
5
|
+
This package currently includes a Pi provider for [`@earendil-works/pi-coding-agent`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent). It lets Composio tools be passed to Pi SDK sessions as `customTools` and includes a dynamic Tool Router session toolset modeled after the Slack bot integration in `~/composio/slack-bot`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @composio/core @composio/experimental @earendil-works/pi-coding-agent
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Static tool wrapping
|
|
14
|
+
|
|
15
|
+
Use this when you already know the exact Composio tools to expose to Pi.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { Composio } from '@composio/core';
|
|
19
|
+
import { PiProvider } from '@composio/experimental';
|
|
20
|
+
import { createAgentSession, SessionManager } from '@earendil-works/pi-coding-agent';
|
|
21
|
+
|
|
22
|
+
const provider = new PiProvider();
|
|
23
|
+
const composio = new Composio({
|
|
24
|
+
apiKey: process.env.COMPOSIO_API_KEY,
|
|
25
|
+
provider,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const tools = await composio.tools.get('default', {
|
|
29
|
+
tools: ['GITHUB_CREATE_ISSUE'],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const { session } = await createAgentSession({
|
|
33
|
+
cwd: process.cwd(),
|
|
34
|
+
sessionManager: SessionManager.inMemory(process.cwd()),
|
|
35
|
+
customTools: tools,
|
|
36
|
+
tools: ['read', 'bash', ...tools.map(tool => tool.name)],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await session.prompt('Create a GitHub issue for the failing test.');
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Dynamic session helpers
|
|
43
|
+
|
|
44
|
+
Use this when Pi should search and execute tools dynamically inside one Tool Router session. Prefer the capability form so your app owns connection management and uses one Pi-style `hooks` object for interception/result transforms.
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { Composio } from '@composio/core';
|
|
48
|
+
import { PiProvider, createPiComposioSystemPrompt } from '@composio/experimental';
|
|
49
|
+
import {
|
|
50
|
+
createAgentSession,
|
|
51
|
+
DefaultResourceLoader,
|
|
52
|
+
getAgentDir,
|
|
53
|
+
SessionManager,
|
|
54
|
+
} from '@earendil-works/pi-coding-agent';
|
|
55
|
+
|
|
56
|
+
const provider = new PiProvider();
|
|
57
|
+
const composio = new Composio({ apiKey: process.env.COMPOSIO_API_KEY });
|
|
58
|
+
|
|
59
|
+
const composioSession = await composio.sessions.create('slack:T123:U456', {
|
|
60
|
+
toolkits: ['github', 'gmail'],
|
|
61
|
+
manageConnections: true,
|
|
62
|
+
workbench: { enable: true },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const composioTools = provider.createSessionTools({
|
|
66
|
+
sessionId: composioSession.sessionId,
|
|
67
|
+
search: composioSession.search.bind(composioSession),
|
|
68
|
+
execute: composioSession.execute.bind(composioSession),
|
|
69
|
+
callbackUrl: 'https://your-app.example.com/auth/callback',
|
|
70
|
+
includeWorkbenchTools: true,
|
|
71
|
+
connections: {
|
|
72
|
+
getToolkitStates: toolkits => composioSession.toolkits({ toolkits }),
|
|
73
|
+
authorizeToolkit: (toolkit, options) => composioSession.authorize(toolkit, options),
|
|
74
|
+
isConnected: state => state.connection?.isActive === true,
|
|
75
|
+
},
|
|
76
|
+
hooks: {
|
|
77
|
+
search: (ctx, next) => {
|
|
78
|
+
ctx.request.toolkits = ctx.request.toolkits?.map(toolkit =>
|
|
79
|
+
toolkit === 'slack' ? 'slackbot' : toolkit
|
|
80
|
+
);
|
|
81
|
+
return next();
|
|
82
|
+
},
|
|
83
|
+
execute: (ctx, next) => {
|
|
84
|
+
if (ctx.request.toolSlug === 'COMPOSIO_MANAGE_CONNECTIONS') {
|
|
85
|
+
return ctx.deny('Meta tools are blocked.');
|
|
86
|
+
}
|
|
87
|
+
return next();
|
|
88
|
+
},
|
|
89
|
+
remoteWorkbench: async (ctx, next) => {
|
|
90
|
+
const result = await next();
|
|
91
|
+
await auditWorkbenchRun({ code: ctx.request.code_to_execute, result });
|
|
92
|
+
return result;
|
|
93
|
+
},
|
|
94
|
+
remoteBash: async (ctx, next) => {
|
|
95
|
+
if (ctx.request.command.includes('rm -rf')) {
|
|
96
|
+
return ctx.deny('Destructive bash commands are blocked.');
|
|
97
|
+
}
|
|
98
|
+
return next();
|
|
99
|
+
},
|
|
100
|
+
onAuthLink: async (ctx, next) => {
|
|
101
|
+
// DM the user, store the continuation, or redact public output.
|
|
102
|
+
await sendConnectionLinkToUser({ url: ctx.url, toolkit: ctx.toolkit });
|
|
103
|
+
return { message: 'Connection link sent out-of-band.' };
|
|
104
|
+
// To also send the original result/link to the model, use: return next();
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
transformResult: async ({ value }) => value,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const loader = new DefaultResourceLoader({
|
|
111
|
+
cwd: process.cwd(),
|
|
112
|
+
agentDir: getAgentDir(),
|
|
113
|
+
systemPromptOverride: () =>
|
|
114
|
+
createPiComposioSystemPrompt(composioSession.sessionId, { includeWorkbenchTools: true }),
|
|
115
|
+
});
|
|
116
|
+
await loader.reload();
|
|
117
|
+
|
|
118
|
+
const { session } = await createAgentSession({
|
|
119
|
+
cwd: process.cwd(),
|
|
120
|
+
resourceLoader: loader,
|
|
121
|
+
sessionManager: SessionManager.inMemory(process.cwd()),
|
|
122
|
+
customTools: composioTools,
|
|
123
|
+
tools: [
|
|
124
|
+
'read',
|
|
125
|
+
'bash',
|
|
126
|
+
'composio_search_tools',
|
|
127
|
+
'composio_manage_connections',
|
|
128
|
+
'composio_execute_tool',
|
|
129
|
+
'composio_remote_workbench',
|
|
130
|
+
'composio_remote_bash',
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await session.prompt('Find my recent GitHub issues and summarize the blockers.');
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`composio_manage_connections` uses your `connections.getToolkitStates()` and `connections.authorizeToolkit()` handlers. It does **not** call `session.execute('COMPOSIO_MANAGE_CONNECTIONS', ...)` internally.
|
|
138
|
+
|
|
139
|
+
The dynamic helpers are:
|
|
140
|
+
|
|
141
|
+
- `composio_search_tools` — search Tool Router for exact tool slugs and schemas.
|
|
142
|
+
- `composio_manage_connections` — check/initiate user app connections.
|
|
143
|
+
- `composio_execute_tool` — execute exact Composio tool slugs in the session.
|
|
144
|
+
- `composio_remote_workbench` — execute Python in the Composio remote workbench for large outputs, remote files, and session-authenticated scripting.
|
|
145
|
+
- `composio_remote_bash` — run short bash commands in the Composio remote workbench filesystem.
|
|
146
|
+
|
|
147
|
+
Workbench helpers are opt-in because the Tool Router session must be created with workbench enabled.
|
|
148
|
+
|
|
149
|
+
## Hooks
|
|
150
|
+
|
|
151
|
+
`hooks` follows Pi's extension-event style with middleware semantics. Each hook receives a mutable `ctx.request`, `ctx.deny('reason')`, and a typed `next()` function. Calling `await next()` runs the default Composio behavior and returns the result before anything is sent back to the model. Return that result to pass it through, return a replacement value to control what the model sees, or skip `next()` entirely to divert/deny the operation.
|
|
152
|
+
|
|
153
|
+
Available hooks:
|
|
154
|
+
|
|
155
|
+
- `search(ctx, next)` — rewrite query/toolkit filters, log search results, or return custom search output.
|
|
156
|
+
- `manageConnections(ctx, next)` — rewrite requested toolkits, force reauth, log connection state, or return custom connection output.
|
|
157
|
+
- `execute(ctx, next)` — block/rewrite tools, route to another session/execute handler, call `ctx.manageConnections(...)`, log outputs, or return a file/workbench reference instead of inline data.
|
|
158
|
+
- `remoteWorkbench(ctx, next)` — rewrite Python code/session metadata, audit workbench runs, or replace large outputs with file/workbench references. Calls through the generic `execute` hook when `next()` is used.
|
|
159
|
+
- `remoteBash(ctx, next)` — rewrite/block shell commands, enforce safety policy, audit filesystem access, or replace output. Calls through the generic `execute` hook when `next()` is used.
|
|
160
|
+
- `onAuthLink(ctx, next)` — send/redact/resume auth links out-of-band. `return next()` keeps the original model-visible result; returning another value replaces it.
|
|
161
|
+
|
|
162
|
+
## Auth link handling
|
|
163
|
+
|
|
164
|
+
Embedded agents often need to keep Composio connection URLs out of the public transcript. Use `hooks.onAuthLink()` to choose whether the model sees the original result or a redacted replacement:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
const tools = provider.createSessionTools({
|
|
168
|
+
search: composioSession.search.bind(composioSession),
|
|
169
|
+
execute: composioSession.execute.bind(composioSession),
|
|
170
|
+
connections: {
|
|
171
|
+
getToolkitStates: toolkits => composioSession.toolkits({ toolkits }),
|
|
172
|
+
authorizeToolkit: (toolkit, options) => composioSession.authorize(toolkit, options),
|
|
173
|
+
},
|
|
174
|
+
hooks: {
|
|
175
|
+
onAuthLink: async (ctx, next) => {
|
|
176
|
+
await sendConnectionLinkToUser({ url: ctx.url, toolkit: ctx.toolkit });
|
|
177
|
+
|
|
178
|
+
if (shouldAlsoShowLinkToModel(ctx)) {
|
|
179
|
+
return next();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { message: 'Connection link sent out-of-band.' };
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Status
|
|
189
|
+
|
|
190
|
+
Experimental. The dynamic helper names and session helper API may change.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { BaseAgenticProvider, ExecuteToolFn, McpServerGetResponse, McpUrlResponse, Tool } from "@composio/core";
|
|
2
|
+
import { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
//#region src/pi/types.d.ts
|
|
5
|
+
type PiToolDetails = {
|
|
6
|
+
slug?: string;
|
|
7
|
+
result?: unknown;
|
|
8
|
+
error?: string | null;
|
|
9
|
+
authLinks?: string[];
|
|
10
|
+
denied?: boolean;
|
|
11
|
+
};
|
|
12
|
+
type PiTool = ToolDefinition;
|
|
13
|
+
type PiToolCollection = PiTool[];
|
|
14
|
+
type PiToolResultFormatter = (result: unknown) => string;
|
|
15
|
+
type PiDeniedResult = {
|
|
16
|
+
successful: false;
|
|
17
|
+
error: string;
|
|
18
|
+
data: null;
|
|
19
|
+
denied: true;
|
|
20
|
+
};
|
|
21
|
+
interface PiHookControls {
|
|
22
|
+
/** Return from a hook to explicitly deny/divert the operation with a model-visible error. */
|
|
23
|
+
deny(error: string): PiDeniedResult;
|
|
24
|
+
}
|
|
25
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
26
|
+
type PiHookNext<TResult> = () => Promise<TResult>;
|
|
27
|
+
interface PiProviderOptions {
|
|
28
|
+
/** Prefix shown in Pi's TUI labels. Defaults to `Composio`. */
|
|
29
|
+
labelPrefix?: string;
|
|
30
|
+
/** Per-tool execution mode. Defaults to Pi's runtime default. */
|
|
31
|
+
executionMode?: PiTool['executionMode'];
|
|
32
|
+
/** Convert Composio results to the text sent back to the model. */
|
|
33
|
+
formatResult?: PiToolResultFormatter;
|
|
34
|
+
/** Return thrown errors as structured JSON instead of rethrowing to Pi. Defaults to true. */
|
|
35
|
+
catchErrors?: boolean;
|
|
36
|
+
}
|
|
37
|
+
declare const DEFAULT_SESSION_TOOL_NAMES: {
|
|
38
|
+
readonly search: "composio_search_tools";
|
|
39
|
+
readonly manageConnections: "composio_manage_connections";
|
|
40
|
+
readonly execute: "composio_execute_tool";
|
|
41
|
+
readonly remoteWorkbench: "composio_remote_workbench";
|
|
42
|
+
readonly remoteBash: "composio_remote_bash";
|
|
43
|
+
};
|
|
44
|
+
type PiSessionToolName = keyof typeof DEFAULT_SESSION_TOOL_NAMES;
|
|
45
|
+
interface PiBaseToolContext {
|
|
46
|
+
/** Tool call id passed by Pi. */
|
|
47
|
+
toolCallId: string;
|
|
48
|
+
/** The Pi helper tool that is currently running. */
|
|
49
|
+
sourceTool: string;
|
|
50
|
+
/** Optional Composio Tool Router session id for prompts/logging/default workbench session ids. */
|
|
51
|
+
sessionId?: string;
|
|
52
|
+
/** Original helper request sent by the model. */
|
|
53
|
+
originalRequest: unknown;
|
|
54
|
+
}
|
|
55
|
+
interface PiSearchContext extends PiBaseToolContext {
|
|
56
|
+
query: string;
|
|
57
|
+
requestedToolkits?: string[];
|
|
58
|
+
}
|
|
59
|
+
interface PiConnectionManagementContext extends PiBaseToolContext {
|
|
60
|
+
requestedToolkits: string[];
|
|
61
|
+
callbackUrl?: string;
|
|
62
|
+
reinitiateAll: boolean;
|
|
63
|
+
}
|
|
64
|
+
interface PiExecuteContext extends PiBaseToolContext {
|
|
65
|
+
toolSlug: string;
|
|
66
|
+
toolkit?: string;
|
|
67
|
+
args: Record<string, unknown>;
|
|
68
|
+
account?: string;
|
|
69
|
+
}
|
|
70
|
+
interface PiAuthLinkContext<TResult = unknown> extends PiHookControls {
|
|
71
|
+
url: string;
|
|
72
|
+
toolkit?: string;
|
|
73
|
+
sourceTool: string;
|
|
74
|
+
originalRequest: unknown;
|
|
75
|
+
result: TResult;
|
|
76
|
+
sessionId?: string;
|
|
77
|
+
}
|
|
78
|
+
interface PiSessionToolOptions extends PiProviderOptions {
|
|
79
|
+
/** Callback URL passed to `authorizeToolkit()` when connection management initiates auth. */
|
|
80
|
+
callbackUrl?: string;
|
|
81
|
+
/** Include first-class wrappers for COMPOSIO_REMOTE_WORKBENCH and COMPOSIO_REMOTE_BASH_TOOL. Defaults to false. */
|
|
82
|
+
includeWorkbenchTools?: boolean;
|
|
83
|
+
/** Override the default helper tool names. */
|
|
84
|
+
names?: Partial<typeof DEFAULT_SESSION_TOOL_NAMES>;
|
|
85
|
+
/** Called before a result is returned to Pi; useful for redacting or routing auth links. */
|
|
86
|
+
transformResult?: (params: {
|
|
87
|
+
tool: PiSessionToolName;
|
|
88
|
+
requestedToolkits?: string[];
|
|
89
|
+
value: unknown;
|
|
90
|
+
context?: PiBaseToolContext;
|
|
91
|
+
}) => unknown | Promise<unknown>;
|
|
92
|
+
}
|
|
93
|
+
interface PiComposioSessionLike<TSearchResult = unknown, TExecuteResult = unknown, TToolkitStates = unknown, TAuthorizeResult = unknown> {
|
|
94
|
+
sessionId?: string;
|
|
95
|
+
search(params: {
|
|
96
|
+
query: string;
|
|
97
|
+
toolkits?: string[];
|
|
98
|
+
}): Promise<TSearchResult>;
|
|
99
|
+
execute(toolSlug: string, args?: Record<string, unknown>, options?: {
|
|
100
|
+
account?: string;
|
|
101
|
+
}): Promise<TExecuteResult>;
|
|
102
|
+
/** Native Tool Router connection-state API. Preferred over executing a meta tool. */
|
|
103
|
+
toolkits?(options?: {
|
|
104
|
+
toolkits?: string[];
|
|
105
|
+
isConnected?: boolean;
|
|
106
|
+
limit?: number;
|
|
107
|
+
cursor?: string;
|
|
108
|
+
}): Promise<TToolkitStates>;
|
|
109
|
+
authorize?(toolkit: string, options?: {
|
|
110
|
+
callbackUrl?: string;
|
|
111
|
+
alias?: string;
|
|
112
|
+
experimental?: unknown;
|
|
113
|
+
}): Promise<TAuthorizeResult>;
|
|
114
|
+
}
|
|
115
|
+
interface PiExecutableSessionLike<TExecuteResult = unknown> {
|
|
116
|
+
sessionId?: string;
|
|
117
|
+
execute(toolSlug: string, args?: Record<string, unknown>, options?: {
|
|
118
|
+
account?: string;
|
|
119
|
+
}): Promise<TExecuteResult>;
|
|
120
|
+
}
|
|
121
|
+
type PiSearchHandler<TSearchResult = unknown> = (params: {
|
|
122
|
+
query: string;
|
|
123
|
+
toolkits?: string[];
|
|
124
|
+
}, context: PiSearchContext) => MaybePromise<TSearchResult>;
|
|
125
|
+
type PiExecuteHandler<TExecuteResult = unknown> = (toolSlug: string, args: Record<string, unknown>, options: {
|
|
126
|
+
account?: string;
|
|
127
|
+
} | undefined, context: PiExecuteContext) => MaybePromise<TExecuteResult>;
|
|
128
|
+
interface PiAuthorizeToolkitOptions {
|
|
129
|
+
callbackUrl?: string;
|
|
130
|
+
alias?: string;
|
|
131
|
+
experimental?: unknown;
|
|
132
|
+
reinitiate?: boolean;
|
|
133
|
+
}
|
|
134
|
+
interface PiConnectionHandlers<TState = unknown, TAuthorizeResult = unknown, TToolkitStates = unknown> {
|
|
135
|
+
/** Return connection states for the requested toolkits, e.g. from `session.toolkits({ toolkits })`. */
|
|
136
|
+
getToolkitStates?: (toolkits: string[], context: PiConnectionManagementContext) => MaybePromise<TToolkitStates>;
|
|
137
|
+
/** Start auth for one toolkit, e.g. via `session.authorize(toolkit, { callbackUrl })`. */
|
|
138
|
+
authorizeToolkit?: (toolkit: string, options: PiAuthorizeToolkitOptions, context: PiConnectionManagementContext) => Promise<TAuthorizeResult> | TAuthorizeResult;
|
|
139
|
+
/** Interpret one state returned by `getToolkitStates()`. Defaults handle common Tool Router shapes. */
|
|
140
|
+
isConnected?: (state: TState, context: {
|
|
141
|
+
toolkit: string;
|
|
142
|
+
request: PiConnectionManagementContext;
|
|
143
|
+
}) => boolean;
|
|
144
|
+
}
|
|
145
|
+
interface PiSearchHookContext extends PiHookControls {
|
|
146
|
+
request: {
|
|
147
|
+
query: string;
|
|
148
|
+
toolkits?: string[];
|
|
149
|
+
};
|
|
150
|
+
context: PiSearchContext;
|
|
151
|
+
}
|
|
152
|
+
interface PiManageConnectionsHookContext extends PiHookControls {
|
|
153
|
+
request: {
|
|
154
|
+
toolkits: string[];
|
|
155
|
+
reinitiateAll: boolean;
|
|
156
|
+
};
|
|
157
|
+
context: PiConnectionManagementContext;
|
|
158
|
+
}
|
|
159
|
+
interface PiExecuteHookContext<TExecuteResult = unknown> extends PiHookControls {
|
|
160
|
+
request: {
|
|
161
|
+
toolSlug: string;
|
|
162
|
+
args: Record<string, unknown>;
|
|
163
|
+
account?: string;
|
|
164
|
+
session?: PiExecutableSessionLike<TExecuteResult>;
|
|
165
|
+
execute?: PiExecuteHandler<TExecuteResult>;
|
|
166
|
+
};
|
|
167
|
+
context: PiExecuteContext;
|
|
168
|
+
manageConnections: (toolkits: string[], options?: {
|
|
169
|
+
reinitiateAll?: boolean;
|
|
170
|
+
}) => Promise<unknown>;
|
|
171
|
+
}
|
|
172
|
+
interface PiRemoteWorkbenchRequest extends Record<string, unknown> {
|
|
173
|
+
code_to_execute: string;
|
|
174
|
+
timeout?: number;
|
|
175
|
+
thought?: string;
|
|
176
|
+
file_path?: string;
|
|
177
|
+
disabled_tools?: string[];
|
|
178
|
+
session_id?: string;
|
|
179
|
+
}
|
|
180
|
+
interface PiRemoteBashRequest extends Record<string, unknown> {
|
|
181
|
+
command: string;
|
|
182
|
+
session_id?: string;
|
|
183
|
+
}
|
|
184
|
+
interface PiRemoteWorkbenchHookContext extends PiHookControls {
|
|
185
|
+
request: PiRemoteWorkbenchRequest;
|
|
186
|
+
context: PiExecuteContext;
|
|
187
|
+
}
|
|
188
|
+
interface PiRemoteBashHookContext extends PiHookControls {
|
|
189
|
+
request: PiRemoteBashRequest;
|
|
190
|
+
context: PiExecuteContext;
|
|
191
|
+
}
|
|
192
|
+
interface PiSessionHooks<TSearchResult = unknown, TExecuteResult = unknown, TState = unknown, TAuthorizeResult = unknown> {
|
|
193
|
+
/** Middleware around search. Mutate `ctx.request`, call `next()` to run search, or return a replacement result. */
|
|
194
|
+
search?: (ctx: PiSearchHookContext, next: PiHookNext<TSearchResult>) => MaybePromise<unknown>;
|
|
195
|
+
/** Middleware around connection management. Mutate `ctx.request`, call `next()` to check/connect, or return a replacement result. */
|
|
196
|
+
manageConnections?: (ctx: PiManageConnectionsHookContext, next: PiHookNext<PiConnectionManagementResult<TState, TAuthorizeResult>>) => MaybePromise<unknown>;
|
|
197
|
+
/** Middleware around tool execution. Mutate `ctx.request`, call `next()` to execute, or return a replacement result. */
|
|
198
|
+
execute?: (ctx: PiExecuteHookContext<TExecuteResult>, next: PiHookNext<TExecuteResult>) => MaybePromise<unknown>;
|
|
199
|
+
/** Middleware around the remote Python workbench helper. Runs outside, then through, the generic `execute` hook when `next()` is called. */
|
|
200
|
+
remoteWorkbench?: (ctx: PiRemoteWorkbenchHookContext, next: PiHookNext<TExecuteResult>) => MaybePromise<unknown>;
|
|
201
|
+
/** Middleware around the remote bash helper. Runs outside, then through, the generic `execute` hook when `next()` is called. */
|
|
202
|
+
remoteBash?: (ctx: PiRemoteBashHookContext, next: PiHookNext<TExecuteResult>) => MaybePromise<unknown>;
|
|
203
|
+
/** Middleware for auth links found in any result. Call `next()` to keep the current model-visible result, or return a replacement. */
|
|
204
|
+
onAuthLink?: (ctx: PiAuthLinkContext<TSearchResult | TExecuteResult | TAuthorizeResult>, next: PiHookNext<TSearchResult | TExecuteResult | TAuthorizeResult>) => MaybePromise<unknown>;
|
|
205
|
+
}
|
|
206
|
+
interface PiSessionToolCapabilities<TSearchResult = unknown, TExecuteResult = unknown, TState = unknown, TAuthorizeResult = unknown, TToolkitStates = unknown> extends PiSessionToolOptions {
|
|
207
|
+
/** Optional session id for prompt context and default workbench session ids. */
|
|
208
|
+
sessionId?: string;
|
|
209
|
+
search: PiSearchHandler<TSearchResult>;
|
|
210
|
+
execute: PiExecuteHandler<TExecuteResult>;
|
|
211
|
+
connections?: PiConnectionHandlers<TState, TAuthorizeResult, TToolkitStates>;
|
|
212
|
+
hooks?: PiSessionHooks<TSearchResult, TExecuteResult, TState, TAuthorizeResult>;
|
|
213
|
+
}
|
|
214
|
+
type PiConnectionToolkitResult<TState = unknown, TAuthorizeResult = unknown> = {
|
|
215
|
+
toolkit: string;
|
|
216
|
+
connected: boolean;
|
|
217
|
+
status: 'connected' | 'auth_initiated' | 'missing_authorize_handler';
|
|
218
|
+
state?: TState;
|
|
219
|
+
authorization?: TAuthorizeResult;
|
|
220
|
+
authLinks?: string[];
|
|
221
|
+
};
|
|
222
|
+
type PiConnectionManagementResult<TState = unknown, TAuthorizeResult = unknown> = {
|
|
223
|
+
successful: true;
|
|
224
|
+
data: {
|
|
225
|
+
message: string;
|
|
226
|
+
results: Record<string, PiConnectionToolkitResult<TState, TAuthorizeResult>>;
|
|
227
|
+
};
|
|
228
|
+
error: null;
|
|
229
|
+
};
|
|
230
|
+
//#endregion
|
|
231
|
+
//#region src/pi/auth-links.d.ts
|
|
232
|
+
declare const extractComposioConnectLinks: (value: unknown) => string[];
|
|
233
|
+
//#endregion
|
|
234
|
+
//#region src/pi/provider.d.ts
|
|
235
|
+
/**
|
|
236
|
+
* Provider for integrating Composio tools with Pi SDK custom tools.
|
|
237
|
+
*/
|
|
238
|
+
declare class PiProvider extends BaseAgenticProvider<PiToolCollection, PiTool, McpServerGetResponse> {
|
|
239
|
+
private readonly options;
|
|
240
|
+
readonly name = "pi";
|
|
241
|
+
constructor(options?: PiProviderOptions);
|
|
242
|
+
/**
|
|
243
|
+
* Wrap a concrete Composio tool as a Pi custom tool definition.
|
|
244
|
+
*/
|
|
245
|
+
wrapTool(composioTool: Tool, executeTool: ExecuteToolFn): PiTool;
|
|
246
|
+
/**
|
|
247
|
+
* Wrap multiple concrete Composio tools as Pi custom tools.
|
|
248
|
+
*/
|
|
249
|
+
wrapTools(tools: Tool[], executeTool: ExecuteToolFn): PiToolCollection;
|
|
250
|
+
createSessionTools<TSearchResult = unknown, TExecuteResult = unknown, TState = unknown, TAuthorizeResult = unknown, TToolkitStates = unknown>(capabilities: PiSessionToolCapabilities<TSearchResult, TExecuteResult, TState, TAuthorizeResult, TToolkitStates>): PiToolCollection;
|
|
251
|
+
createSessionTools<TSearchResult = unknown, TExecuteResult = unknown, TToolkitStates = unknown, TAuthorizeResult = unknown>(session: PiComposioSessionLike<TSearchResult, TExecuteResult, TToolkitStates, TAuthorizeResult>, options?: PiSessionToolOptions): PiToolCollection;
|
|
252
|
+
/**
|
|
253
|
+
* Transform MCP URL responses into Pi-compatible standard URL entries.
|
|
254
|
+
*/
|
|
255
|
+
wrapMcpServerResponse(data: McpUrlResponse): McpServerGetResponse;
|
|
256
|
+
}
|
|
257
|
+
//#endregion
|
|
258
|
+
//#region src/pi/prompt.d.ts
|
|
259
|
+
declare const createPiComposioSystemPrompt: (sessionId?: string, options?: {
|
|
260
|
+
includeWorkbenchTools?: boolean;
|
|
261
|
+
}) => string;
|
|
262
|
+
//#endregion
|
|
263
|
+
//#region src/pi/results.d.ts
|
|
264
|
+
declare const denyPiToolCall: (error: string) => PiDeniedResult;
|
|
265
|
+
//#endregion
|
|
266
|
+
export { type MaybePromise, DEFAULT_SESSION_TOOL_NAMES as PI_COMPOSIO_SESSION_TOOL_NAMES, type PiAuthLinkContext, type PiAuthorizeToolkitOptions, type PiBaseToolContext, type PiComposioSessionLike, type PiConnectionHandlers, type PiConnectionManagementContext, type PiConnectionManagementResult, type PiConnectionToolkitResult, type PiDeniedResult, type PiExecutableSessionLike, type PiExecuteContext, type PiExecuteHandler, type PiExecuteHookContext, type PiHookControls, type PiHookNext, type PiManageConnectionsHookContext, PiProvider, type PiProviderOptions, type PiRemoteBashHookContext, type PiRemoteBashRequest, type PiRemoteWorkbenchHookContext, type PiRemoteWorkbenchRequest, type PiSearchContext, type PiSearchHandler, type PiSearchHookContext, type PiSessionHooks, type PiSessionToolCapabilities, type PiSessionToolName, type PiSessionToolOptions, type PiTool, type PiToolCollection, type PiToolDetails, type PiToolResultFormatter, createPiComposioSystemPrompt, denyPiToolCall, extractComposioConnectLinks };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
import { BaseAgenticProvider, normalizeToolArguments } from "@composio/core";
|
|
2
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
|
|
5
|
+
//#region src/pi/hooks.ts
|
|
6
|
+
const runHook = async (hook, context, getDefaultResult) => {
|
|
7
|
+
if (!hook) return getDefaultResult();
|
|
8
|
+
const state = {};
|
|
9
|
+
const next = async () => {
|
|
10
|
+
state.nextResult ??= Promise.resolve(getDefaultResult());
|
|
11
|
+
return state.nextResult;
|
|
12
|
+
};
|
|
13
|
+
const hookValue = await hook(context, next);
|
|
14
|
+
if (hookValue !== void 0) return hookValue;
|
|
15
|
+
return state.nextResult ?? next();
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/pi/results.ts
|
|
20
|
+
const denyPiToolCall = (error) => ({
|
|
21
|
+
successful: false,
|
|
22
|
+
error,
|
|
23
|
+
data: null,
|
|
24
|
+
denied: true
|
|
25
|
+
});
|
|
26
|
+
const hookControls = { deny: denyPiToolCall };
|
|
27
|
+
const defaultFormatResult = (result) => JSON.stringify(result, null, 2);
|
|
28
|
+
const stringifyError = (error) => error instanceof Error ? error.message : String(error);
|
|
29
|
+
const isPiDeniedResult = (value) => !!value && typeof value === "object" && value.denied === true && value.successful === false;
|
|
30
|
+
const toPiResult = (value, formatter, details = {}) => ({
|
|
31
|
+
content: [{
|
|
32
|
+
type: "text",
|
|
33
|
+
text: formatter(value)
|
|
34
|
+
}],
|
|
35
|
+
details: {
|
|
36
|
+
...details,
|
|
37
|
+
denied: details.denied ?? (isPiDeniedResult(value) ? true : void 0),
|
|
38
|
+
result: value
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
const toPiErrorResult = (error, formatter, details = {}) => {
|
|
42
|
+
const message = stringifyError(error);
|
|
43
|
+
const value = {
|
|
44
|
+
successful: false,
|
|
45
|
+
error: message,
|
|
46
|
+
data: null
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
content: [{
|
|
50
|
+
type: "text",
|
|
51
|
+
text: formatter(value)
|
|
52
|
+
}],
|
|
53
|
+
details: {
|
|
54
|
+
...details,
|
|
55
|
+
error: message,
|
|
56
|
+
result: value
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region src/pi/auth-links.ts
|
|
63
|
+
const extractComposioConnectLinks = (value) => {
|
|
64
|
+
const text = stringifyUnknown(value);
|
|
65
|
+
const connectLinks = text.match(/https:\/\/connect\.composio\.dev\/[^\s<>)"']+/gi) ?? [];
|
|
66
|
+
const genericLinks = text.match(/https:\/\/[^\s<>)"']*composio[^\s<>)"']*\/link\/[^\s<>)"']+/gi) ?? [];
|
|
67
|
+
return [...new Set([...connectLinks, ...genericLinks].map((url) => url.replace(/[.,;:!?]+$/g, "")))];
|
|
68
|
+
};
|
|
69
|
+
const stringifyUnknown = (value) => {
|
|
70
|
+
if (typeof value === "string") return value;
|
|
71
|
+
try {
|
|
72
|
+
return JSON.stringify(value);
|
|
73
|
+
} catch {
|
|
74
|
+
return String(value);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const applyAuthLinkHandlers = async (capabilities, value, context) => {
|
|
78
|
+
const links = extractComposioConnectLinks(value);
|
|
79
|
+
const run = async (index, currentValue) => {
|
|
80
|
+
const url = links[index];
|
|
81
|
+
if (!url) return currentValue;
|
|
82
|
+
return run(index + 1, await runHook(capabilities.hooks?.onAuthLink, {
|
|
83
|
+
...context,
|
|
84
|
+
...hookControls,
|
|
85
|
+
url,
|
|
86
|
+
result: currentValue
|
|
87
|
+
}, async () => currentValue));
|
|
88
|
+
};
|
|
89
|
+
return {
|
|
90
|
+
value: await run(0, value),
|
|
91
|
+
authLinks: links
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/pi/schemas.ts
|
|
97
|
+
const EmptyObjectSchema = Type.Object({});
|
|
98
|
+
const ToolkitsSchema = Type.Optional(Type.Array(Type.String({ description: "Optional toolkit slug filter, e.g. github, gmail." })));
|
|
99
|
+
const objectInputSchema = (schema) => {
|
|
100
|
+
const candidate = schema && typeof schema === "object" ? { ...schema } : { ...EmptyObjectSchema };
|
|
101
|
+
if (!candidate.type) candidate.type = "object";
|
|
102
|
+
if (!candidate.properties) candidate.properties = {};
|
|
103
|
+
if (candidate.additionalProperties === void 0) candidate.additionalProperties = true;
|
|
104
|
+
return Type.Unsafe(candidate);
|
|
105
|
+
};
|
|
106
|
+
const optionalRecordSchema = (description) => Type.Optional(Type.Record(Type.String(), Type.Any(), { description }));
|
|
107
|
+
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/pi/utils.ts
|
|
110
|
+
const normalizeToolkits = (value) => {
|
|
111
|
+
const toolkits = Array.isArray(value) ? value.filter((item) => typeof item === "string" && item.trim().length > 0) : typeof value === "string" && value.trim().length > 0 ? [value] : [];
|
|
112
|
+
const unique = [...new Set(toolkits.map((toolkit) => toolkit.trim()))];
|
|
113
|
+
return unique.length > 0 ? unique : void 0;
|
|
114
|
+
};
|
|
115
|
+
const maybeTransform = async (options, params) => options.transformResult ? options.transformResult(params) : params.value;
|
|
116
|
+
const stringValue = (value) => typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
|
|
117
|
+
const toolkitFromToolSlug = (toolSlug) => {
|
|
118
|
+
const normalized = toolSlug.trim().toLowerCase();
|
|
119
|
+
if (!normalized || normalized.startsWith("composio_")) return void 0;
|
|
120
|
+
for (const [prefix, toolkit] of [
|
|
121
|
+
["google_calendar_", "googlecalendar"],
|
|
122
|
+
["google_drive_", "googledrive"],
|
|
123
|
+
["microsoft_teams_", "microsoftteams"]
|
|
124
|
+
]) if (normalized.startsWith(prefix)) return toolkit;
|
|
125
|
+
const [prefix] = normalized.split("_");
|
|
126
|
+
return prefix || void 0;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/pi/connections.ts
|
|
131
|
+
const defaultIsToolkitConnected = (state) => {
|
|
132
|
+
if (!state || typeof state !== "object") return false;
|
|
133
|
+
const record = state;
|
|
134
|
+
if (record.isNoAuth === true || record.is_no_auth === true) return true;
|
|
135
|
+
if (typeof record.isConnected === "boolean") return record.isConnected;
|
|
136
|
+
if (typeof record.is_connected === "boolean") return record.is_connected;
|
|
137
|
+
const connection = record.connection;
|
|
138
|
+
if (connection) {
|
|
139
|
+
if (connection.isActive === true || connection.is_active === true) return true;
|
|
140
|
+
if (connection.status === "ACTIVE") return true;
|
|
141
|
+
if (connection.connectedAccount?.status === "ACTIVE") return true;
|
|
142
|
+
if (connection.connected_account?.status === "ACTIVE") return true;
|
|
143
|
+
}
|
|
144
|
+
if (record.connectedAccount?.status === "ACTIVE") return true;
|
|
145
|
+
if (record.connected_account?.status === "ACTIVE") return true;
|
|
146
|
+
return false;
|
|
147
|
+
};
|
|
148
|
+
const toolkitKeyFromState = (state) => {
|
|
149
|
+
if (!state || typeof state !== "object") return void 0;
|
|
150
|
+
const record = state;
|
|
151
|
+
const toolkit = record.toolkit;
|
|
152
|
+
return stringValue(record.slug) ?? stringValue(record.toolkitSlug) ?? stringValue(record.toolkit_slug) ?? stringValue(record.name) ?? stringValue(toolkit?.slug);
|
|
153
|
+
};
|
|
154
|
+
const normalizeToolkitStateMap = (raw, requestedToolkits) => {
|
|
155
|
+
const byToolkit = /* @__PURE__ */ new Map();
|
|
156
|
+
const addState = (toolkit, state) => {
|
|
157
|
+
if (toolkit) byToolkit.set(toolkit.toLowerCase(), state);
|
|
158
|
+
};
|
|
159
|
+
if (!raw) return byToolkit;
|
|
160
|
+
if (Array.isArray(raw)) {
|
|
161
|
+
raw.forEach((state, index) => addState(toolkitKeyFromState(state) ?? requestedToolkits[index], state));
|
|
162
|
+
return byToolkit;
|
|
163
|
+
}
|
|
164
|
+
if (typeof raw === "object") {
|
|
165
|
+
const record = raw;
|
|
166
|
+
if (Array.isArray(record.items)) {
|
|
167
|
+
record.items.forEach((state, index) => addState(toolkitKeyFromState(state) ?? requestedToolkits[index], state));
|
|
168
|
+
return byToolkit;
|
|
169
|
+
}
|
|
170
|
+
for (const toolkit of requestedToolkits) if (record[toolkit] !== void 0) addState(toolkit, record[toolkit]);
|
|
171
|
+
if (byToolkit.size === 0 && requestedToolkits.length === 1) addState(toolkitKeyFromState(raw) ?? requestedToolkits[0], raw);
|
|
172
|
+
}
|
|
173
|
+
return byToolkit;
|
|
174
|
+
};
|
|
175
|
+
const formatDefaultConnectionResult = (results) => {
|
|
176
|
+
return {
|
|
177
|
+
successful: true,
|
|
178
|
+
data: {
|
|
179
|
+
message: Object.values(results).filter((result) => !result.connected).length === 0 ? "All requested toolkits are connected." : "Connection flow initiated for missing toolkits.",
|
|
180
|
+
results
|
|
181
|
+
},
|
|
182
|
+
error: null
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
const inferSessionConnections = (session, options) => {
|
|
186
|
+
if (!session.toolkits && !session.authorize) return void 0;
|
|
187
|
+
return {
|
|
188
|
+
getToolkitStates: session.toolkits ? (toolkits) => session.toolkits?.({
|
|
189
|
+
toolkits,
|
|
190
|
+
limit: Math.max(toolkits.length, 1)
|
|
191
|
+
}) : void 0,
|
|
192
|
+
authorizeToolkit: session.authorize ? (toolkit, authorizeOptions) => session.authorize?.(toolkit, {
|
|
193
|
+
callbackUrl: authorizeOptions.callbackUrl ?? options.callbackUrl,
|
|
194
|
+
alias: authorizeOptions.alias,
|
|
195
|
+
experimental: authorizeOptions.experimental
|
|
196
|
+
}) : void 0
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
const isCapabilityInput = (value) => "connections" in value || "hooks" in value || "includeWorkbenchTools" in value || "names" in value;
|
|
200
|
+
const toCapabilities = (input, providerOptions, options = {}) => {
|
|
201
|
+
if (isCapabilityInput(input)) return {
|
|
202
|
+
...providerOptions,
|
|
203
|
+
...input,
|
|
204
|
+
names: {
|
|
205
|
+
...providerOptions.names,
|
|
206
|
+
...input.names ?? {}
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
const mergedOptions = {
|
|
210
|
+
...providerOptions,
|
|
211
|
+
...options
|
|
212
|
+
};
|
|
213
|
+
return {
|
|
214
|
+
...mergedOptions,
|
|
215
|
+
sessionId: input.sessionId,
|
|
216
|
+
search: (params) => input.search(params),
|
|
217
|
+
execute: (toolSlug, args, executeOptions) => executeOptions ? input.execute(toolSlug, args, executeOptions) : input.execute(toolSlug, args),
|
|
218
|
+
connections: inferSessionConnections(input, mergedOptions)
|
|
219
|
+
};
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
//#endregion
|
|
223
|
+
//#region src/pi/types.ts
|
|
224
|
+
const DEFAULT_SESSION_TOOL_NAMES = {
|
|
225
|
+
search: "composio_search_tools",
|
|
226
|
+
manageConnections: "composio_manage_connections",
|
|
227
|
+
execute: "composio_execute_tool",
|
|
228
|
+
remoteWorkbench: "composio_remote_workbench",
|
|
229
|
+
remoteBash: "composio_remote_bash"
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
//#endregion
|
|
233
|
+
//#region src/pi/session-tools.ts
|
|
234
|
+
const createPiSessionTools = (input, providerOptions, options = {}) => {
|
|
235
|
+
const capabilities = toCapabilities(input, providerOptions, options);
|
|
236
|
+
const formatter = capabilities.formatResult ?? defaultFormatResult;
|
|
237
|
+
const catchErrors = capabilities.catchErrors ?? true;
|
|
238
|
+
const names = {
|
|
239
|
+
...DEFAULT_SESSION_TOOL_NAMES,
|
|
240
|
+
...capabilities.names ?? {}
|
|
241
|
+
};
|
|
242
|
+
const executionMode = capabilities.executionMode;
|
|
243
|
+
const buildBaseContext = (toolCallId, sourceTool, originalRequest) => ({
|
|
244
|
+
toolCallId,
|
|
245
|
+
sourceTool,
|
|
246
|
+
sessionId: capabilities.sessionId,
|
|
247
|
+
originalRequest
|
|
248
|
+
});
|
|
249
|
+
const manageConnectionsForToolkits = async (toolCallId, originalRequest, toolkits, reinitiateAll = false) => {
|
|
250
|
+
const connectionContext = {
|
|
251
|
+
...buildBaseContext(toolCallId, names.manageConnections, originalRequest),
|
|
252
|
+
requestedToolkits: toolkits,
|
|
253
|
+
callbackUrl: capabilities.callbackUrl,
|
|
254
|
+
reinitiateAll
|
|
255
|
+
};
|
|
256
|
+
const hookContext = {
|
|
257
|
+
...hookControls,
|
|
258
|
+
request: {
|
|
259
|
+
toolkits,
|
|
260
|
+
reinitiateAll
|
|
261
|
+
},
|
|
262
|
+
context: connectionContext
|
|
263
|
+
};
|
|
264
|
+
const authLinks = [];
|
|
265
|
+
return {
|
|
266
|
+
value: await runHook(capabilities.hooks?.manageConnections, hookContext, async () => {
|
|
267
|
+
const requestedToolkits = normalizeToolkits(hookContext.request.toolkits) ?? [];
|
|
268
|
+
const shouldReinitiateAll = hookContext.request.reinitiateAll;
|
|
269
|
+
connectionContext.requestedToolkits = requestedToolkits;
|
|
270
|
+
connectionContext.reinitiateAll = shouldReinitiateAll;
|
|
271
|
+
const states = normalizeToolkitStateMap(await capabilities.connections?.getToolkitStates?.(requestedToolkits, connectionContext), requestedToolkits);
|
|
272
|
+
const results = {};
|
|
273
|
+
for (const toolkit of requestedToolkits) {
|
|
274
|
+
const state = states.get(toolkit.toLowerCase());
|
|
275
|
+
if ((state ? capabilities.connections?.isConnected?.(state, {
|
|
276
|
+
toolkit,
|
|
277
|
+
request: connectionContext
|
|
278
|
+
}) ?? defaultIsToolkitConnected(state) : false) && !shouldReinitiateAll) {
|
|
279
|
+
results[toolkit] = {
|
|
280
|
+
toolkit,
|
|
281
|
+
connected: true,
|
|
282
|
+
status: "connected",
|
|
283
|
+
state
|
|
284
|
+
};
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (!capabilities.connections?.authorizeToolkit) {
|
|
288
|
+
results[toolkit] = {
|
|
289
|
+
toolkit,
|
|
290
|
+
connected: false,
|
|
291
|
+
status: "missing_authorize_handler",
|
|
292
|
+
state
|
|
293
|
+
};
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const authorization = await capabilities.connections.authorizeToolkit(toolkit, {
|
|
297
|
+
callbackUrl: capabilities.callbackUrl,
|
|
298
|
+
reinitiate: shouldReinitiateAll
|
|
299
|
+
}, connectionContext);
|
|
300
|
+
const handledAuthorization = await applyAuthLinkHandlers(capabilities, authorization, {
|
|
301
|
+
...connectionContext,
|
|
302
|
+
toolkit,
|
|
303
|
+
result: authorization
|
|
304
|
+
});
|
|
305
|
+
authLinks.push(...handledAuthorization.authLinks);
|
|
306
|
+
results[toolkit] = {
|
|
307
|
+
toolkit,
|
|
308
|
+
connected: false,
|
|
309
|
+
status: "auth_initiated",
|
|
310
|
+
state,
|
|
311
|
+
authorization: handledAuthorization.value
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
return formatDefaultConnectionResult(results);
|
|
315
|
+
}),
|
|
316
|
+
authLinks,
|
|
317
|
+
context: connectionContext
|
|
318
|
+
};
|
|
319
|
+
};
|
|
320
|
+
const executeWithPolicy = async (toolCallId, sourceTool, originalRequest, toolSlug, args, account) => {
|
|
321
|
+
const executeContext = {
|
|
322
|
+
...buildBaseContext(toolCallId, sourceTool, originalRequest),
|
|
323
|
+
toolSlug,
|
|
324
|
+
toolkit: toolkitFromToolSlug(toolSlug),
|
|
325
|
+
args,
|
|
326
|
+
account
|
|
327
|
+
};
|
|
328
|
+
const authLinks = [];
|
|
329
|
+
const hookContext = {
|
|
330
|
+
...hookControls,
|
|
331
|
+
request: {
|
|
332
|
+
toolSlug,
|
|
333
|
+
args,
|
|
334
|
+
account
|
|
335
|
+
},
|
|
336
|
+
context: executeContext,
|
|
337
|
+
manageConnections: async (managedToolkits, manageOptions) => {
|
|
338
|
+
const managed = await manageConnectionsForToolkits(toolCallId, originalRequest, managedToolkits, manageOptions?.reinitiateAll);
|
|
339
|
+
authLinks.push(...managed.authLinks);
|
|
340
|
+
return managed.value;
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
return {
|
|
344
|
+
value: await runHook(capabilities.hooks?.execute, hookContext, async () => {
|
|
345
|
+
const finalToolSlug = hookContext.request.toolSlug;
|
|
346
|
+
const finalArgs = hookContext.request.args;
|
|
347
|
+
const finalAccount = hookContext.request.account;
|
|
348
|
+
const finalContext = {
|
|
349
|
+
...executeContext,
|
|
350
|
+
toolSlug: finalToolSlug,
|
|
351
|
+
toolkit: toolkitFromToolSlug(finalToolSlug),
|
|
352
|
+
args: finalArgs,
|
|
353
|
+
account: finalAccount
|
|
354
|
+
};
|
|
355
|
+
hookContext.context = finalContext;
|
|
356
|
+
const execute = hookContext.request.execute ?? capabilities.execute;
|
|
357
|
+
const session = hookContext.request.session;
|
|
358
|
+
const result = session ? await session.execute(finalToolSlug, finalArgs, finalAccount ? { account: finalAccount } : void 0) : await execute(finalToolSlug, finalArgs, finalAccount ? { account: finalAccount } : void 0, finalContext);
|
|
359
|
+
const handledResult = await applyAuthLinkHandlers(capabilities, result, {
|
|
360
|
+
...finalContext,
|
|
361
|
+
result
|
|
362
|
+
});
|
|
363
|
+
authLinks.push(...handledResult.authLinks);
|
|
364
|
+
return handledResult.value;
|
|
365
|
+
}),
|
|
366
|
+
authLinks,
|
|
367
|
+
context: hookContext.context
|
|
368
|
+
};
|
|
369
|
+
};
|
|
370
|
+
const searchTools = defineTool({
|
|
371
|
+
name: names.search,
|
|
372
|
+
label: "Composio Search Tools",
|
|
373
|
+
description: "Search Composio for tools that can perform a requested action. Search globally by default; pass toolkits only when intentionally narrowing the search.",
|
|
374
|
+
promptSnippet: "Use composio_search_tools to discover exact Composio tool slugs and schemas before executing app actions.",
|
|
375
|
+
promptGuidelines: ["Search Composio before inventing tool slugs or arguments.", "Only pass a toolkit filter when you intentionally want to narrow search results."],
|
|
376
|
+
parameters: Type.Object({
|
|
377
|
+
query: Type.String({ description: "Natural language description of the action to perform." }),
|
|
378
|
+
toolkits: ToolkitsSchema
|
|
379
|
+
}),
|
|
380
|
+
...executionMode ? { executionMode } : {},
|
|
381
|
+
execute: async (toolCallId, params) => {
|
|
382
|
+
try {
|
|
383
|
+
const requestedToolkits = normalizeToolkits(params.toolkits);
|
|
384
|
+
const searchContext = {
|
|
385
|
+
...buildBaseContext(toolCallId, names.search, params),
|
|
386
|
+
query: params.query,
|
|
387
|
+
requestedToolkits
|
|
388
|
+
};
|
|
389
|
+
const hookContext = {
|
|
390
|
+
...hookControls,
|
|
391
|
+
request: {
|
|
392
|
+
query: params.query,
|
|
393
|
+
...requestedToolkits ? { toolkits: requestedToolkits } : {}
|
|
394
|
+
},
|
|
395
|
+
context: searchContext
|
|
396
|
+
};
|
|
397
|
+
const authLinks = [];
|
|
398
|
+
const value = await runHook(capabilities.hooks?.search, hookContext, async () => {
|
|
399
|
+
const toolkits = normalizeToolkits(hookContext.request.toolkits);
|
|
400
|
+
hookContext.context.query = hookContext.request.query;
|
|
401
|
+
hookContext.context.requestedToolkits = toolkits;
|
|
402
|
+
const result = await capabilities.search({
|
|
403
|
+
query: hookContext.request.query,
|
|
404
|
+
...toolkits ? { toolkits } : {}
|
|
405
|
+
}, hookContext.context);
|
|
406
|
+
const handledResult = await applyAuthLinkHandlers(capabilities, result, {
|
|
407
|
+
...hookContext.context,
|
|
408
|
+
result
|
|
409
|
+
});
|
|
410
|
+
authLinks.push(...handledResult.authLinks);
|
|
411
|
+
return handledResult.value;
|
|
412
|
+
});
|
|
413
|
+
return toPiResult(await maybeTransform(capabilities, {
|
|
414
|
+
tool: "search",
|
|
415
|
+
requestedToolkits: hookContext.context.requestedToolkits,
|
|
416
|
+
value,
|
|
417
|
+
context: hookContext.context
|
|
418
|
+
}), formatter, {
|
|
419
|
+
slug: names.search,
|
|
420
|
+
authLinks
|
|
421
|
+
});
|
|
422
|
+
} catch (error) {
|
|
423
|
+
if (!catchErrors) throw error;
|
|
424
|
+
return toPiErrorResult(error, formatter, { slug: names.search });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
const manageConnections = defineTool({
|
|
429
|
+
name: names.manageConnections,
|
|
430
|
+
label: "Composio Manage Connections",
|
|
431
|
+
description: "Check whether the user has active connections for requested toolkits and initiate Composio auth when needed.",
|
|
432
|
+
promptSnippet: "Use composio_manage_connections when a searched tool requires a missing app connection.",
|
|
433
|
+
promptGuidelines: ["When an app connection is missing, call composio_manage_connections with the toolkit slug.", "Never ask the user for OAuth secrets or API keys directly."],
|
|
434
|
+
parameters: Type.Object({
|
|
435
|
+
toolkits: Type.Array(Type.String({ description: "Toolkit slugs to check/connect, e.g. github, gmail." })),
|
|
436
|
+
reinitiate_all: Type.Optional(Type.Boolean({ description: "Force reconnection even if active connections exist." }))
|
|
437
|
+
}),
|
|
438
|
+
...executionMode ? { executionMode } : {},
|
|
439
|
+
execute: async (toolCallId, params) => {
|
|
440
|
+
const toolkits = normalizeToolkits(params.toolkits) ?? [];
|
|
441
|
+
try {
|
|
442
|
+
const managed = await manageConnectionsForToolkits(toolCallId, params, toolkits, params.reinitiate_all ?? false);
|
|
443
|
+
return toPiResult(await maybeTransform(capabilities, {
|
|
444
|
+
tool: "manageConnections",
|
|
445
|
+
requestedToolkits: toolkits,
|
|
446
|
+
value: managed.value,
|
|
447
|
+
context: managed.context
|
|
448
|
+
}), formatter, {
|
|
449
|
+
slug: names.manageConnections,
|
|
450
|
+
authLinks: managed.authLinks,
|
|
451
|
+
denied: managed.denied
|
|
452
|
+
});
|
|
453
|
+
} catch (error) {
|
|
454
|
+
if (!catchErrors) throw error;
|
|
455
|
+
return toPiErrorResult(error, formatter, { slug: names.manageConnections });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
const executeTool = defineTool({
|
|
460
|
+
name: names.execute,
|
|
461
|
+
label: "Composio Execute Tool",
|
|
462
|
+
description: "Execute an exact Composio tool slug using the configured Composio execution capability. Use search first so the slug and arguments match the schema.",
|
|
463
|
+
promptSnippet: "Use composio_execute_tool to execute an exact Composio tool slug returned by composio_search_tools.",
|
|
464
|
+
promptGuidelines: ["Always use exact tool slugs and schema-compliant arguments.", "For missing connections, use composio_manage_connections instead of asking for credentials."],
|
|
465
|
+
parameters: Type.Object({
|
|
466
|
+
toolSlug: Type.String({ description: "Exact Composio tool slug, e.g. GITHUB_CREATE_ISSUE." }),
|
|
467
|
+
arguments: optionalRecordSchema("Tool arguments matching the searched schema."),
|
|
468
|
+
account: Type.Optional(Type.String({ description: "Optional account selector for multi-account sessions. Use connected account id or alias when required." }))
|
|
469
|
+
}),
|
|
470
|
+
...executionMode ? { executionMode } : {},
|
|
471
|
+
prepareArguments: (args) => normalizeToolArguments(args, names.execute),
|
|
472
|
+
execute: async (toolCallId, params) => {
|
|
473
|
+
const toolSlug = params.toolSlug.trim();
|
|
474
|
+
const args = params.arguments ?? {};
|
|
475
|
+
try {
|
|
476
|
+
const executed = await executeWithPolicy(toolCallId, names.execute, params, toolSlug, args, params.account);
|
|
477
|
+
return toPiResult(await maybeTransform(capabilities, {
|
|
478
|
+
tool: "execute",
|
|
479
|
+
requestedToolkits: toolkitFromToolSlug(executed.context.toolSlug) ? [toolkitFromToolSlug(executed.context.toolSlug)] : void 0,
|
|
480
|
+
value: executed.value,
|
|
481
|
+
context: executed.context
|
|
482
|
+
}), formatter, {
|
|
483
|
+
slug: executed.context.toolSlug || names.execute,
|
|
484
|
+
authLinks: executed.authLinks,
|
|
485
|
+
denied: executed.denied
|
|
486
|
+
});
|
|
487
|
+
} catch (error) {
|
|
488
|
+
if (!catchErrors) throw error;
|
|
489
|
+
return toPiErrorResult(error, formatter, { slug: toolSlug || names.execute });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
if (!capabilities.includeWorkbenchTools) return [
|
|
494
|
+
searchTools,
|
|
495
|
+
manageConnections,
|
|
496
|
+
executeTool
|
|
497
|
+
];
|
|
498
|
+
return [
|
|
499
|
+
searchTools,
|
|
500
|
+
manageConnections,
|
|
501
|
+
executeTool,
|
|
502
|
+
defineTool({
|
|
503
|
+
name: names.remoteWorkbench,
|
|
504
|
+
label: "Composio Remote Workbench",
|
|
505
|
+
description: "Execute Python code inside the Composio remote workbench for this Tool Router session. Use it for remote files, bulk processing, large tool outputs, and Composio-authenticated scripting.",
|
|
506
|
+
promptSnippet: "Use composio_remote_workbench for Python scripting in the Composio remote sandbox when data is large, stored in remote files, or needs session-authenticated tool/proxy helpers.",
|
|
507
|
+
promptGuidelines: ["Use composio_remote_workbench for large data processing or remote workbench files; do not use it for tiny inline transformations.", "Split long-running work into small cells and save checkpoints in the workbench filesystem."],
|
|
508
|
+
parameters: Type.Object({
|
|
509
|
+
code_to_execute: Type.String({ description: "Python code to run in the persistent Composio remote workbench. Keep cells focused and avoid long-running jobs." }),
|
|
510
|
+
timeout: Type.Optional(Type.Number({
|
|
511
|
+
minimum: 1,
|
|
512
|
+
maximum: 780,
|
|
513
|
+
description: "Maximum seconds to allow execution. Defaults to the session/backend workbench timeout."
|
|
514
|
+
})),
|
|
515
|
+
thought: Type.Optional(Type.String({ description: "Concise objective for why this workbench cell is needed." })),
|
|
516
|
+
file_path: Type.Optional(Type.String({ description: "Remote workbench path/glob to analyze when processing a file." })),
|
|
517
|
+
disabled_tools: Type.Optional(Type.Array(Type.String({ description: "Tool slugs to disable for this workbench call." }))),
|
|
518
|
+
session_id: Type.Optional(Type.String({ description: "Workbench workflow session id. Defaults to the Composio Tool Router session id." }))
|
|
519
|
+
}),
|
|
520
|
+
...executionMode ? { executionMode } : {},
|
|
521
|
+
execute: async (toolCallId, params) => {
|
|
522
|
+
try {
|
|
523
|
+
const request = {
|
|
524
|
+
...params,
|
|
525
|
+
...params.session_id || capabilities.sessionId ? { session_id: params.session_id ?? capabilities.sessionId } : {}
|
|
526
|
+
};
|
|
527
|
+
const hookContext = {
|
|
528
|
+
...hookControls,
|
|
529
|
+
request,
|
|
530
|
+
context: {
|
|
531
|
+
...buildBaseContext(toolCallId, names.remoteWorkbench, params),
|
|
532
|
+
toolSlug: "COMPOSIO_REMOTE_WORKBENCH",
|
|
533
|
+
args: request
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
const authLinks = [];
|
|
537
|
+
const details = {};
|
|
538
|
+
return toPiResult(await maybeTransform(capabilities, {
|
|
539
|
+
tool: "remoteWorkbench",
|
|
540
|
+
value: await runHook(capabilities.hooks?.remoteWorkbench, hookContext, async () => {
|
|
541
|
+
const executed = await executeWithPolicy(toolCallId, names.remoteWorkbench, params, "COMPOSIO_REMOTE_WORKBENCH", hookContext.request);
|
|
542
|
+
authLinks.push(...executed.authLinks);
|
|
543
|
+
details.denied = executed.denied;
|
|
544
|
+
hookContext.context = executed.context;
|
|
545
|
+
return executed.value;
|
|
546
|
+
}),
|
|
547
|
+
context: hookContext.context
|
|
548
|
+
}), formatter, {
|
|
549
|
+
slug: hookContext.context.toolSlug,
|
|
550
|
+
authLinks,
|
|
551
|
+
denied: details.denied
|
|
552
|
+
});
|
|
553
|
+
} catch (error) {
|
|
554
|
+
if (!catchErrors) throw error;
|
|
555
|
+
return toPiErrorResult(error, formatter, { slug: "COMPOSIO_REMOTE_WORKBENCH" });
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}),
|
|
559
|
+
defineTool({
|
|
560
|
+
name: names.remoteBash,
|
|
561
|
+
label: "Composio Remote Bash",
|
|
562
|
+
description: "Execute a bash command inside the Composio remote workbench for this Tool Router session.",
|
|
563
|
+
promptSnippet: "Use composio_remote_bash to inspect or manipulate files in the Composio remote workbench filesystem.",
|
|
564
|
+
promptGuidelines: ["Use composio_remote_bash for filesystem inspection in the remote workbench, especially for truncated output files.", "Keep commands short and non-interactive."],
|
|
565
|
+
parameters: Type.Object({
|
|
566
|
+
command: Type.String({ description: "Bash command to execute in the Composio remote workbench." }),
|
|
567
|
+
session_id: Type.Optional(Type.String({ description: "Workbench workflow session id. Defaults to the Composio Tool Router session id." }))
|
|
568
|
+
}),
|
|
569
|
+
...executionMode ? { executionMode } : {},
|
|
570
|
+
execute: async (toolCallId, params) => {
|
|
571
|
+
try {
|
|
572
|
+
const request = {
|
|
573
|
+
...params,
|
|
574
|
+
...params.session_id || capabilities.sessionId ? { session_id: params.session_id ?? capabilities.sessionId } : {}
|
|
575
|
+
};
|
|
576
|
+
const hookContext = {
|
|
577
|
+
...hookControls,
|
|
578
|
+
request,
|
|
579
|
+
context: {
|
|
580
|
+
...buildBaseContext(toolCallId, names.remoteBash, params),
|
|
581
|
+
toolSlug: "COMPOSIO_REMOTE_BASH_TOOL",
|
|
582
|
+
args: request
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
const authLinks = [];
|
|
586
|
+
const details = {};
|
|
587
|
+
return toPiResult(await maybeTransform(capabilities, {
|
|
588
|
+
tool: "remoteBash",
|
|
589
|
+
value: await runHook(capabilities.hooks?.remoteBash, hookContext, async () => {
|
|
590
|
+
const executed = await executeWithPolicy(toolCallId, names.remoteBash, params, "COMPOSIO_REMOTE_BASH_TOOL", hookContext.request);
|
|
591
|
+
authLinks.push(...executed.authLinks);
|
|
592
|
+
details.denied = executed.denied;
|
|
593
|
+
hookContext.context = executed.context;
|
|
594
|
+
return executed.value;
|
|
595
|
+
}),
|
|
596
|
+
context: hookContext.context
|
|
597
|
+
}), formatter, {
|
|
598
|
+
slug: hookContext.context.toolSlug,
|
|
599
|
+
authLinks,
|
|
600
|
+
denied: details.denied
|
|
601
|
+
});
|
|
602
|
+
} catch (error) {
|
|
603
|
+
if (!catchErrors) throw error;
|
|
604
|
+
return toPiErrorResult(error, formatter, { slug: "COMPOSIO_REMOTE_BASH_TOOL" });
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
})
|
|
608
|
+
];
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
//#endregion
|
|
612
|
+
//#region src/pi/provider.ts
|
|
613
|
+
/**
|
|
614
|
+
* Provider for integrating Composio tools with Pi SDK custom tools.
|
|
615
|
+
*/
|
|
616
|
+
var PiProvider = class extends BaseAgenticProvider {
|
|
617
|
+
options;
|
|
618
|
+
name = "pi";
|
|
619
|
+
constructor(options = {}) {
|
|
620
|
+
super();
|
|
621
|
+
this.options = options;
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Wrap a concrete Composio tool as a Pi custom tool definition.
|
|
625
|
+
*/
|
|
626
|
+
wrapTool(composioTool, executeTool) {
|
|
627
|
+
const formatter = this.options.formatResult ?? defaultFormatResult;
|
|
628
|
+
const catchErrors = this.options.catchErrors ?? true;
|
|
629
|
+
const schema = objectInputSchema(composioTool.inputParameters);
|
|
630
|
+
return defineTool({
|
|
631
|
+
name: composioTool.slug,
|
|
632
|
+
label: `${this.options.labelPrefix ?? "Composio"}: ${composioTool.name ?? composioTool.slug}`,
|
|
633
|
+
description: composioTool.description ?? `Execute ${composioTool.slug} with Composio.`,
|
|
634
|
+
promptSnippet: `Use ${composioTool.slug} for ${composioTool.description ?? composioTool.name ?? "this Composio action"}.`,
|
|
635
|
+
parameters: schema,
|
|
636
|
+
...this.options.executionMode ? { executionMode: this.options.executionMode } : {},
|
|
637
|
+
prepareArguments: (args) => normalizeToolArguments(args, composioTool.slug),
|
|
638
|
+
execute: async (_toolCallId, params) => {
|
|
639
|
+
try {
|
|
640
|
+
const args = normalizeToolArguments(params, composioTool.slug);
|
|
641
|
+
const result = await executeTool(composioTool.slug, args);
|
|
642
|
+
return toPiResult(result, formatter, {
|
|
643
|
+
slug: composioTool.slug,
|
|
644
|
+
error: result?.error
|
|
645
|
+
});
|
|
646
|
+
} catch (error) {
|
|
647
|
+
if (!catchErrors) throw error;
|
|
648
|
+
return toPiErrorResult(error, formatter, { slug: composioTool.slug });
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Wrap multiple concrete Composio tools as Pi custom tools.
|
|
655
|
+
*/
|
|
656
|
+
wrapTools(tools, executeTool) {
|
|
657
|
+
return tools.map((tool) => this.wrapTool(tool, executeTool));
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Create Slack-bot-style dynamic Composio helpers.
|
|
661
|
+
*
|
|
662
|
+
* Prefer passing capabilities (`search`, `execute`, `connections`, `hooks`)
|
|
663
|
+
* so app code owns auth, interception, and shared/service-session routing.
|
|
664
|
+
* Passing a native Tool Router session is also supported; connection
|
|
665
|
+
* management uses `session.toolkits()` + `session.authorize()` when present
|
|
666
|
+
* and never executes `COMPOSIO_MANAGE_CONNECTIONS` internally.
|
|
667
|
+
*/
|
|
668
|
+
createSessionTools(input, options = {}) {
|
|
669
|
+
return createPiSessionTools(input, this.options, options);
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Transform MCP URL responses into Pi-compatible standard URL entries.
|
|
673
|
+
*/
|
|
674
|
+
wrapMcpServerResponse(data) {
|
|
675
|
+
return data.map((item) => ({
|
|
676
|
+
url: new URL(item.url),
|
|
677
|
+
name: item.name
|
|
678
|
+
}));
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
//#endregion
|
|
683
|
+
//#region src/pi/prompt.ts
|
|
684
|
+
const createPiComposioSystemPrompt = (sessionId, options = {}) => [
|
|
685
|
+
"You have Composio tools for working across the user's connected apps.",
|
|
686
|
+
"Use composio_search_tools to find the right tool before executing app actions.",
|
|
687
|
+
"Use composio_manage_connections when an app is not connected; never ask for OAuth secrets or API keys.",
|
|
688
|
+
"Use composio_execute_tool with exact tool slugs and schema-compliant arguments from search results.",
|
|
689
|
+
options.includeWorkbenchTools ? "Use composio_remote_workbench or composio_remote_bash for large outputs, remote files, or Composio-authenticated scripting in the remote workbench." : void 0,
|
|
690
|
+
sessionId ? `Composio session id: ${sessionId}` : void 0
|
|
691
|
+
].filter(Boolean).join("\n");
|
|
692
|
+
|
|
693
|
+
//#endregion
|
|
694
|
+
export { DEFAULT_SESSION_TOOL_NAMES as PI_COMPOSIO_SESSION_TOOL_NAMES, PiProvider, createPiComposioSystemPrompt, denyPiToolCall, extractComposioConnectLinks };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Composio, Session } from "@composio/core";
|
|
2
|
+
|
|
3
|
+
//#region src/workbench/types.d.ts
|
|
4
|
+
interface LocalWorkbenchSession {
|
|
5
|
+
helperSource: string;
|
|
6
|
+
env: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region src/workbench/local-workbench.d.ts
|
|
10
|
+
declare function experimental_createLocalWorkbenchSession(composio: Composio, session: Session<unknown, unknown, never>): Promise<LocalWorkbenchSession>;
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/workbench/shim.d.ts
|
|
13
|
+
interface WorkbenchEnvOptions {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
backendUrl: string;
|
|
16
|
+
apiKey: string;
|
|
17
|
+
}
|
|
18
|
+
interface PythonWorkbenchHelperSourceOptions {
|
|
19
|
+
invokeLlmModel?: string;
|
|
20
|
+
}
|
|
21
|
+
declare function experimental_createWorkbenchEnv(env: WorkbenchEnvOptions): Record<string, string>;
|
|
22
|
+
declare function experimental_createPythonWorkbenchHelperSource(opts?: PythonWorkbenchHelperSourceOptions): string;
|
|
23
|
+
//#endregion
|
|
24
|
+
export { type LocalWorkbenchSession, type PythonWorkbenchHelperSourceOptions, type WorkbenchEnvOptions, experimental_createLocalWorkbenchSession, experimental_createPythonWorkbenchHelperSource, experimental_createWorkbenchEnv };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//#region src/workbench/python-helpers.generated.ts
|
|
2
|
+
const PYTHON_WORKBENCH_HELPER_SOURCE = "import json\nimport os\nimport random\nimport time\nimport urllib.error\nimport urllib.parse\nimport urllib.request\nimport uuid\nfrom typing import Any, Dict, Literal, Optional\n\n\n# Config is injected by the SDK via an `_INTERNAL` dict in a prologue prepended\n# at runtime. When running this file directly (e.g. pytest), default to empty.\ntry:\n _INTERNAL\nexcept NameError:\n _INTERNAL = {}\n\n\nDEFAULT_INVOKE_LLM_MODEL = _INTERNAL.get(\"invoke_llm_model\", \"openai/gpt-oss-120b\")\nRATE_LIMIT_PATTERNS = (\n \"rate limit\",\n \"ratelimit\",\n \"too many requests\",\n \"quota exceeded\",\n \"resource exhausted\",\n)\n\n\ndef _read_env(name, default=None):\n value = os.environ.get(name)\n return default if value is None or value == \"\" else value\n\n\ndef _require_value(value, label):\n if value is None or value == \"\":\n raise RuntimeError(\"%s is required\" % label)\n return value\n\n\ndef _request_id():\n return str(uuid.uuid4())\n\n\ndef _session_execute_url():\n backend_url = _read_env(\"BACKEND_URL\", \"https://backend.composio.dev\").rstrip(\"/\")\n session_id = _require_value(\n _read_env(\"COMPOSIO_TOOLROUTER_SESSION_ID\"),\n \"COMPOSIO_TOOLROUTER_SESSION_ID\",\n )\n encoded_session_id = urllib.parse.quote(session_id, safe=\"\")\n return \"%s/api/v3/tool_router/session/%s/execute\" % (backend_url, encoded_session_id)\n\n\ndef _session_proxy_execute_url():\n backend_url = _read_env(\"BACKEND_URL\", \"https://backend.composio.dev\").rstrip(\"/\")\n session_id = _require_value(\n _read_env(\"COMPOSIO_TOOLROUTER_SESSION_ID\"),\n \"COMPOSIO_TOOLROUTER_SESSION_ID\",\n )\n encoded_session_id = urllib.parse.quote(session_id, safe=\"\")\n return \"%s/api/v3/tool_router/session/%s/proxy_execute\" % (\n backend_url,\n encoded_session_id,\n )\n\n\ndef _post_json(url, headers, payload, timeout=120):\n body = json.dumps(payload).encode(\"utf-8\")\n request = urllib.request.Request(url, data=body, headers=headers, method=\"POST\")\n try:\n with urllib.request.urlopen(request, timeout=timeout) as response:\n return response.status, dict(response.headers), response.read().decode(\"utf-8\")\n except urllib.error.HTTPError as error:\n return error.code, dict(error.headers), error.read().decode(\"utf-8\")\n\n\ndef _parse_json(text):\n if not text:\n return {}\n return json.loads(text)\n\n\ndef _safe_json(text):\n try:\n return _parse_json(text)\n except json.JSONDecodeError:\n return {\"raw\": text}\n\n\ndef _contains_rate_limit_error(payload):\n # Only inspect the API \"error\" field, not the whole body — otherwise benign\n # tool output mentioning \"rate limit\"/\"quota\" triggers spurious retries.\n if not isinstance(payload, dict):\n return False\n error = payload.get(\"error\")\n if not error:\n return False\n text = json.dumps(error, default=str).lower()\n return any(pattern in text for pattern in RATE_LIMIT_PATTERNS)\n\n\ndef _retry_delay(attempt, delay_ms):\n base_delay = max(delay_ms, 0) / 1000.0\n if base_delay == 0:\n return\n # Exponential backoff: double the delay each attempt (parity with Apollo).\n backoff = base_delay * (2 ** attempt)\n jitter = random.uniform(0, min(backoff * 0.2, 0.5))\n time.sleep(backoff + jitter)\n\n\ndef _json_shape(value):\n if isinstance(value, dict):\n return {key: _json_shape(item) for key, item in value.items()}\n if isinstance(value, list):\n return [_json_shape(value[0])] if value else []\n if value is None:\n return \"null\"\n return type(value).__name__\n\n\ndef print_json_structure(value):\n print(json.dumps(_json_shape(value), indent=2, sort_keys=True))\n\n\ndef _track_helper_event(*_args, **_kwargs):\n return None\n\n\ndef run_composio_tool(\n tool_slug,\n arguments=None,\n retry_params=None,\n print_schema_for_tool=True,\n *,\n account=None,\n):\n if not tool_slug:\n return {}, \"tool_slug is required\"\n\n api_key = _require_value(_read_env(\"COMPOSIO_API_KEY\"), \"COMPOSIO_API_KEY\")\n retry_config = {\"max_retries\": 3, \"delay_ms\": 2000}\n if retry_params:\n retry_config.update(retry_params)\n\n payload = {\n \"tool_slug\": str(tool_slug).strip().upper(),\n \"arguments\": arguments or {},\n }\n if account is not None:\n payload[\"account\"] = account\n\n headers = {\n \"Content-Type\": \"application/json\",\n \"x-api-key\": api_key,\n \"x-request-id\": _request_id(),\n }\n max_retries = int(retry_config.get(\"max_retries\", 3))\n delay_ms = int(retry_config.get(\"delay_ms\", 2000))\n\n for attempt in range(max_retries + 1):\n try:\n status, _headers, text = _post_json(_session_execute_url(), headers, payload)\n except (urllib.error.URLError, TimeoutError) as error:\n # Network failure (timeout, connection/DNS error). Retry transient\n # failures, then surface as the error tuple instead of throwing.\n if attempt < max_retries:\n _retry_delay(attempt, delay_ms)\n continue\n return {}, \"Composio tool request failed: %s\" % error\n\n if status == 429 and attempt < max_retries:\n _retry_delay(attempt, delay_ms)\n continue\n\n if status >= 400:\n response_data = _safe_json(text)\n return response_data, \"Composio tool execution failed with HTTP %s\" % status\n\n try:\n response_data = _parse_json(text)\n except json.JSONDecodeError as error:\n return {\"raw\": text}, \"Failed to parse Composio tool response as JSON: %s\" % error\n\n if _contains_rate_limit_error(response_data) and attempt < max_retries:\n _retry_delay(attempt, delay_ms)\n continue\n\n # A failed tool call returns HTTP 200 with a top-level \"error\" field;\n # surface it as the error tuple element so callers don't read it as success.\n if isinstance(response_data, dict) and response_data.get(\"error\"):\n return response_data, str(response_data[\"error\"])\n\n if print_schema_for_tool:\n print_json_structure(response_data)\n return response_data, \"\"\n\n return {}, \"Composio tool execution failed after retries\"\n\n\ndef _strip_code_fence(content):\n stripped = content.strip()\n code_fence = chr(96) * 3\n if stripped.startswith(code_fence):\n stripped = stripped[3:].strip()\n if stripped.lower().startswith(\"json\"):\n stripped = stripped[4:].strip()\n if stripped.endswith(code_fence):\n stripped = stripped[:-3].strip()\n elif stripped.lower().startswith(\"json\\n\"):\n stripped = stripped[5:].strip()\n return stripped.strip()\n\n\ndef invoke_llm(query, reasoning_effort=None):\n if not query:\n return \"\", \"query is required\"\n\n system_prompt = (\n \"You are a generic, smart large language model. \"\n \"When asked to output a JSON, respond only with valid JSON - \"\n \"no other text / code fences / quotes around the JSON.\"\n )\n response, error = run_composio_tool(\n \"COMPOSIO_SEARCH_GROQ_CHAT\",\n {\n \"model\": DEFAULT_INVOKE_LLM_MODEL,\n \"messages\": [\n {\"role\": \"system\", \"content\": system_prompt},\n {\"role\": \"user\", \"content\": query},\n ],\n \"temperature\": 0.5,\n },\n None,\n False,\n )\n _track_helper_event(\"invoke_llm\", {\"reasoning_effort\": reasoning_effort})\n\n if error:\n return \"\", error\n\n choices = (response.get(\"data\") or {}).get(\"choices\", \"\")\n if not choices:\n return \"\", \"No choices returned from invoke_llm\"\n\n first_choice = choices[0] if isinstance(choices, list) else choices\n content = (first_choice.get(\"message\") or {}).get(\"content\", \"\")\n if not content:\n return \"\", \"No content returned from invoke_llm\"\n return _strip_code_fence(content), \"\"\n\n\ndef web_search(query):\n if not query:\n return \"\", \"query is required\"\n\n response, error = run_composio_tool(\n \"COMPOSIO_SEARCH_EXA_ANSWER\",\n {\"content\": query},\n None,\n False,\n )\n _track_helper_event(\"web_search\", {})\n\n if error:\n return \"\", error\n return (response.get(\"data\") or {}).get(\"answer\", \"\"), \"\"\n\n\ndef proxy_execute(\n method: Literal[\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\"],\n endpoint: str,\n toolkit: str,\n query_params: Optional[Dict[str, str]] = None,\n body: Optional[object] = None,\n headers: Optional[Dict[str, str]] = None,\n) -> tuple[Any, str]:\n \"\"\"Call a toolkit's API directly when no Composio tool exists.\n\n The session resolves the connected account and injects auth server-side, so\n your code never handles raw credentials.\n\n Args:\n method: HTTP method to use for the request. Example: \"GET\"\n endpoint: API endpoint to call. Example: \"/repos/owner/repo\"\n toolkit: Name of the toolkit. Example: \"GITHUB\"\n query_params: Query parameters as key-value pairs. Example: {\"q\": \"is:unread\"}\n body: The request body (required for POST, PUT, and PATCH requests)\n headers: HTTP headers as key-value pairs. Example: {\"Accept\": \"application/json\"}\n\n Returns:\n tuple[Any, str]:\n - data: Response data from the API (None if error)\n - error: Error message (\"\" if no error)\n \"\"\"\n # Wrapped end-to-end so the helper always returns a (data, error) tuple\n # rather than raising.\n try:\n valid_methods = [\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\"]\n if method.upper() not in valid_methods:\n return None, \"Invalid HTTP method: \" + method\n\n if not endpoint.strip():\n return None, \"Endpoint cannot be empty\"\n\n if not toolkit.strip():\n return None, \"Toolkit cannot be empty\"\n\n if query_params is not None and not isinstance(query_params, dict):\n return None, (\n \"Invalid query_params type: expected dict or None, got \"\n + type(query_params).__name__\n )\n\n if headers is not None and not isinstance(headers, dict):\n return None, (\n \"Invalid headers type: expected dict or None, got \"\n + type(headers).__name__\n )\n\n if method.upper() in [\"POST\", \"PUT\", \"PATCH\"] and body is None:\n return None, \"Body is required for \" + method.upper() + \" requests\"\n\n if method.upper() in [\"GET\", \"DELETE\"] and body is not None:\n return None, \"Body should not be provided for \" + method.upper() + \" requests\"\n\n api_key = _read_env(\"COMPOSIO_API_KEY\")\n if not api_key:\n return None, \"Missing environment variable COMPOSIO_API_KEY\"\n if not _read_env(\"COMPOSIO_TOOLROUTER_SESSION_ID\"):\n return None, \"Missing environment variable COMPOSIO_TOOLROUTER_SESSION_ID\"\n\n # Convert query_params and headers dicts to the flat parameters array.\n parameters = []\n if query_params:\n for key, value in query_params.items():\n parameters.append({\"name\": key, \"value\": str(value), \"type\": \"query\"})\n if headers:\n for key, value in headers.items():\n parameters.append({\"name\": key, \"value\": str(value), \"type\": \"header\"})\n\n payload = {\n \"toolkit_slug\": toolkit.lower(),\n \"endpoint\": endpoint,\n \"method\": method.upper(),\n }\n if parameters:\n payload[\"parameters\"] = parameters\n if body is not None:\n payload[\"body\"] = body\n\n request_headers = {\n \"Content-Type\": \"application/json\",\n \"x-api-key\": api_key,\n \"x-request-id\": _request_id(),\n }\n\n status, _response_headers, text = _post_json(\n _session_proxy_execute_url(), request_headers, payload\n )\n\n # Handle HTTP errors explicitly. Always surface a non-empty message so a\n # >=400 response never looks like success (an empty error/message field\n # must not win over the default).\n if status >= 400:\n error_msg = \"HTTP \" + str(status) + \" Error\"\n try:\n error_data = json.loads(text)\n if isinstance(error_data, dict):\n error_msg = (\n error_data.get(\"error\") or error_data.get(\"message\") or error_msg\n )\n except (ValueError, TypeError):\n if text:\n sanitized = text[:200].replace(\"\\n\", \" \").strip()\n error_msg = \"HTTP \" + str(status) + \": \" + sanitized\n return None, str(error_msg)\n\n response_data = json.loads(text)\n\n # The session wraps the proxied response as {data, status, headers}; the\n # toolkit's own API may still report a >=400 status inside that envelope.\n # `status` can arrive as a string or null, so coerce it before comparing\n # — a bare `>= 400` against a non-int would raise and mask a valid result.\n if isinstance(response_data, dict):\n try:\n api_status = int(response_data.get(\"status\", 200))\n except (TypeError, ValueError):\n api_status = 200\n if api_status >= 400:\n error_msg = \"API request failed\"\n if isinstance(response_data.get(\"data\"), dict):\n error_msg = response_data[\"data\"].get(\n \"message\", response_data[\"data\"].get(\"error\", error_msg)\n )\n return response_data, \"API returned status \" + str(api_status) + \": \" + str(error_msg)\n return response_data.get(\"data\"), \"\"\n\n return response_data, \"\"\n except Exception as error:\n return None, \"Failed to execute proxy request: \" + str(error)\n";
|
|
3
|
+
|
|
4
|
+
//#endregion
|
|
5
|
+
//#region src/workbench/shim.ts
|
|
6
|
+
function trimTrailingSlashes(value) {
|
|
7
|
+
let end = value.length;
|
|
8
|
+
while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1;
|
|
9
|
+
return value.slice(0, end);
|
|
10
|
+
}
|
|
11
|
+
function experimental_createWorkbenchEnv(env) {
|
|
12
|
+
return {
|
|
13
|
+
BACKEND_URL: trimTrailingSlashes(env.backendUrl),
|
|
14
|
+
COMPOSIO_TOOLROUTER_SESSION_ID: env.sessionId,
|
|
15
|
+
COMPOSIO_API_KEY: env.apiKey
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function experimental_createPythonWorkbenchHelperSource(opts = {}) {
|
|
19
|
+
const config = { invoke_llm_model: opts.invokeLlmModel ?? "openai/gpt-oss-120b" };
|
|
20
|
+
return `import json as _composio_internal_json\n_INTERNAL = _composio_internal_json.loads(${JSON.stringify(JSON.stringify(config))})\n` + PYTHON_WORKBENCH_HELPER_SOURCE;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/workbench/local-workbench.ts
|
|
25
|
+
async function experimental_createLocalWorkbenchSession(composio, session) {
|
|
26
|
+
if (session.workbench?.enable !== false) throw new Error("experimental_createLocalWorkbenchSession requires a session created with workbench.enable: false. The remote workbench and a local sandbox cannot both run for one session.");
|
|
27
|
+
const { apiKey, baseURL } = composio.getConfig();
|
|
28
|
+
if (!apiKey) throw new Error("A Composio project API key is required to create a local workbench session");
|
|
29
|
+
return {
|
|
30
|
+
env: experimental_createWorkbenchEnv({
|
|
31
|
+
sessionId: session.sessionId,
|
|
32
|
+
backendUrl: baseURL ?? "https://backend.composio.dev",
|
|
33
|
+
apiKey
|
|
34
|
+
}),
|
|
35
|
+
helperSource: experimental_createPythonWorkbenchHelperSource()
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
//#endregion
|
|
40
|
+
export { experimental_createLocalWorkbenchSession, experimental_createPythonWorkbenchHelperSource, experimental_createWorkbenchEnv };
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@composio/experimental",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Experimental Composio integrations and helpers",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/ComposioHQ/composio.git",
|
|
10
|
+
"directory": "ts/packages/experimental"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/ComposioHQ/composio/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/ComposioHQ/composio/tree/main/ts/packages/experimental#readme",
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public",
|
|
18
|
+
"main": "dist/index.mjs",
|
|
19
|
+
"types": "dist/index.d.mts"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.mts",
|
|
24
|
+
"default": "./dist/index.mjs"
|
|
25
|
+
},
|
|
26
|
+
"./pi": {
|
|
27
|
+
"types": "./dist/index.d.mts",
|
|
28
|
+
"default": "./dist/index.mjs"
|
|
29
|
+
},
|
|
30
|
+
"./workbench": {
|
|
31
|
+
"types": "./dist/workbench/index.d.mts",
|
|
32
|
+
"default": "./dist/workbench/index.mjs"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"README.md",
|
|
37
|
+
"dist"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"clean": "git clean -xdf node_modules",
|
|
41
|
+
"build:python-helpers": "tsx scripts/build-python-helpers.ts",
|
|
42
|
+
"check:python-helpers": "tsx scripts/build-python-helpers.ts && git diff --exit-code -- src/workbench/python-helpers.generated.ts",
|
|
43
|
+
"build": "pnpm run build:python-helpers && pnpm exec tsdown",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
46
|
+
},
|
|
47
|
+
"keywords": [
|
|
48
|
+
"composio",
|
|
49
|
+
"experimental",
|
|
50
|
+
"pi",
|
|
51
|
+
"coding-agent",
|
|
52
|
+
"tools",
|
|
53
|
+
"agent"
|
|
54
|
+
],
|
|
55
|
+
"author": "",
|
|
56
|
+
"license": "ISC",
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"typebox": "1.1.38"
|
|
59
|
+
},
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"@composio/core": ">=0.10.0 <1.0.0",
|
|
62
|
+
"@earendil-works/pi-coding-agent": ">=0.79.0 <1.0.0"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@composio/core": "workspace:*",
|
|
66
|
+
"@earendil-works/pi-coding-agent": "0.79.8",
|
|
67
|
+
"tsdown": "catalog:",
|
|
68
|
+
"tsx": "catalog:",
|
|
69
|
+
"typescript": "catalog:",
|
|
70
|
+
"vitest": "catalog:"
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/index.ts
ADDED