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.
Files changed (40) hide show
  1. package/README.md +67 -6
  2. package/core/system-skills/composio-large-outputs/SKILL.md +25 -0
  3. package/dist/{cli.d.ts → bin/cli.d.ts} +27 -6
  4. package/dist/bin/cli.js +384 -0
  5. package/dist/{agent-as-tool.d.ts → core/agent-as-tool.d.ts} +2 -2
  6. package/dist/{agent-as-tool.js → core/agent-as-tool.js} +1 -1
  7. package/dist/core/client.d.ts +113 -0
  8. package/dist/core/client.js +123 -0
  9. package/dist/core/discover-agents.d.ts +45 -0
  10. package/dist/{discover-agents.js → core/discover-agents.js} +12 -4
  11. package/dist/core/gateway-tools.d.ts +226 -0
  12. package/dist/core/gateway-tools.js +660 -0
  13. package/dist/core/loop-events.d.ts +168 -0
  14. package/dist/core/loop-events.js +1 -0
  15. package/dist/{model-calls → core/model-calls}/anthropic-model-call.d.ts +1 -1
  16. package/dist/{model-calls → core/model-calls}/deepseek-model-call.d.ts +1 -1
  17. package/dist/{model-calls → core/model-calls}/openai-model-call.d.ts +1 -1
  18. package/dist/core/run-agent.d.ts +182 -0
  19. package/dist/{run-agent.js → core/run-agent.js} +233 -51
  20. package/dist/{session-store.d.ts → core/session-store.d.ts} +13 -1
  21. package/dist/{session-store.js → core/session-store.js} +19 -0
  22. package/dist/core/system-tools/ask_user.d.ts +72 -0
  23. package/dist/core/system-tools/ask_user.js +136 -0
  24. package/dist/core/system-tools/index.d.ts +3 -0
  25. package/dist/core/system-tools/index.js +12 -0
  26. package/dist/core/system-tools/read_file.d.ts +28 -0
  27. package/dist/core/system-tools/read_file.js +64 -0
  28. package/dist/index.d.ts +19 -16
  29. package/dist/index.js +16 -9
  30. package/package.json +33 -25
  31. package/dist/cli.js +0 -191
  32. package/dist/discover-agents.d.ts +0 -24
  33. package/dist/run-agent.d.ts +0 -92
  34. /package/dist/{agent-config.d.ts → core/agent-config.d.ts} +0 -0
  35. /package/dist/{agent-config.js → core/agent-config.js} +0 -0
  36. /package/dist/{model-calls → core/model-calls}/anthropic-model-call.js +0 -0
  37. /package/dist/{model-calls → core/model-calls}/deepseek-model-call.js +0 -0
  38. /package/dist/{model-calls → core/model-calls}/openai-model-call.js +0 -0
  39. /package/dist/{vector-index.d.ts → core/vector-index.d.ts} +0 -0
  40. /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
+ }