loopengine 0.0.17 → 0.0.20
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 +67 -6
- package/core/system-skills/composio-large-outputs/SKILL.md +25 -0
- package/dist/{cli.d.ts → bin/cli.d.ts} +27 -6
- package/dist/bin/cli.js +384 -0
- package/dist/{agent-as-tool.d.ts → core/agent-as-tool.d.ts} +2 -2
- package/dist/{agent-as-tool.js → core/agent-as-tool.js} +1 -1
- package/dist/core/client.d.ts +113 -0
- package/dist/core/client.js +123 -0
- package/dist/core/discover-agents.d.ts +45 -0
- package/dist/{discover-agents.js → core/discover-agents.js} +12 -4
- package/dist/core/gateway-tools.d.ts +226 -0
- package/dist/core/gateway-tools.js +660 -0
- package/dist/core/loop-events.d.ts +168 -0
- package/dist/core/loop-events.js +1 -0
- package/dist/{model-calls → core/model-calls}/anthropic-model-call.d.ts +1 -1
- package/dist/{model-calls → core/model-calls}/deepseek-model-call.d.ts +1 -1
- package/dist/{model-calls → core/model-calls}/openai-model-call.d.ts +1 -1
- package/dist/core/run-agent.d.ts +182 -0
- package/dist/{run-agent.js → core/run-agent.js} +233 -51
- package/dist/{session-store.d.ts → core/session-store.d.ts} +13 -1
- package/dist/{session-store.js → core/session-store.js} +19 -0
- package/dist/core/system-tools/ask_user.d.ts +72 -0
- package/dist/core/system-tools/ask_user.js +136 -0
- package/dist/core/system-tools/index.d.ts +3 -0
- package/dist/core/system-tools/index.js +12 -0
- package/dist/core/system-tools/read_file.d.ts +28 -0
- package/dist/core/system-tools/read_file.js +64 -0
- package/dist/index.d.ts +19 -16
- package/dist/index.js +16 -9
- package/package.json +33 -25
- package/dist/cli.js +0 -191
- package/dist/discover-agents.d.ts +0 -24
- package/dist/run-agent.d.ts +0 -92
- /package/dist/{agent-config.d.ts → core/agent-config.d.ts} +0 -0
- /package/dist/{agent-config.js → core/agent-config.js} +0 -0
- /package/dist/{model-calls → core/model-calls}/anthropic-model-call.js +0 -0
- /package/dist/{model-calls → core/model-calls}/deepseek-model-call.js +0 -0
- /package/dist/{model-calls → core/model-calls}/openai-model-call.js +0 -0
- /package/dist/{vector-index.d.ts → core/vector-index.d.ts} +0 -0
- /package/dist/{vector-index.js → core/vector-index.js} +0 -0
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
// Lets an operator register external tool gateways (Composio today —
|
|
2
|
+
// Nango, Arcade, Scalekit are meant to slot in later as thin mcpplug
|
|
3
|
+
// ToolSource adapters, same shape, once this mechanism is proven) against
|
|
4
|
+
// an agent, without hand-writing a tools/index.ts. The registry is
|
|
5
|
+
// agents/<name>/gateway-tools.yml — plain data, read fresh off disk every
|
|
6
|
+
// call, same "no restart to see an edit" behavior actauth.yml already
|
|
7
|
+
// has (see run-agent.ts's loadRules). adapters/http.ts's admin routes are
|
|
8
|
+
// the only intended writer of that file; loadGatewayToolsFromDir is what
|
|
9
|
+
// run-agent.ts calls to actually resolve it into ToolDefinitions.
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { execFile } from 'node:child_process';
|
|
14
|
+
import { promisify } from 'node:util';
|
|
15
|
+
import { parse as parseYaml, parseDocument, stringify as stringifyYaml } from 'yaml';
|
|
16
|
+
import { connectComposioSource } from 'mcpplug';
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
// Same "one level up from this file, not process.cwd()" reasoning
|
|
19
|
+
// run-agent.ts's own agentsRootDir uses (this file lives in core/
|
|
20
|
+
// alongside it, with agents/ one level up at repo root either way) — real
|
|
21
|
+
// code (an actauth.yml write, a gateway-tools.yml write) needs to land in
|
|
22
|
+
// the same place a built dist/ would look for it, not wherever the
|
|
23
|
+
// process happened to be launched from.
|
|
24
|
+
const agentsRootDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'agents');
|
|
25
|
+
function gatewayToolsPath(dir) {
|
|
26
|
+
return join(dir, 'gateway-tools.yml');
|
|
27
|
+
}
|
|
28
|
+
/** A top-level agent's real folder — exported so callers resolving other
|
|
29
|
+
* per-agent paths (adapters/http.ts's describeAgent, computing the same
|
|
30
|
+
* `dir` to hand to loadGatewayToolsFromDir) use the exact same base this
|
|
31
|
+
* module's own registry read/writes do, rather than re-deriving it (e.g.
|
|
32
|
+
* from process.cwd()) and risking the two drifting apart. */
|
|
33
|
+
export function agentDir(agentName) {
|
|
34
|
+
return join(agentsRootDir, agentName);
|
|
35
|
+
}
|
|
36
|
+
/** `dir` is the agent's real folder — `agents/<name>` for a top-level
|
|
37
|
+
* agent, or the nested `agents/<parent>/subagents/<child>` a subagent
|
|
38
|
+
* actually lives in (see run-agent.ts's resolveSubagentConfig, which
|
|
39
|
+
* hit this exact same name-vs-folder distinction for tools/rules/
|
|
40
|
+
* skillsDirs). Missing file is just `[]`, same convention every other
|
|
41
|
+
* folder-form default in this repo gets. */
|
|
42
|
+
export function readGatewayToolsFromDir(dir) {
|
|
43
|
+
const path = gatewayToolsPath(dir);
|
|
44
|
+
if (!existsSync(path))
|
|
45
|
+
return [];
|
|
46
|
+
const raw = (parseYaml(readFileSync(path, 'utf8')) ?? {});
|
|
47
|
+
return raw.sources ?? [];
|
|
48
|
+
}
|
|
49
|
+
/** Convenience wrapper over readGatewayToolsFromDir for the common,
|
|
50
|
+
* top-level-agent case — what adapters/http.ts's admin routes actually
|
|
51
|
+
* call, since GET /agents/:name/gateway-tools only ever addresses a
|
|
52
|
+
* top-level agent (getEntry(name) has no notion of a subagent path). */
|
|
53
|
+
export function readGatewayTools(agentName) {
|
|
54
|
+
return readGatewayToolsFromDir(agentDir(agentName));
|
|
55
|
+
}
|
|
56
|
+
function writeGatewayToolsFile(dir, sources) {
|
|
57
|
+
mkdirSync(dir, { recursive: true });
|
|
58
|
+
writeFileSync(gatewayToolsPath(dir), stringifyYaml({ sources }));
|
|
59
|
+
}
|
|
60
|
+
// Re-reads and writes gateway-tools.yml fresh, not off whatever snapshot
|
|
61
|
+
// the caller (connectEntry/describeGatewayTools) originally read — a
|
|
62
|
+
// live resolution can take real seconds (mcpplug's per-slug CLI round
|
|
63
|
+
// trips), long enough for an admin-UI edit (add/remove a slug) to land
|
|
64
|
+
// in between. Merged into the source's existing `resolved`, not
|
|
65
|
+
// replaced, so two overlapping resolutions of different slug subsets
|
|
66
|
+
// (e.g. connectEntry resolving one newly-added slug while
|
|
67
|
+
// describeGatewayTools resolves another) don't clobber each other.
|
|
68
|
+
// Silently a no-op if the source itself is gone by the time this runs —
|
|
69
|
+
// nothing left to persist onto.
|
|
70
|
+
function persistResolvedTools(dir, sourceName, newlyResolved) {
|
|
71
|
+
if (!Object.keys(newlyResolved).length)
|
|
72
|
+
return;
|
|
73
|
+
const sources = readGatewayToolsFromDir(dir);
|
|
74
|
+
const index = sources.findIndex((s) => s.name === sourceName);
|
|
75
|
+
if (index === -1)
|
|
76
|
+
return;
|
|
77
|
+
const existing = sources[index];
|
|
78
|
+
const updated = [...sources];
|
|
79
|
+
updated[index] = { ...existing, resolved: { ...existing.resolved, ...newlyResolved } };
|
|
80
|
+
writeGatewayToolsFile(dir, updated);
|
|
81
|
+
}
|
|
82
|
+
export class GatewayToolExistsError extends Error {
|
|
83
|
+
}
|
|
84
|
+
export class GatewayToolNotFoundError extends Error {
|
|
85
|
+
}
|
|
86
|
+
// A toolkit's own action-naming convention, not this repo's — Composio
|
|
87
|
+
// slugs are consistently VERB_REST (LIST_REPOS, CREATE_ISSUE, ...), just
|
|
88
|
+
// not consistently *which* verb, so this is a heuristic, not a spec.
|
|
89
|
+
// Scans every underscore-separated token (not just the first) since the
|
|
90
|
+
// verb isn't always right after the toolkit prefix — GITHUB_LIST_REPOS
|
|
91
|
+
// and github_GITHUB_LIST_REPOS (the ${entry.name}_${slug} form
|
|
92
|
+
// appendActauthRules actually calls this with) both still hit LIST.
|
|
93
|
+
const READ_ONLY_VERBS = new Set([
|
|
94
|
+
'LIST',
|
|
95
|
+
'GET',
|
|
96
|
+
'FETCH',
|
|
97
|
+
'FIND',
|
|
98
|
+
'SEARCH',
|
|
99
|
+
'READ',
|
|
100
|
+
'RETRIEVE',
|
|
101
|
+
'VIEW',
|
|
102
|
+
'CHECK',
|
|
103
|
+
'SHOW',
|
|
104
|
+
'DESCRIBE',
|
|
105
|
+
'QUERY',
|
|
106
|
+
'COUNT',
|
|
107
|
+
'EXPORT',
|
|
108
|
+
]);
|
|
109
|
+
const MUTATING_VERBS = new Set([
|
|
110
|
+
'CREATE',
|
|
111
|
+
'ADD',
|
|
112
|
+
'UPDATE',
|
|
113
|
+
'EDIT',
|
|
114
|
+
'MODIFY',
|
|
115
|
+
'DELETE',
|
|
116
|
+
'REMOVE',
|
|
117
|
+
'SEND',
|
|
118
|
+
'POST',
|
|
119
|
+
'WRITE',
|
|
120
|
+
'SET',
|
|
121
|
+
'PUT',
|
|
122
|
+
'PATCH',
|
|
123
|
+
'PUBLISH',
|
|
124
|
+
'UPLOAD',
|
|
125
|
+
'INVITE',
|
|
126
|
+
'ASSIGN',
|
|
127
|
+
'MERGE',
|
|
128
|
+
'CLOSE',
|
|
129
|
+
'CANCEL',
|
|
130
|
+
'ARCHIVE',
|
|
131
|
+
'RESTORE',
|
|
132
|
+
'ENABLE',
|
|
133
|
+
'DISABLE',
|
|
134
|
+
'GRANT',
|
|
135
|
+
'REVOKE',
|
|
136
|
+
'EXECUTE',
|
|
137
|
+
'RUN',
|
|
138
|
+
'TRIGGER',
|
|
139
|
+
'START',
|
|
140
|
+
'STOP',
|
|
141
|
+
'PAY',
|
|
142
|
+
'CHARGE',
|
|
143
|
+
'REFUND',
|
|
144
|
+
'TRANSFER',
|
|
145
|
+
'IMPORT',
|
|
146
|
+
'MOVE',
|
|
147
|
+
'COPY',
|
|
148
|
+
'DUPLICATE',
|
|
149
|
+
'CLONE',
|
|
150
|
+
'RESET',
|
|
151
|
+
'REPLACE',
|
|
152
|
+
'ATTACH',
|
|
153
|
+
'DETACH',
|
|
154
|
+
'LOCK',
|
|
155
|
+
'UNLOCK',
|
|
156
|
+
'BLOCK',
|
|
157
|
+
'UNBLOCK',
|
|
158
|
+
'BAN',
|
|
159
|
+
]);
|
|
160
|
+
/** Whether a gateway tool's name reads as read-only (safe to
|
|
161
|
+
* auto-allow) rather than mutating (should stay at actauth's own
|
|
162
|
+
* default, typically 'ask' or 'deny', until a human explicitly grants
|
|
163
|
+
* it) — see addGatewayTool's 'auto' decision mode, which is what
|
|
164
|
+
* actually calls this. The first token that matches either list wins,
|
|
165
|
+
* scanned in order; a name with no recognized verb at all (an
|
|
166
|
+
* inconsistently-named action, or a toolkit this list hasn't seen yet)
|
|
167
|
+
* defaults to *not* read-only — an unclassifiable tool staying at
|
|
168
|
+
* actauth's default is the safe failure mode, the reverse would silently
|
|
169
|
+
* auto-allow something this heuristic simply doesn't understand. Exported
|
|
170
|
+
* for testing and because adapters/http.ts's picker UI may want to show
|
|
171
|
+
* the same classification before a tool is even added. */
|
|
172
|
+
export function isReadOnlyToolName(toolName) {
|
|
173
|
+
const tokens = toolName.toUpperCase().split('_');
|
|
174
|
+
for (const token of tokens) {
|
|
175
|
+
if (READ_ONLY_VERBS.has(token))
|
|
176
|
+
return true;
|
|
177
|
+
if (MUTATING_VERBS.has(token))
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
/** Seeds one exact-match actauth rule per tool this source produces —
|
|
183
|
+
* ActAuth's own `tool` matching is exact-string, not glob (only `scope`
|
|
184
|
+
* supports wildcards), so "allow everything gh_* produces" isn't
|
|
185
|
+
* expressible as one rule; each `${entry.name}_${slug}` tool name gets
|
|
186
|
+
* its own. Computed from `entry` directly (never a live call to the
|
|
187
|
+
* gateway) so adding a source doesn't require the CLI/provider to be
|
|
188
|
+
* reachable at that exact moment — only actually loading tools later
|
|
189
|
+
* does. Creates agents/<name>/actauth.yml (default_decision: deny) if it
|
|
190
|
+
* doesn't exist yet, same fallback loadRules' own default gets.
|
|
191
|
+
*
|
|
192
|
+
* `decision: 'auto'` seeds an explicit 'allow' rule for tools
|
|
193
|
+
* isReadOnlyToolName reads as safe, and an explicit 'ask' rule for
|
|
194
|
+
* everything else — never no rule at all. Relying on "no rule falls
|
|
195
|
+
* through to actauth's own default_decision" was the first cut of this,
|
|
196
|
+
* but that ties a mutating tool's actual governing decision to whatever
|
|
197
|
+
* default_decision happens to be *at the time someone reads it*: change
|
|
198
|
+
* default_decision later (say from 'ask' to 'allow' for an unrelated
|
|
199
|
+
* reason) and every mutating tool added this way silently becomes
|
|
200
|
+
* allowed too, with nothing in actauth.yml showing that ever happened.
|
|
201
|
+
* An explicit 'ask' rule can't drift like that. This is what the
|
|
202
|
+
* picker's own "select all" uses instead of forcing one decision onto a
|
|
203
|
+
* batch that might mix GITHUB_LIST_REPOS with GITHUB_DELETE_REPO.
|
|
204
|
+
*
|
|
205
|
+
* Edits via `yaml`'s Document API (parse → mutate the CST → toString),
|
|
206
|
+
* not parse-into-a-plain-object → re-stringify — the latter silently
|
|
207
|
+
* drops every comment in the file, which for a real, hand-maintained
|
|
208
|
+
* actauth.yml (see agents/customer-service/actauth.yml's own extensive
|
|
209
|
+
* rule-by-rule reasoning) would mean adding one gateway tool wipes out
|
|
210
|
+
* documentation an operator wrote for entirely unrelated rules. */
|
|
211
|
+
function appendActauthRules(dir, toolNames, decision, provider) {
|
|
212
|
+
const path = join(dir, 'actauth.yml');
|
|
213
|
+
const doc = existsSync(path) ? parseDocument(readFileSync(path, 'utf8')) : parseDocument('default_decision: deny\nrules: []\n');
|
|
214
|
+
if (doc.get('rules') == null)
|
|
215
|
+
doc.set('rules', []);
|
|
216
|
+
const rules = doc.get('rules');
|
|
217
|
+
for (const tool of toolNames) {
|
|
218
|
+
const resolved = decision === 'auto' ? (isReadOnlyToolName(tool) ? 'allow' : 'ask') : decision;
|
|
219
|
+
// Prefixed with the *provider* (e.g. 'composio'), not the source's
|
|
220
|
+
// own local `name` (e.g. 'github') — `tool` already embeds the
|
|
221
|
+
// source name (`${entry.name}_${slug}`), so repeating it in the
|
|
222
|
+
// rule name too would be redundant; the provider is the one piece
|
|
223
|
+
// of context `tool` doesn't already carry.
|
|
224
|
+
rules.add(doc.createNode({ name: `${provider}-${tool}-web-added`, scope: '*/*', tool, decision: resolved }));
|
|
225
|
+
}
|
|
226
|
+
mkdirSync(dir, { recursive: true });
|
|
227
|
+
writeFileSync(path, doc.toString());
|
|
228
|
+
}
|
|
229
|
+
/** The inverse of appendActauthRules — strips any rule it previously
|
|
230
|
+
* wrote for one of `toolNames`, recognized by the exact `-web-added`
|
|
231
|
+
* name suffix that function always writes, so a hand-authored rule that
|
|
232
|
+
* happens to reference the same tool (a different name, no suffix) is
|
|
233
|
+
* never touched. Called from removeGatewayTool/removeGatewayToolSlug so
|
|
234
|
+
* removing a tool also removes the permission it was granted, rather
|
|
235
|
+
* than leaving a rule that now governs nothing. A no-op, not an error,
|
|
236
|
+
* if actauth.yml doesn't exist or has no matching rule — removing a tool
|
|
237
|
+
* that was never given a seeded rule (decision omitted when it was
|
|
238
|
+
* added) is a completely normal case, not a problem to report. */
|
|
239
|
+
function removeAutoAddedActauthRules(dir, toolNames) {
|
|
240
|
+
const path = join(dir, 'actauth.yml');
|
|
241
|
+
if (!existsSync(path))
|
|
242
|
+
return;
|
|
243
|
+
const doc = parseDocument(readFileSync(path, 'utf8'));
|
|
244
|
+
const rules = doc.get('rules');
|
|
245
|
+
if (!rules)
|
|
246
|
+
return;
|
|
247
|
+
const before = rules.items.length;
|
|
248
|
+
rules.items = rules.items.filter((rule) => {
|
|
249
|
+
const name = rule.get('name');
|
|
250
|
+
const tool = rule.get('tool');
|
|
251
|
+
return !(typeof name === 'string' && name.endsWith('-web-added') && typeof tool === 'string' && toolNames.includes(tool));
|
|
252
|
+
});
|
|
253
|
+
if (rules.items.length === before)
|
|
254
|
+
return;
|
|
255
|
+
writeFileSync(path, doc.toString());
|
|
256
|
+
}
|
|
257
|
+
function toolNamesFor(entry) {
|
|
258
|
+
return entry.slugs.map((slug) => `${entry.name}_${slug}`);
|
|
259
|
+
}
|
|
260
|
+
/** Registers a new gateway tool for `agentName` (top-level only — see
|
|
261
|
+
* readGatewayTools), or, if a source with this exact `name` is already
|
|
262
|
+
* registered, merges `entry.slugs` into it (deduped) instead of
|
|
263
|
+
* rejecting the call — the name is just a namespace label, not a
|
|
264
|
+
* one-shot slot; reusing it to add more tools under the same source is
|
|
265
|
+
* the common case (picking a few more actions for an app you already
|
|
266
|
+
* added), not a conflict. It's only a real conflict when the existing
|
|
267
|
+
* entry is for a *different* provider — two different connection
|
|
268
|
+
* configs can't share one name. `decision`, if given, pre-seeds an
|
|
269
|
+
* actauth rule per tool in *this call's own* `entry.slugs` (see
|
|
270
|
+
* appendActauthRules) — not the merged list, so re-adding an
|
|
271
|
+
* already-registered slug doesn't also re-seed a duplicate rule for it.
|
|
272
|
+
* Omit `decision` to leave every new tool at actauth's own
|
|
273
|
+
* defaultDecision (typically 'deny'), the same "new tools are opt-in,
|
|
274
|
+
* not silently allowed" convention every other tool in this repo
|
|
275
|
+
* follows. `decision: 'auto'` — see appendActauthRules — resolves per
|
|
276
|
+
* tool instead of applying one decision to the whole batch. */
|
|
277
|
+
export function addGatewayTool(agentName, entry, decision) {
|
|
278
|
+
const dir = agentDir(agentName);
|
|
279
|
+
const sources = readGatewayToolsFromDir(dir);
|
|
280
|
+
const existingIndex = sources.findIndex((s) => s.name === entry.name);
|
|
281
|
+
if (existingIndex === -1) {
|
|
282
|
+
writeGatewayToolsFile(dir, [...sources, entry]);
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
const existing = sources[existingIndex];
|
|
286
|
+
if (existing.provider !== entry.provider) {
|
|
287
|
+
throw new GatewayToolExistsError(`A gateway tool named '${entry.name}' already exists for '${agentName}' with a different provider ('${existing.provider}' vs '${entry.provider}') — pick a different name or remove it first.`);
|
|
288
|
+
}
|
|
289
|
+
const mergedSlugs = Array.from(new Set([...existing.slugs, ...entry.slugs]));
|
|
290
|
+
const updated = [...sources];
|
|
291
|
+
updated[existingIndex] = { ...existing, ...entry, slugs: mergedSlugs };
|
|
292
|
+
writeGatewayToolsFile(dir, updated);
|
|
293
|
+
}
|
|
294
|
+
if (decision)
|
|
295
|
+
appendActauthRules(dir, toolNamesFor(entry), decision, entry.provider);
|
|
296
|
+
}
|
|
297
|
+
/** Removes a gateway tool's registration, along with any actauth rule
|
|
298
|
+
* appendActauthRules previously seeded for it (see
|
|
299
|
+
* removeAutoAddedActauthRules — a hand-authored rule referencing the
|
|
300
|
+
* same tool under a different name is never touched). Removing a tool
|
|
301
|
+
* is expected to remove the permission it was granted too, not leave a
|
|
302
|
+
* rule that governs nothing behind. */
|
|
303
|
+
export function removeGatewayTool(agentName, sourceName) {
|
|
304
|
+
const dir = agentDir(agentName);
|
|
305
|
+
const sources = readGatewayToolsFromDir(dir);
|
|
306
|
+
const existing = sources.find((s) => s.name === sourceName);
|
|
307
|
+
if (!existing) {
|
|
308
|
+
throw new GatewayToolNotFoundError(`No gateway tool named '${sourceName}' for '${agentName}'.`);
|
|
309
|
+
}
|
|
310
|
+
writeGatewayToolsFile(dir, sources.filter((s) => s.name !== sourceName));
|
|
311
|
+
removeAutoAddedActauthRules(dir, toolNamesFor(existing));
|
|
312
|
+
}
|
|
313
|
+
/** Removes one slug from an existing source — the per-tool remove icon
|
|
314
|
+
* in the Tools tab's Gateway Tools list, as opposed to removeGatewayTool
|
|
315
|
+
* above, which drops the whole source at once. Deletes the source
|
|
316
|
+
* entirely if that was its last slug: a source with zero slugs produces
|
|
317
|
+
* zero tools anyway (mcpplug's connectComposioSource loops per slug, see
|
|
318
|
+
* connectEntry), so there's nothing left worth keeping registered. Same
|
|
319
|
+
* "also remove any rule this tool was auto-granted" behavior as
|
|
320
|
+
* removeGatewayTool — see its own doc comment and
|
|
321
|
+
* removeAutoAddedActauthRules. */
|
|
322
|
+
export function removeGatewayToolSlug(agentName, sourceName, slug) {
|
|
323
|
+
const dir = agentDir(agentName);
|
|
324
|
+
const sources = readGatewayToolsFromDir(dir);
|
|
325
|
+
const index = sources.findIndex((s) => s.name === sourceName);
|
|
326
|
+
if (index === -1 || !sources[index].slugs.includes(slug)) {
|
|
327
|
+
throw new GatewayToolNotFoundError(`No gateway tool '${slug}' registered under '${sourceName}' for '${agentName}'.`);
|
|
328
|
+
}
|
|
329
|
+
const remainingSlugs = sources[index].slugs.filter((s) => s !== slug);
|
|
330
|
+
// Drops the removed slug's own resolved entry too — leaving it behind
|
|
331
|
+
// would just be dead weight (nothing in `slugs` references it anymore,
|
|
332
|
+
// so it can never be read back), and would silently reappear if the
|
|
333
|
+
// exact same slug were ever re-added later, skipping what should be a
|
|
334
|
+
// fresh resolution. Omitted entirely (not `resolved: {}`) once empty —
|
|
335
|
+
// a source that never had any resolved slugs shouldn't gain a stray
|
|
336
|
+
// empty field just because one was removed from it.
|
|
337
|
+
const { [slug]: _removed, ...remainingResolved } = sources[index].resolved ?? {};
|
|
338
|
+
const updated = remainingSlugs.length === 0
|
|
339
|
+
? sources.filter((_, i) => i !== index)
|
|
340
|
+
: sources.map((s, i) => {
|
|
341
|
+
if (i !== index)
|
|
342
|
+
return s;
|
|
343
|
+
const next = { ...s, slugs: remainingSlugs };
|
|
344
|
+
if (Object.keys(remainingResolved).length)
|
|
345
|
+
next.resolved = remainingResolved;
|
|
346
|
+
else
|
|
347
|
+
delete next.resolved;
|
|
348
|
+
return next;
|
|
349
|
+
});
|
|
350
|
+
writeGatewayToolsFile(dir, updated);
|
|
351
|
+
removeAutoAddedActauthRules(dir, [`${sources[index].name}_${slug}`]);
|
|
352
|
+
}
|
|
353
|
+
// Builds a ToolDefinition straight from gateway-tools.yml's own persisted
|
|
354
|
+
// `resolved` entry — no CLI call at all for the schema/description, since
|
|
355
|
+
// those are exactly what's already cached. execute() itself still always
|
|
356
|
+
// shells out fresh (mirrors mcpplug's composio-source.ts own execute
|
|
357
|
+
// closure exactly — resolving *what a tool is* is cacheable, actually
|
|
358
|
+
// *running* it never is).
|
|
359
|
+
function toolFromResolved(slug, resolved, cliCommand) {
|
|
360
|
+
return {
|
|
361
|
+
name: resolved.name,
|
|
362
|
+
description: resolved.description,
|
|
363
|
+
input_schema: resolved.input_schema,
|
|
364
|
+
execute: async (input) => {
|
|
365
|
+
const result = (await runComposioCli(cliCommand ?? 'composio', ['execute', slug, '-d', JSON.stringify(input)]));
|
|
366
|
+
if (result.successful === false || result.error) {
|
|
367
|
+
throw new Error(`composio tool ${slug} failed: ${JSON.stringify(result.error ?? result)}`);
|
|
368
|
+
}
|
|
369
|
+
return result.data ?? result;
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
// The only thing that still needs mcpplug/a live Composio round trip —
|
|
374
|
+
// slugs with no persisted resolution yet (just registered, or a `force`
|
|
375
|
+
// re-check). Returns [] without touching the CLI at all when there's
|
|
376
|
+
// nothing left to resolve, which is the common case once a source's
|
|
377
|
+
// slugs have all been resolved once.
|
|
378
|
+
async function fetchUnresolvedSlugs(entry, slugs) {
|
|
379
|
+
if (!slugs.length)
|
|
380
|
+
return [];
|
|
381
|
+
const source = await connectComposioSource(entry.name, { slugs, cliCommand: entry.cliCommand });
|
|
382
|
+
try {
|
|
383
|
+
return await source.loadTools();
|
|
384
|
+
}
|
|
385
|
+
finally {
|
|
386
|
+
await source.close();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/** Resolves `entry` into real ToolDefinitions for run-agent.ts to merge
|
|
390
|
+
* in, preferring gateway-tools.yml's own persisted `resolved` data (see
|
|
391
|
+
* ComposioGatewayToolEntry's own doc comment) over asking Composio again —
|
|
392
|
+
* mcpplug's connectComposioSource (and the ~3s-per-slug CLI round trip it
|
|
393
|
+
* costs) is only ever invoked for slugs that aren't resolved yet. Newly
|
|
394
|
+
* resolved slugs are persisted back before returning, so the *next* call
|
|
395
|
+
* (even after a restart) skips the live fetch entirely too. */
|
|
396
|
+
async function connectEntry(entry, dir) {
|
|
397
|
+
if (entry.provider === 'composio') {
|
|
398
|
+
const resolved = entry.resolved ?? {};
|
|
399
|
+
const cachedTools = entry.slugs.filter((slug) => resolved[slug]).map((slug) => toolFromResolved(slug, resolved[slug], entry.cliCommand));
|
|
400
|
+
const unresolvedSlugs = entry.slugs.filter((slug) => !resolved[slug]);
|
|
401
|
+
const freshTools = await fetchUnresolvedSlugs(entry, unresolvedSlugs);
|
|
402
|
+
if (freshTools.length) {
|
|
403
|
+
const newlyResolved = {};
|
|
404
|
+
freshTools.forEach((tool, i) => {
|
|
405
|
+
newlyResolved[unresolvedSlugs[i]] = { name: tool.name, description: tool.description, input_schema: tool.input_schema };
|
|
406
|
+
});
|
|
407
|
+
persistResolvedTools(dir, entry.name, newlyResolved);
|
|
408
|
+
}
|
|
409
|
+
return [...cachedTools, ...freshTools];
|
|
410
|
+
}
|
|
411
|
+
// Unreachable while GatewayToolEntry is a union of one — the check (and
|
|
412
|
+
// the cast it needs) starts earning its keep the moment a second
|
|
413
|
+
// provider is added and a stale gateway-tools.yml entry from before
|
|
414
|
+
// that provider existed needs a real error, not a silent skip.
|
|
415
|
+
throw new Error(`Unknown gateway tool provider '${entry.provider}'.`);
|
|
416
|
+
}
|
|
417
|
+
/** mcpplug's own connectComposioSource fills each ToolDefinition's
|
|
418
|
+
* description with a mechanically humanized slug (e.g. "abort repository
|
|
419
|
+
* migration") — the only thing it has to work with is `composio execute
|
|
420
|
+
* <slug> --get-schema`, which has no top-level description field, only
|
|
421
|
+
* per-input ones. Composio's own catalog (`composio tools list
|
|
422
|
+
* <toolkit>`) has a genuinely useful one instead (e.g. "Tool to abort a
|
|
423
|
+
* repository migration that is queued or in progress. Use when you need
|
|
424
|
+
* to cancel an ongoing migration operation.") — worth fetching for
|
|
425
|
+
* describeGatewayTools specifically — only paid once per slug, right
|
|
426
|
+
* before persisting it (see ComposioGatewayToolEntry's own `resolved`
|
|
427
|
+
* doc comment), not on every load. Deliberately *not* applied inside
|
|
428
|
+
* connectEntry itself, even though
|
|
429
|
+
* that would also improve what the model actually sees at runtime —
|
|
430
|
+
* connectEntry is loadGatewayToolsFromDir's hot path, and that function's
|
|
431
|
+
* own doc comment already explicitly rejects paying any extra
|
|
432
|
+
* reconnect/network cost there beyond what's unavoidable; confirmed live
|
|
433
|
+
* that adding one more CLI call per distinct toolkit on every cache-miss
|
|
434
|
+
* measurably slowed it down enough to blow real test timeouts.
|
|
435
|
+
*
|
|
436
|
+
* Toolkit is derived from each slug's own TOOLKIT_ACTION naming
|
|
437
|
+
* convention, not from `entry.name` — that's just a local namespace
|
|
438
|
+
* label an operator can set to anything (see ComposioGatewayToolEntry's
|
|
439
|
+
* own doc comment), so it can't be trusted to name a real toolkit.
|
|
440
|
+
* Best-effort per toolkit: a lookup failing (unusual slug shape, a
|
|
441
|
+
* toolkit no longer connected) just leaves those tools at their
|
|
442
|
+
* humanize() fallback rather than failing the whole call — same
|
|
443
|
+
* fail-open reasoning loadGatewayToolsFromDir's own doc comment already
|
|
444
|
+
* uses for a broken source. */
|
|
445
|
+
async function withRealComposioDescriptions(tools, slugs, cliCommand) {
|
|
446
|
+
const toolkits = Array.from(new Set(slugs.map((slug) => slug.split('_')[0].toLowerCase())));
|
|
447
|
+
const descriptions = new Map();
|
|
448
|
+
await Promise.all(toolkits.map(async (toolkit) => {
|
|
449
|
+
try {
|
|
450
|
+
const catalog = await listComposioTools(toolkit, cliCommand);
|
|
451
|
+
for (const t of catalog)
|
|
452
|
+
descriptions.set(t.slug, t.description);
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
// Best-effort — see this function's own doc comment.
|
|
456
|
+
}
|
|
457
|
+
}));
|
|
458
|
+
return tools.map((tool, i) => {
|
|
459
|
+
const real = descriptions.get(slugs[i]);
|
|
460
|
+
return real ? { ...tool, description: real } : tool;
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
/** The admin page's read model — every registered source for `agentName`,
|
|
464
|
+
* each actually connected to and reported on individually, same
|
|
465
|
+
* fail-open-per-source behavior loadGatewayToolsFromDir has (see its own
|
|
466
|
+
* doc comment). The difference is what happens with a failure: this
|
|
467
|
+
* function surfaces it explicitly, per source, since an operator looking
|
|
468
|
+
* at this page needs to know *which* source is broken and why — where
|
|
469
|
+
* loadGatewayToolsFromDir just logs and drops it, since runAgent() has no
|
|
470
|
+
* structured place to hand a per-source error to.
|
|
471
|
+
*
|
|
472
|
+
* No in-memory cache here (or anywhere in this file, for this) — the
|
|
473
|
+
* cache *is* gateway-tools.yml's own `resolved` field (see
|
|
474
|
+
* ComposioGatewayToolEntry's own doc comment and connectEntry, which this
|
|
475
|
+
* shares that field with), so it survives a restart instead of being
|
|
476
|
+
* lost on one. Only genuinely-unresolved slugs pay for a live fetch, and
|
|
477
|
+
* for those this still enriches with Composio's real catalog description
|
|
478
|
+
* (see withRealComposioDescriptions) before persisting — unlike
|
|
479
|
+
* connectEntry, which doesn't (see that function's own reasoning), this
|
|
480
|
+
* is an occasional operator action, not runAgent()'s hot path, so paying
|
|
481
|
+
* for the better description once, at resolution time, is worth it here.
|
|
482
|
+
* Pass `force: true` (the admin page's own "Refresh" button) to treat
|
|
483
|
+
* every slug as unresolved and overwrite their persisted entries with a
|
|
484
|
+
* fresh live check — e.g. after reconnecting an account, or on the rare
|
|
485
|
+
* chance a schema changed upstream without gateway-tools.yml itself
|
|
486
|
+
* changing. */
|
|
487
|
+
export async function describeGatewayTools(agentName, options = {}) {
|
|
488
|
+
const dir = agentDir(agentName);
|
|
489
|
+
const entries = readGatewayTools(agentName);
|
|
490
|
+
return Promise.all(entries.map(async (entry) => {
|
|
491
|
+
try {
|
|
492
|
+
const resolved = entry.resolved ?? {};
|
|
493
|
+
const cachedTools = options.force ? [] : entry.slugs.filter((slug) => resolved[slug]).map((slug) => toolFromResolved(slug, resolved[slug], entry.cliCommand));
|
|
494
|
+
const unresolvedSlugs = options.force ? entry.slugs : entry.slugs.filter((slug) => !resolved[slug]);
|
|
495
|
+
const rawFreshTools = await fetchUnresolvedSlugs(entry, unresolvedSlugs);
|
|
496
|
+
const freshTools = entry.provider === 'composio' ? await withRealComposioDescriptions(rawFreshTools, unresolvedSlugs, entry.cliCommand) : rawFreshTools;
|
|
497
|
+
if (freshTools.length) {
|
|
498
|
+
const newlyResolved = {};
|
|
499
|
+
freshTools.forEach((tool, i) => {
|
|
500
|
+
newlyResolved[unresolvedSlugs[i]] = { name: tool.name, description: tool.description, input_schema: tool.input_schema };
|
|
501
|
+
});
|
|
502
|
+
persistResolvedTools(dir, entry.name, newlyResolved);
|
|
503
|
+
}
|
|
504
|
+
const tools = [...cachedTools, ...freshTools];
|
|
505
|
+
return { entry, status: 'ok', tools: tools.map((t) => ({ name: t.name, description: t.description })) };
|
|
506
|
+
}
|
|
507
|
+
catch (err) {
|
|
508
|
+
return { entry, status: 'error', tools: [], error: err instanceof Error ? err.message : String(err) };
|
|
509
|
+
}
|
|
510
|
+
}));
|
|
511
|
+
}
|
|
512
|
+
/** In-memory, keyed by the registry file's own mtime — cheap to check
|
|
513
|
+
* every call (one stat), and correct: it can only go stale if the file
|
|
514
|
+
* changes without its mtime changing, which no real filesystem does.
|
|
515
|
+
* Real cost here is connectEntry's own CLI subprocess-per-slug (see
|
|
516
|
+
* mcpplug's composio-source.ts) — reconnecting on every single
|
|
517
|
+
* runAgent() turn would be a real, unnecessary latency tax the rest of
|
|
518
|
+
* this repo's folder-form defaults (loadDefaultTools, loadRules) don't
|
|
519
|
+
* have to worry about, since those are plain reads/imports, not a
|
|
520
|
+
* network/subprocess round trip. */
|
|
521
|
+
const cache = new Map();
|
|
522
|
+
/** Resolves agents/<name>/gateway-tools.yml (or the equivalent nested path
|
|
523
|
+
* for a subagent — see readGatewayToolsFromDir) into real ToolDefinitions,
|
|
524
|
+
* for run-agent.ts to merge into the rest of an agent's tools. Missing
|
|
525
|
+
* file is `[]`, no cache entry created.
|
|
526
|
+
*
|
|
527
|
+
* Fails open per source: one broken/unreachable gateway (Composio down,
|
|
528
|
+
* a CLI not authenticated) is logged and contributes no tools, rather
|
|
529
|
+
* than rejecting the whole call — the same reasoning a single tool's own
|
|
530
|
+
* `execute()` failing doesn't crash runAgent's whole turn (ToolLane
|
|
531
|
+
* reports it as an is_error tool_result instead). Without this, an agent
|
|
532
|
+
* with an unrelated broken integration would fail *every* request, tools
|
|
533
|
+
* it actually needs included — confirmed live: a run-agent.test.ts test
|
|
534
|
+
* that reuses the real 'file-agent' name purely to exercise the
|
|
535
|
+
* skillsDirs default started silently depending on the real `composio`
|
|
536
|
+
* CLI/network the moment agents/file-agent/gateway-tools.yml existed,
|
|
537
|
+
* before this fix made a failed (or slow/unavailable) source harmless
|
|
538
|
+
* instead of fatal. */
|
|
539
|
+
export async function loadGatewayToolsFromDir(dir) {
|
|
540
|
+
const path = gatewayToolsPath(dir);
|
|
541
|
+
if (!existsSync(path))
|
|
542
|
+
return [];
|
|
543
|
+
const mtimeMs = statSync(path).mtimeMs;
|
|
544
|
+
const cached = cache.get(path);
|
|
545
|
+
if (cached && cached.mtimeMs === mtimeMs)
|
|
546
|
+
return cached.tools;
|
|
547
|
+
const entries = readGatewayToolsFromDir(dir);
|
|
548
|
+
const resolved = await Promise.all(entries.map(async (entry) => {
|
|
549
|
+
try {
|
|
550
|
+
return await connectEntry(entry, dir);
|
|
551
|
+
}
|
|
552
|
+
catch (err) {
|
|
553
|
+
console.error(`[gateway-tools] source '${entry.name}' (${entry.provider}) failed to load, skipping: ${err instanceof Error ? err.message : String(err)}`);
|
|
554
|
+
return [];
|
|
555
|
+
}
|
|
556
|
+
}));
|
|
557
|
+
const tools = resolved.flat();
|
|
558
|
+
cache.set(path, { mtimeMs, tools });
|
|
559
|
+
return tools;
|
|
560
|
+
}
|
|
561
|
+
// ----- Discovery: what's already connected, what a connected app offers
|
|
562
|
+
// -----
|
|
563
|
+
//
|
|
564
|
+
// The add-a-source form used to ask an operator to already know a
|
|
565
|
+
// toolkit's exact slug strings by heart — real friction, and the only
|
|
566
|
+
// reason was that nothing in this file had actually shelled out to check
|
|
567
|
+
// what `composio connections list` / `composio tools list <toolkit>`
|
|
568
|
+
// return. Both work off the same `composio link <toolkit>` auth
|
|
569
|
+
// connectComposioSource already assumes — no extra "developer project"
|
|
570
|
+
// setup (`composio dev init`) needed, confirmed against a real CLI
|
|
571
|
+
// before building this.
|
|
572
|
+
async function runComposioCli(cliCommand, args) {
|
|
573
|
+
let stdout;
|
|
574
|
+
try {
|
|
575
|
+
;
|
|
576
|
+
({ stdout } = await execFileAsync(cliCommand, args));
|
|
577
|
+
}
|
|
578
|
+
catch (err) {
|
|
579
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
580
|
+
throw new Error(`composio CLI call failed (${args.join(' ')}): ${message}`);
|
|
581
|
+
}
|
|
582
|
+
try {
|
|
583
|
+
return JSON.parse(stdout);
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
throw new Error(`composio CLI returned non-JSON output for ${args.join(' ')}: ${stdout}`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
/** Flattens `composio connections list`'s own shape (a map of toolkit
|
|
590
|
+
* slug -> array of accounts) into one array — easier for a caller (the
|
|
591
|
+
* add-source picker) to filter/sort without knowing that shape itself.
|
|
592
|
+
* Every toolkit with at least one connected account appears, `status`
|
|
593
|
+
* and all; deciding what counts as "usable" (typically just `'ACTIVE'`)
|
|
594
|
+
* is left to the caller; a CLI/auth failure propagates as a normal
|
|
595
|
+
* rejection — see runComposioCli. */
|
|
596
|
+
export async function listComposioConnections(cliCommand = 'composio') {
|
|
597
|
+
const raw = (await runComposioCli(cliCommand, ['connections', 'list']));
|
|
598
|
+
const connections = [];
|
|
599
|
+
for (const [toolkit, accounts] of Object.entries(raw)) {
|
|
600
|
+
for (const account of accounts) {
|
|
601
|
+
connections.push({ toolkit, status: account.status, alias: account.alias ?? null, wordId: account.word_id ?? null });
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return connections;
|
|
605
|
+
}
|
|
606
|
+
/** What `composio tools list <toolkit>` offers for one already-connected
|
|
607
|
+
* app — the picker's second step, once a toolkit is chosen from
|
|
608
|
+
* listComposioConnections. Every action a toolkit exposes, not just ones
|
|
609
|
+
* already registered as a gateway tool source — the picker's whole point
|
|
610
|
+
* is showing what's *available* to add. `--limit 1000` (the CLI's own
|
|
611
|
+
* max) is explicit rather than left to the CLI's own default: without
|
|
612
|
+
* it, `composio tools list github` silently caps at 30 results — out of
|
|
613
|
+
* GitHub's real ~900, and alphabetically sorted, so a picker session
|
|
614
|
+
* would only ever see a narrow, unrepresentative slice (every result
|
|
615
|
+
* starting with "A" — ABORT/ACCEPT/ADD/API/APPROVE/ASSIGN — almost all
|
|
616
|
+
* mutating verbs), not "every tool this toolkit has" the doc comment
|
|
617
|
+
* above already promises. Confirmed live against the real CLI: no
|
|
618
|
+
* --limit gave 30, --limit 1000 gave 893. */
|
|
619
|
+
export async function listComposioTools(toolkit, cliCommand = 'composio') {
|
|
620
|
+
const raw = (await runComposioCli(cliCommand, ['tools', 'list', toolkit, '--limit', '1000']));
|
|
621
|
+
return raw.map((t) => ({ slug: t.slug, name: t.name, description: t.description }));
|
|
622
|
+
}
|
|
623
|
+
/** Whether the composio CLI itself (not any one connected app — see
|
|
624
|
+
* listComposioConnections for that) is authenticated on this machine.
|
|
625
|
+
* `composio whoami` succeeds once `composio login` has run and fails
|
|
626
|
+
* otherwise, so a failure here is read as "not connected", not
|
|
627
|
+
* propagated as an error the way listComposioConnections' own CLI
|
|
628
|
+
* failures are — this is a normal, expected state for an environment
|
|
629
|
+
* that just hasn't been logged in yet, not a bug. Deliberately
|
|
630
|
+
* read-only: login is an interactive, machine-wide CLI session (opens a
|
|
631
|
+
* browser, affects every agent's gateway tools, not just one web
|
|
632
|
+
* request), so the global config page's Gateways panel that calls this
|
|
633
|
+
* only ever shows status + instructions, never drives login/logout
|
|
634
|
+
* itself. */
|
|
635
|
+
export async function getComposioAuthStatus(cliCommand = 'composio') {
|
|
636
|
+
try {
|
|
637
|
+
const raw = (await runComposioCli(cliCommand, ['whoami']));
|
|
638
|
+
return { connected: true, email: raw.email, org: raw.current_org_name };
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
return { connected: false };
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
/** `composio logout` — already non-interactive on its own (see its own
|
|
645
|
+
* --help), unlike login. Machine-wide: this deauthenticates the CLI for
|
|
646
|
+
* every agent's gateway tools on this machine, not just the browser tab
|
|
647
|
+
* that clicked Disconnect — same as running it in a terminal yourself.
|
|
648
|
+
*
|
|
649
|
+
* There's no connectComposioAccount counterpart: `composio login`'s only
|
|
650
|
+
* non-interactive path is `--user-api-key`, which turned out not to be
|
|
651
|
+
* what an operator actually has on hand — the key Composio's own
|
|
652
|
+
* dashboard hands out for MCP client auth (sent via an x-consumer-api-key
|
|
653
|
+
* header) isn't the same credential, and 401s against this flag.
|
|
654
|
+
* Composio's own documented path is plain, interactive `composio login`
|
|
655
|
+
* (opens a browser) after installing the CLI — not something a web
|
|
656
|
+
* request can drive, so the Gateways panel shows that as instructions
|
|
657
|
+
* instead of a form; see its own comment in web/global-config-page.ts. */
|
|
658
|
+
export async function disconnectComposioAccount(cliCommand = 'composio') {
|
|
659
|
+
await execFileAsync(cliCommand, ['logout']);
|
|
660
|
+
}
|