loopengine 0.0.17 → 0.0.18

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 CHANGED
@@ -258,7 +258,37 @@ A few things worth knowing before reaching for this:
258
258
  spends turns at every level (default 25 each) — a "simple" answer can
259
259
  cost far more than any one agent's own turn budget suggests.
260
260
 
261
- ### 5. Tool permission and multi-tenancy
261
+ ### 5. Gateway tool sources — connecting external tools from a web page
262
+
263
+ Beyond hand-written tools and subagents, an agent can pull tools from an
264
+ external gateway — [Composio](https://composio.dev) today, with
265
+ [`mcpplug`](https://www.npmjs.com/package/mcpplug)'s `ToolSource`
266
+ interface designed so more providers (Nango, Arcade, Scalekit, ...) slot
267
+ in later as thin adapters, same shape. Unlike hand-written tools, these
268
+ are meant to be managed by an operator at runtime, not committed to code:
269
+ run the HTTP adapter, open `/agents/config`, pick an agent, and switch to
270
+ its "Gateway tools" tab — alongside "Overview" and "Actauth" — to see its
271
+ connected sources and add or remove one.
272
+
273
+ ```bash
274
+ npx tsx --env-file-if-exists=.env adapters/http.ts
275
+ # open http://localhost:8787/agents/config
276
+ ```
277
+
278
+ Adding a source (via the page, or `POST /agents/:name/gateway-tools` with
279
+ `{ provider: 'composio', name: 'gh', slugs: [...] }`) writes
280
+ `agents/<name>/gateway-tools.yml` — read fresh off disk on every request,
281
+ same "no restart to see an edit" behavior `actauth.yml` already has.
282
+ Composio's own OAuth is out of band: run `composio link <toolkit>` once
283
+ on the machine running the server, then use the page purely to pick which
284
+ already-authorized slugs to expose to which agent.
285
+
286
+ A newly-added tool is **denied by default**, same "opt-in, not silently
287
+ allowed" rule every tool in this repo follows — pass a `decision` when
288
+ adding a source (or use the page's permission dropdown) to seed an
289
+ `actauth.yml` rule for it instead, or add the rule by hand afterward.
290
+
291
+ ### 6. Tool permission and multi-tenancy
262
292
 
263
293
  Every tool call is gated by [`actauth`](https://www.npmjs.com/package/actauth):
264
294
  each rule in `AgentConfig.rules` maps a `scope` (tenant/environment) + tool
@@ -292,7 +322,7 @@ undefined` — from headers only, never the request body, since it feeds
292
322
  permission decisions directly. No `tenantFor` means every request is the
293
323
  `'default'` tenant.
294
324
 
295
- ### 6. Sessions
325
+ ### 7. Sessions
296
326
 
297
327
  A session is one ongoing conversation. Message history persists between
298
328
  requests automatically — send a message, get a reply, come back later with
@@ -317,7 +347,7 @@ npx tsx adapters/cli.ts --agent customer-service --session s1 "order A-1001 arri
317
347
  **HTTP:**
318
348
 
319
349
  ```bash
320
- npx tsx adapters/http.ts
350
+ npx tsx --env-file-if-exists=.env adapters/http.ts
321
351
  curl -X POST localhost:8787/agents/customer-service/messages \
322
352
  -H 'content-type: application/json' \
323
353
  -d '{"customerEmail":"a@example.com","message":"order A-1001 arrived broken"}'
package/dist/cli.d.ts CHANGED
@@ -8,7 +8,7 @@ export declare class AgentNotFoundError extends Error {
8
8
  export declare function agentIndexTemplate(name: string): string;
9
9
  /** Same shape as agentIndexTemplate, plus `toolDescription` — the one
10
10
  * field a subagent needs that a top-level agent doesn't. run-agent.ts's
11
- * loadSubagentTools wraps this config with agentAsTool, which throws
11
+ * loadSubagentAsTools wraps this config with agentAsTool, which throws
12
12
  * without a real toolDescription: it's what the parent agent's model
13
13
  * reads to decide when to delegate here, and systemPrompt (instructions
14
14
  * for this agent itself) isn't a substitute. See agent-as-tool.ts. */
@@ -21,7 +21,7 @@ export declare function scaffoldAgent(baseDir: string, name: string): Promise<st
21
21
  /** Writes <parentDir>/subagents/<name>/index.ts under baseDir and
22
22
  * returns its path, where <parentDir> is `parent` resolved by
23
23
  * resolveParentDir — the folder convention run-agent.ts's
24
- * loadSubagentTools auto-discovers, so this subagent becomes one of
24
+ * loadSubagentAsTools auto-discovers, so this subagent becomes one of
25
25
  * `parent`'s tools with no further edits: no import, no
26
26
  * AgentConfig.tools entry, nothing to register by hand. Requires
27
27
  * `parent` to already exist (its own index.ts) — a subagent without a
package/dist/cli.js CHANGED
@@ -1,12 +1,16 @@
1
1
  #!/usr/bin/env node
2
- // The `loopengine` bin — distinct from adapters/cli.ts, which runs an
3
- // agent, not scaffolds one. Generates the agents/<name>/index.ts
4
- // boilerplate the folder convention expects (see agent-config.ts's own
5
- // doc comments, or the README's "Define your first agent"), so using
6
- // that convention doesn't mean memorizing its shape and hand-writing it
7
- // every time.
2
+ // The `loopengine` bin. add-agent/add-subagent generate the
3
+ // agents/<name>/index.ts boilerplate the folder convention expects (see
4
+ // agent-config.ts's own doc comments, or the README's "Define your first
5
+ // agent"), so using that convention doesn't mean memorizing its shape
6
+ // and hand-writing it every time. run/serve/dev are shorter muscle-
7
+ // memory commands for what a scaffolded project's own adapters/cli.ts
8
+ // and adapters/http.ts already do — see runTsx's own doc comment for why
9
+ // these stay thin wrappers around those files rather than a built-in
10
+ // runner/server this package hides inside itself.
8
11
  import { realpathSync } from 'node:fs';
9
12
  import { access, mkdir, writeFile } from 'node:fs/promises';
13
+ import { spawn } from 'node:child_process';
10
14
  import path from 'node:path';
11
15
  import { fileURLToPath } from 'node:url';
12
16
  const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
@@ -28,7 +32,7 @@ export const config: AgentConfig = {
28
32
  }
29
33
  /** Same shape as agentIndexTemplate, plus `toolDescription` — the one
30
34
  * field a subagent needs that a top-level agent doesn't. run-agent.ts's
31
- * loadSubagentTools wraps this config with agentAsTool, which throws
35
+ * loadSubagentAsTools wraps this config with agentAsTool, which throws
32
36
  * without a real toolDescription: it's what the parent agent's model
33
37
  * reads to decide when to delegate here, and systemPrompt (instructions
34
38
  * for this agent itself) isn't a substitute. See agent-as-tool.ts. */
@@ -79,7 +83,7 @@ function resolveParentDir(baseDir, parent) {
79
83
  /** Writes <parentDir>/subagents/<name>/index.ts under baseDir and
80
84
  * returns its path, where <parentDir> is `parent` resolved by
81
85
  * resolveParentDir — the folder convention run-agent.ts's
82
- * loadSubagentTools auto-discovers, so this subagent becomes one of
86
+ * loadSubagentAsTools auto-discovers, so this subagent becomes one of
83
87
  * `parent`'s tools with no further edits: no import, no
84
88
  * AgentConfig.tools entry, nothing to register by hand. Requires
85
89
  * `parent` to already exist (its own index.ts) — a subagent without a
@@ -113,6 +117,36 @@ async function pathExists(p) {
113
117
  return false;
114
118
  }
115
119
  }
120
+ /** Runs `tsx <args>` in the current project (process.cwd()) — via `npx`,
121
+ * not a bundled copy, so it resolves *that project's own*
122
+ * node_modules/.bin/tsx (the version create-loopengine's own template
123
+ * package.json pins), the same binary "npm run dev"/"npm run cli"
124
+ * already invoke there. Inherits stdio so a REPL-like session, streamed
125
+ * SSE-style logs, or Ctrl+C all behave exactly like running the tsx
126
+ * command by hand — this is that command, just shorter to type. Resolves
127
+ * with the child's own exit code (1 if the process couldn't even start)
128
+ * rather than throwing, so `loopengine run <agent>`'s own exit code
129
+ * mirrors adapters/cli.ts's, not this wrapper's. */
130
+ function runTsx(args) {
131
+ return new Promise((resolve) => {
132
+ const child = spawn('npx', ['tsx', ...args], { cwd: process.cwd(), stdio: 'inherit' });
133
+ child.on('exit', (code) => resolve(code ?? 1));
134
+ child.on('error', () => resolve(1));
135
+ });
136
+ }
137
+ /** run/serve/dev all delegate to a file the *project* owns (created by
138
+ * create-loopengine's own scaffold — see its template/adapters/), not
139
+ * one loopengine bundles — so a project that deleted or never had that
140
+ * file needs a clear, actionable error here, not tsx's own generic
141
+ * "cannot find module" a few layers down. */
142
+ async function requireAdapterFile(relPath) {
143
+ if (await pathExists(path.join(process.cwd(), relPath)))
144
+ return true;
145
+ console.error(`${relPath} not found in this project.`);
146
+ console.error('Run "npx create-loopengine@latest" to scaffold one, or add your own at that path.');
147
+ process.exitCode = 1;
148
+ return false;
149
+ }
116
150
  async function main() {
117
151
  const [, , command, ...rest] = process.argv;
118
152
  if (command === 'add-agent') {
@@ -165,8 +199,52 @@ async function main() {
165
199
  console.log(`Run the top-level agent: npx tsx adapters/cli.ts --agent ${parent.split('/')[0]} "hello"`);
166
200
  return;
167
201
  }
202
+ // One-shot: sends one message, prints the reply, exits — exactly
203
+ // adapters/cli.ts's own contract (see its own header comment), not a
204
+ // REPL. `rest` after `<agent>` is forwarded through as-is (--session,
205
+ // the message, and any future flag that file grows), so this wrapper
206
+ // never needs to change in step with adapters/cli.ts's own arg parsing.
207
+ if (command === 'run') {
208
+ const [agent, ...forward] = rest;
209
+ if (!agent) {
210
+ console.error('Usage: loopengine run <agent> [--session <id>] "<message>"');
211
+ process.exitCode = 1;
212
+ return;
213
+ }
214
+ if (!(await requireAdapterFile('adapters/cli.ts')))
215
+ return;
216
+ process.exitCode = await runTsx(['--env-file-if-exists=.env', 'adapters/cli.ts', '--agent', agent, ...forward]);
217
+ return;
218
+ }
219
+ // Serves every registered agent at once (adapters/http.ts has no
220
+ // notion of "just one agent") — same command the create-loopengine
221
+ // template's own "npm run dev" script already runs, see runTsx's own
222
+ // doc comment.
223
+ if (command === 'serve') {
224
+ if (!(await requireAdapterFile('adapters/http.ts')))
225
+ return;
226
+ process.exitCode = await runTsx(['--env-file-if-exists=.env', 'adapters/http.ts', ...rest]);
227
+ return;
228
+ }
229
+ // Same server as `serve`, via `tsx watch` instead of plain `tsx` — tsx
230
+ // itself doesn't hot-reload by default (see playground.ts's own dev
231
+ // instructions in the README, which use plain tsx), so without this,
232
+ // editing an agent's tools/rules/skills mid-session would need a
233
+ // manual restart to take effect. gateway-tools.yml/actauth.yml already
234
+ // read fresh off disk every call regardless (no restart needed for
235
+ // those even under plain `serve`) — this is for the TS source itself:
236
+ // a new tool file, an edited AgentConfig, ...
237
+ if (command === 'dev') {
238
+ if (!(await requireAdapterFile('adapters/http.ts')))
239
+ return;
240
+ process.exitCode = await runTsx(['watch', '--env-file-if-exists=.env', 'adapters/http.ts', ...rest]);
241
+ return;
242
+ }
168
243
  console.error('Usage: loopengine add-agent <name>');
169
244
  console.error(' loopengine add-subagent <parent> <name>');
245
+ console.error(' loopengine run <agent> [--session <id>] "<message>"');
246
+ console.error(' loopengine serve');
247
+ console.error(' loopengine dev');
170
248
  process.exitCode = 1;
171
249
  }
172
250
  // A package-manager shim (npx, node_modules/.bin) invokes this file
@@ -8,7 +8,7 @@ export interface AgentModule {
8
8
  /** Imports one agent module at `path` and resolves it to an AgentModule —
9
9
  * the single-file version of what discoverAgents does over a whole
10
10
  * directory, factored out so run-agent.ts's subagent loader (see
11
- * agent-as-tool.ts / loadSubagentTools) can load one `subagents/<name>/
11
+ * agent-as-tool.ts / loadSubagentAsTools) can load one `subagents/<name>/
12
12
  * index.ts` at a time without duplicating this resolution logic.
13
13
  * `label` is only used in error messages — discoverAgents passes
14
14
  * `'<file> in <dir>'`, callers loading a single known path can just pass
@@ -66,7 +66,7 @@ function resolveModulePath(dir, entry) {
66
66
  /** Imports one agent module at `path` and resolves it to an AgentModule —
67
67
  * the single-file version of what discoverAgents does over a whole
68
68
  * directory, factored out so run-agent.ts's subagent loader (see
69
- * agent-as-tool.ts / loadSubagentTools) can load one `subagents/<name>/
69
+ * agent-as-tool.ts / loadSubagentAsTools) can load one `subagents/<name>/
70
70
  * index.ts` at a time without duplicating this resolution logic.
71
71
  * `label` is only used in error messages — discoverAgents passes
72
72
  * `'<file> in <dir>'`, callers loading a single known path can just pass
@@ -0,0 +1,194 @@
1
+ import type { Decision } from 'actauth';
2
+ import type { ToolDefinition } from './agent-config.js';
3
+ export interface ComposioGatewayToolEntry {
4
+ provider: 'composio';
5
+ /** Namespaces every tool this source produces (`${name}_${slug}`,
6
+ * mcpplug's own convention) — must be unique among this agent's own
7
+ * sources. */
8
+ name: string;
9
+ slugs: string[];
10
+ /** Override for a non-default binary name, or a stand-in script in
11
+ * tests — see ComposioSourceOptions.cliCommand. Defaults to 'composio'. */
12
+ cliCommand?: string;
13
+ }
14
+ export type GatewayToolEntry = ComposioGatewayToolEntry;
15
+ /** A top-level agent's real folder — exported so callers resolving other
16
+ * per-agent paths (adapters/http.ts's describeAgent, computing the same
17
+ * `dir` to hand to loadGatewayToolsFromDir) use the exact same base this
18
+ * module's own registry read/writes do, rather than re-deriving it (e.g.
19
+ * from process.cwd()) and risking the two drifting apart. */
20
+ export declare function agentDir(agentName: string): string;
21
+ /** `dir` is the agent's real folder — `agents/<name>` for a top-level
22
+ * agent, or the nested `agents/<parent>/subagents/<child>` a subagent
23
+ * actually lives in (see run-agent.ts's resolveSubagentConfig, which
24
+ * hit this exact same name-vs-folder distinction for tools/rules/
25
+ * skillsDirs). Missing file is just `[]`, same convention every other
26
+ * folder-form default in this repo gets. */
27
+ export declare function readGatewayToolsFromDir(dir: string): GatewayToolEntry[];
28
+ /** Convenience wrapper over readGatewayToolsFromDir for the common,
29
+ * top-level-agent case — what adapters/http.ts's admin routes actually
30
+ * call, since GET /agents/:name/gateway-tools only ever addresses a
31
+ * top-level agent (getEntry(name) has no notion of a subagent path). */
32
+ export declare function readGatewayTools(agentName: string): GatewayToolEntry[];
33
+ export declare class GatewayToolExistsError extends Error {
34
+ }
35
+ export declare class GatewayToolNotFoundError extends Error {
36
+ }
37
+ /** Whether a gateway tool's name reads as read-only (safe to
38
+ * auto-allow) rather than mutating (should stay at actauth's own
39
+ * default, typically 'ask' or 'deny', until a human explicitly grants
40
+ * it) — see addGatewayTool's 'auto' decision mode, which is what
41
+ * actually calls this. The first token that matches either list wins,
42
+ * scanned in order; a name with no recognized verb at all (an
43
+ * inconsistently-named action, or a toolkit this list hasn't seen yet)
44
+ * defaults to *not* read-only — an unclassifiable tool staying at
45
+ * actauth's default is the safe failure mode, the reverse would silently
46
+ * auto-allow something this heuristic simply doesn't understand. Exported
47
+ * for testing and because adapters/http.ts's picker UI may want to show
48
+ * the same classification before a tool is even added. */
49
+ export declare function isReadOnlyToolName(toolName: string): boolean;
50
+ /** 'auto' resolves per-tool via isReadOnlyToolName instead of one fixed
51
+ * decision for a whole batch — see addGatewayTool's own doc comment for
52
+ * why a single decision can't safely cover "select all" against a
53
+ * toolkit with dozens of actions of very different risk. */
54
+ export type GatewayToolDecision = Decision | 'auto';
55
+ /** Registers a new gateway tool for `agentName` (top-level only — see
56
+ * readGatewayTools), or, if a source with this exact `name` is already
57
+ * registered, merges `entry.slugs` into it (deduped) instead of
58
+ * rejecting the call — the name is just a namespace label, not a
59
+ * one-shot slot; reusing it to add more tools under the same source is
60
+ * the common case (picking a few more actions for an app you already
61
+ * added), not a conflict. It's only a real conflict when the existing
62
+ * entry is for a *different* provider — two different connection
63
+ * configs can't share one name. `decision`, if given, pre-seeds an
64
+ * actauth rule per tool in *this call's own* `entry.slugs` (see
65
+ * appendActauthRules) — not the merged list, so re-adding an
66
+ * already-registered slug doesn't also re-seed a duplicate rule for it.
67
+ * Omit `decision` to leave every new tool at actauth's own
68
+ * defaultDecision (typically 'deny'), the same "new tools are opt-in,
69
+ * not silently allowed" convention every other tool in this repo
70
+ * follows. `decision: 'auto'` — see appendActauthRules — resolves per
71
+ * tool instead of applying one decision to the whole batch. */
72
+ export declare function addGatewayTool(agentName: string, entry: GatewayToolEntry, decision?: GatewayToolDecision): void;
73
+ /** Removes a gateway tool's registration, along with any actauth rule
74
+ * appendActauthRules previously seeded for it (see
75
+ * removeAutoAddedActauthRules — a hand-authored rule referencing the
76
+ * same tool under a different name is never touched). Removing a tool
77
+ * is expected to remove the permission it was granted too, not leave a
78
+ * rule that governs nothing behind. */
79
+ export declare function removeGatewayTool(agentName: string, sourceName: string): void;
80
+ /** Removes one slug from an existing source — the per-tool remove icon
81
+ * in the Tools tab's Gateway Tools list, as opposed to removeGatewayTool
82
+ * above, which drops the whole source at once. Deletes the source
83
+ * entirely if that was its last slug: a source with zero slugs produces
84
+ * zero tools anyway (mcpplug's connectComposioSource loops per slug, see
85
+ * connectEntry), so there's nothing left worth keeping registered. Same
86
+ * "also remove any rule this tool was auto-granted" behavior as
87
+ * removeGatewayTool — see its own doc comment and
88
+ * removeAutoAddedActauthRules. */
89
+ export declare function removeGatewayToolSlug(agentName: string, sourceName: string, slug: string): void;
90
+ export interface GatewayToolStatus {
91
+ entry: GatewayToolEntry;
92
+ status: 'ok' | 'error';
93
+ tools: Array<{
94
+ name: string;
95
+ description: string;
96
+ }>;
97
+ error?: string;
98
+ }
99
+ /** The admin page's read model — every registered source for `agentName`,
100
+ * each actually connected to and reported on individually, same
101
+ * fail-open-per-source behavior loadGatewayToolsFromDir has (see its own
102
+ * doc comment). The difference is what happens with a failure: this
103
+ * function surfaces it explicitly, per source, since an operator looking
104
+ * at this page needs to know *which* source is broken and why — where
105
+ * loadGatewayToolsFromDir just logs and drops it, since runAgent() has no
106
+ * structured place to hand a per-source error to. Deliberately bypasses
107
+ * loadGatewayToolsFromDir's mtime cache — this is an occasional,
108
+ * deliberate operator action, not a per-turn hot path, so "always
109
+ * current" is worth more here than "cheap." */
110
+ export declare function describeGatewayTools(agentName: string): Promise<GatewayToolStatus[]>;
111
+ /** Resolves agents/<name>/gateway-tools.yml (or the equivalent nested path
112
+ * for a subagent — see readGatewayToolsFromDir) into real ToolDefinitions,
113
+ * for run-agent.ts to merge into the rest of an agent's tools. Missing
114
+ * file is `[]`, no cache entry created.
115
+ *
116
+ * Fails open per source: one broken/unreachable gateway (Composio down,
117
+ * a CLI not authenticated) is logged and contributes no tools, rather
118
+ * than rejecting the whole call — the same reasoning a single tool's own
119
+ * `execute()` failing doesn't crash runAgent's whole turn (ToolLane
120
+ * reports it as an is_error tool_result instead). Without this, an agent
121
+ * with an unrelated broken integration would fail *every* request, tools
122
+ * it actually needs included — confirmed live: a run-agent.test.ts test
123
+ * that reuses the real 'file-agent' name purely to exercise the
124
+ * skillsDirs default started silently depending on the real `composio`
125
+ * CLI/network the moment agents/file-agent/gateway-tools.yml existed,
126
+ * before this fix made a failed (or slow/unavailable) source harmless
127
+ * instead of fatal. */
128
+ export declare function loadGatewayToolsFromDir(dir: string): Promise<ToolDefinition[]>;
129
+ export interface ComposioConnection {
130
+ toolkit: string;
131
+ status: string;
132
+ alias: string | null;
133
+ wordId: string | null;
134
+ }
135
+ /** Flattens `composio connections list`'s own shape (a map of toolkit
136
+ * slug -> array of accounts) into one array — easier for a caller (the
137
+ * add-source picker) to filter/sort without knowing that shape itself.
138
+ * Every toolkit with at least one connected account appears, `status`
139
+ * and all; deciding what counts as "usable" (typically just `'ACTIVE'`)
140
+ * is left to the caller; a CLI/auth failure propagates as a normal
141
+ * rejection — see runComposioCli. */
142
+ export declare function listComposioConnections(cliCommand?: string): Promise<ComposioConnection[]>;
143
+ export interface ComposioToolInfo {
144
+ slug: string;
145
+ name: string;
146
+ description: string;
147
+ }
148
+ /** What `composio tools list <toolkit>` offers for one already-connected
149
+ * app — the picker's second step, once a toolkit is chosen from
150
+ * listComposioConnections. Every action a toolkit exposes, not just ones
151
+ * already registered as a gateway tool source — the picker's whole point
152
+ * is showing what's *available* to add. `--limit 1000` (the CLI's own
153
+ * max) is explicit rather than left to the CLI's own default: without
154
+ * it, `composio tools list github` silently caps at 30 results — out of
155
+ * GitHub's real ~900, and alphabetically sorted, so a picker session
156
+ * would only ever see a narrow, unrepresentative slice (every result
157
+ * starting with "A" — ABORT/ACCEPT/ADD/API/APPROVE/ASSIGN — almost all
158
+ * mutating verbs), not "every tool this toolkit has" the doc comment
159
+ * above already promises. Confirmed live against the real CLI: no
160
+ * --limit gave 30, --limit 1000 gave 893. */
161
+ export declare function listComposioTools(toolkit: string, cliCommand?: string): Promise<ComposioToolInfo[]>;
162
+ export interface ComposioAuthStatus {
163
+ connected: boolean;
164
+ email?: string;
165
+ org?: string;
166
+ }
167
+ /** Whether the composio CLI itself (not any one connected app — see
168
+ * listComposioConnections for that) is authenticated on this machine.
169
+ * `composio whoami` succeeds once `composio login` has run and fails
170
+ * otherwise, so a failure here is read as "not connected", not
171
+ * propagated as an error the way listComposioConnections' own CLI
172
+ * failures are — this is a normal, expected state for an environment
173
+ * that just hasn't been logged in yet, not a bug. Deliberately
174
+ * read-only: login is an interactive, machine-wide CLI session (opens a
175
+ * browser, affects every agent's gateway tools, not just one web
176
+ * request), so the global config page's Gateways panel that calls this
177
+ * only ever shows status + instructions, never drives login/logout
178
+ * itself. */
179
+ export declare function getComposioAuthStatus(cliCommand?: string): Promise<ComposioAuthStatus>;
180
+ /** `composio logout` — already non-interactive on its own (see its own
181
+ * --help), unlike login. Machine-wide: this deauthenticates the CLI for
182
+ * every agent's gateway tools on this machine, not just the browser tab
183
+ * that clicked Disconnect — same as running it in a terminal yourself.
184
+ *
185
+ * There's no connectComposioAccount counterpart: `composio login`'s only
186
+ * non-interactive path is `--user-api-key`, which turned out not to be
187
+ * what an operator actually has on hand — the key Composio's own
188
+ * dashboard hands out for MCP client auth (sent via an x-consumer-api-key
189
+ * header) isn't the same credential, and 401s against this flag.
190
+ * Composio's own documented path is plain, interactive `composio login`
191
+ * (opens a browser) after installing the CLI — not something a web
192
+ * request can drive, so the Gateways panel shows that as instructions
193
+ * instead of a form; see its own comment in adapters/global-config-page.ts. */
194
+ export declare function disconnectComposioAccount(cliCommand?: string): Promise<void>;
@@ -0,0 +1,548 @@
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 "next to this file, not process.cwd()" reasoning run-agent.ts's
19
+ // own agentsRootDir uses — real code (an actauth.yml write, a
20
+ // gateway-tools.yml write) needs to land in the same place a built dist/
21
+ // would look for it, not wherever the process happened to be launched
22
+ // from.
23
+ const agentsRootDir = join(dirname(fileURLToPath(import.meta.url)), 'agents');
24
+ function gatewayToolsPath(dir) {
25
+ return join(dir, 'gateway-tools.yml');
26
+ }
27
+ /** A top-level agent's real folder — exported so callers resolving other
28
+ * per-agent paths (adapters/http.ts's describeAgent, computing the same
29
+ * `dir` to hand to loadGatewayToolsFromDir) use the exact same base this
30
+ * module's own registry read/writes do, rather than re-deriving it (e.g.
31
+ * from process.cwd()) and risking the two drifting apart. */
32
+ export function agentDir(agentName) {
33
+ return join(agentsRootDir, agentName);
34
+ }
35
+ /** `dir` is the agent's real folder — `agents/<name>` for a top-level
36
+ * agent, or the nested `agents/<parent>/subagents/<child>` a subagent
37
+ * actually lives in (see run-agent.ts's resolveSubagentConfig, which
38
+ * hit this exact same name-vs-folder distinction for tools/rules/
39
+ * skillsDirs). Missing file is just `[]`, same convention every other
40
+ * folder-form default in this repo gets. */
41
+ export function readGatewayToolsFromDir(dir) {
42
+ const path = gatewayToolsPath(dir);
43
+ if (!existsSync(path))
44
+ return [];
45
+ const raw = (parseYaml(readFileSync(path, 'utf8')) ?? {});
46
+ return raw.sources ?? [];
47
+ }
48
+ /** Convenience wrapper over readGatewayToolsFromDir for the common,
49
+ * top-level-agent case — what adapters/http.ts's admin routes actually
50
+ * call, since GET /agents/:name/gateway-tools only ever addresses a
51
+ * top-level agent (getEntry(name) has no notion of a subagent path). */
52
+ export function readGatewayTools(agentName) {
53
+ return readGatewayToolsFromDir(agentDir(agentName));
54
+ }
55
+ function writeGatewayToolsFile(dir, sources) {
56
+ mkdirSync(dir, { recursive: true });
57
+ writeFileSync(gatewayToolsPath(dir), stringifyYaml({ sources }));
58
+ }
59
+ export class GatewayToolExistsError extends Error {
60
+ }
61
+ export class GatewayToolNotFoundError extends Error {
62
+ }
63
+ // A toolkit's own action-naming convention, not this repo's — Composio
64
+ // slugs are consistently VERB_REST (LIST_REPOS, CREATE_ISSUE, ...), just
65
+ // not consistently *which* verb, so this is a heuristic, not a spec.
66
+ // Scans every underscore-separated token (not just the first) since the
67
+ // verb isn't always right after the toolkit prefix — GITHUB_LIST_REPOS
68
+ // and github_GITHUB_LIST_REPOS (the ${entry.name}_${slug} form
69
+ // appendActauthRules actually calls this with) both still hit LIST.
70
+ const READ_ONLY_VERBS = new Set([
71
+ 'LIST',
72
+ 'GET',
73
+ 'FETCH',
74
+ 'FIND',
75
+ 'SEARCH',
76
+ 'READ',
77
+ 'RETRIEVE',
78
+ 'VIEW',
79
+ 'CHECK',
80
+ 'SHOW',
81
+ 'DESCRIBE',
82
+ 'QUERY',
83
+ 'COUNT',
84
+ 'EXPORT',
85
+ ]);
86
+ const MUTATING_VERBS = new Set([
87
+ 'CREATE',
88
+ 'ADD',
89
+ 'UPDATE',
90
+ 'EDIT',
91
+ 'MODIFY',
92
+ 'DELETE',
93
+ 'REMOVE',
94
+ 'SEND',
95
+ 'POST',
96
+ 'WRITE',
97
+ 'SET',
98
+ 'PUT',
99
+ 'PATCH',
100
+ 'PUBLISH',
101
+ 'UPLOAD',
102
+ 'INVITE',
103
+ 'ASSIGN',
104
+ 'MERGE',
105
+ 'CLOSE',
106
+ 'CANCEL',
107
+ 'ARCHIVE',
108
+ 'RESTORE',
109
+ 'ENABLE',
110
+ 'DISABLE',
111
+ 'GRANT',
112
+ 'REVOKE',
113
+ 'EXECUTE',
114
+ 'RUN',
115
+ 'TRIGGER',
116
+ 'START',
117
+ 'STOP',
118
+ 'PAY',
119
+ 'CHARGE',
120
+ 'REFUND',
121
+ 'TRANSFER',
122
+ 'IMPORT',
123
+ 'MOVE',
124
+ 'COPY',
125
+ 'DUPLICATE',
126
+ 'CLONE',
127
+ 'RESET',
128
+ 'REPLACE',
129
+ 'ATTACH',
130
+ 'DETACH',
131
+ 'LOCK',
132
+ 'UNLOCK',
133
+ 'BLOCK',
134
+ 'UNBLOCK',
135
+ 'BAN',
136
+ ]);
137
+ /** Whether a gateway tool's name reads as read-only (safe to
138
+ * auto-allow) rather than mutating (should stay at actauth's own
139
+ * default, typically 'ask' or 'deny', until a human explicitly grants
140
+ * it) — see addGatewayTool's 'auto' decision mode, which is what
141
+ * actually calls this. The first token that matches either list wins,
142
+ * scanned in order; a name with no recognized verb at all (an
143
+ * inconsistently-named action, or a toolkit this list hasn't seen yet)
144
+ * defaults to *not* read-only — an unclassifiable tool staying at
145
+ * actauth's default is the safe failure mode, the reverse would silently
146
+ * auto-allow something this heuristic simply doesn't understand. Exported
147
+ * for testing and because adapters/http.ts's picker UI may want to show
148
+ * the same classification before a tool is even added. */
149
+ export function isReadOnlyToolName(toolName) {
150
+ const tokens = toolName.toUpperCase().split('_');
151
+ for (const token of tokens) {
152
+ if (READ_ONLY_VERBS.has(token))
153
+ return true;
154
+ if (MUTATING_VERBS.has(token))
155
+ return false;
156
+ }
157
+ return false;
158
+ }
159
+ /** Seeds one exact-match actauth rule per tool this source produces —
160
+ * ActAuth's own `tool` matching is exact-string, not glob (only `scope`
161
+ * supports wildcards), so "allow everything gh_* produces" isn't
162
+ * expressible as one rule; each `${entry.name}_${slug}` tool name gets
163
+ * its own. Computed from `entry` directly (never a live call to the
164
+ * gateway) so adding a source doesn't require the CLI/provider to be
165
+ * reachable at that exact moment — only actually loading tools later
166
+ * does. Creates agents/<name>/actauth.yml (default_decision: deny) if it
167
+ * doesn't exist yet, same fallback loadRules' own default gets.
168
+ *
169
+ * `decision: 'auto'` seeds an explicit 'allow' rule for tools
170
+ * isReadOnlyToolName reads as safe, and an explicit 'ask' rule for
171
+ * everything else — never no rule at all. Relying on "no rule falls
172
+ * through to actauth's own default_decision" was the first cut of this,
173
+ * but that ties a mutating tool's actual governing decision to whatever
174
+ * default_decision happens to be *at the time someone reads it*: change
175
+ * default_decision later (say from 'ask' to 'allow' for an unrelated
176
+ * reason) and every mutating tool added this way silently becomes
177
+ * allowed too, with nothing in actauth.yml showing that ever happened.
178
+ * An explicit 'ask' rule can't drift like that. This is what the
179
+ * picker's own "select all" uses instead of forcing one decision onto a
180
+ * batch that might mix GITHUB_LIST_REPOS with GITHUB_DELETE_REPO.
181
+ *
182
+ * Edits via `yaml`'s Document API (parse → mutate the CST → toString),
183
+ * not parse-into-a-plain-object → re-stringify — the latter silently
184
+ * drops every comment in the file, which for a real, hand-maintained
185
+ * actauth.yml (see agents/customer-service/actauth.yml's own extensive
186
+ * rule-by-rule reasoning) would mean adding one gateway tool wipes out
187
+ * documentation an operator wrote for entirely unrelated rules. */
188
+ function appendActauthRules(dir, toolNames, decision, provider) {
189
+ const path = join(dir, 'actauth.yml');
190
+ const doc = existsSync(path) ? parseDocument(readFileSync(path, 'utf8')) : parseDocument('default_decision: deny\nrules: []\n');
191
+ if (doc.get('rules') == null)
192
+ doc.set('rules', []);
193
+ const rules = doc.get('rules');
194
+ for (const tool of toolNames) {
195
+ const resolved = decision === 'auto' ? (isReadOnlyToolName(tool) ? 'allow' : 'ask') : decision;
196
+ // Prefixed with the *provider* (e.g. 'composio'), not the source's
197
+ // own local `name` (e.g. 'github') — `tool` already embeds the
198
+ // source name (`${entry.name}_${slug}`), so repeating it in the
199
+ // rule name too would be redundant; the provider is the one piece
200
+ // of context `tool` doesn't already carry.
201
+ rules.add(doc.createNode({ name: `${provider}-${tool}-web-added`, scope: '*/*', tool, decision: resolved }));
202
+ }
203
+ mkdirSync(dir, { recursive: true });
204
+ writeFileSync(path, doc.toString());
205
+ }
206
+ /** The inverse of appendActauthRules — strips any rule it previously
207
+ * wrote for one of `toolNames`, recognized by the exact `-web-added`
208
+ * name suffix that function always writes, so a hand-authored rule that
209
+ * happens to reference the same tool (a different name, no suffix) is
210
+ * never touched. Called from removeGatewayTool/removeGatewayToolSlug so
211
+ * removing a tool also removes the permission it was granted, rather
212
+ * than leaving a rule that now governs nothing. A no-op, not an error,
213
+ * if actauth.yml doesn't exist or has no matching rule — removing a tool
214
+ * that was never given a seeded rule (decision omitted when it was
215
+ * added) is a completely normal case, not a problem to report. */
216
+ function removeAutoAddedActauthRules(dir, toolNames) {
217
+ const path = join(dir, 'actauth.yml');
218
+ if (!existsSync(path))
219
+ return;
220
+ const doc = parseDocument(readFileSync(path, 'utf8'));
221
+ const rules = doc.get('rules');
222
+ if (!rules)
223
+ return;
224
+ const before = rules.items.length;
225
+ rules.items = rules.items.filter((rule) => {
226
+ const name = rule.get('name');
227
+ const tool = rule.get('tool');
228
+ return !(typeof name === 'string' && name.endsWith('-web-added') && typeof tool === 'string' && toolNames.includes(tool));
229
+ });
230
+ if (rules.items.length === before)
231
+ return;
232
+ writeFileSync(path, doc.toString());
233
+ }
234
+ function toolNamesFor(entry) {
235
+ return entry.slugs.map((slug) => `${entry.name}_${slug}`);
236
+ }
237
+ /** Registers a new gateway tool for `agentName` (top-level only — see
238
+ * readGatewayTools), or, if a source with this exact `name` is already
239
+ * registered, merges `entry.slugs` into it (deduped) instead of
240
+ * rejecting the call — the name is just a namespace label, not a
241
+ * one-shot slot; reusing it to add more tools under the same source is
242
+ * the common case (picking a few more actions for an app you already
243
+ * added), not a conflict. It's only a real conflict when the existing
244
+ * entry is for a *different* provider — two different connection
245
+ * configs can't share one name. `decision`, if given, pre-seeds an
246
+ * actauth rule per tool in *this call's own* `entry.slugs` (see
247
+ * appendActauthRules) — not the merged list, so re-adding an
248
+ * already-registered slug doesn't also re-seed a duplicate rule for it.
249
+ * Omit `decision` to leave every new tool at actauth's own
250
+ * defaultDecision (typically 'deny'), the same "new tools are opt-in,
251
+ * not silently allowed" convention every other tool in this repo
252
+ * follows. `decision: 'auto'` — see appendActauthRules — resolves per
253
+ * tool instead of applying one decision to the whole batch. */
254
+ export function addGatewayTool(agentName, entry, decision) {
255
+ const dir = agentDir(agentName);
256
+ const sources = readGatewayToolsFromDir(dir);
257
+ const existingIndex = sources.findIndex((s) => s.name === entry.name);
258
+ if (existingIndex === -1) {
259
+ writeGatewayToolsFile(dir, [...sources, entry]);
260
+ }
261
+ else {
262
+ const existing = sources[existingIndex];
263
+ if (existing.provider !== entry.provider) {
264
+ 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.`);
265
+ }
266
+ const mergedSlugs = Array.from(new Set([...existing.slugs, ...entry.slugs]));
267
+ const updated = [...sources];
268
+ updated[existingIndex] = { ...existing, ...entry, slugs: mergedSlugs };
269
+ writeGatewayToolsFile(dir, updated);
270
+ }
271
+ if (decision)
272
+ appendActauthRules(dir, toolNamesFor(entry), decision, entry.provider);
273
+ }
274
+ /** Removes a gateway tool's registration, along with any actauth rule
275
+ * appendActauthRules previously seeded for it (see
276
+ * removeAutoAddedActauthRules — a hand-authored rule referencing the
277
+ * same tool under a different name is never touched). Removing a tool
278
+ * is expected to remove the permission it was granted too, not leave a
279
+ * rule that governs nothing behind. */
280
+ export function removeGatewayTool(agentName, sourceName) {
281
+ const dir = agentDir(agentName);
282
+ const sources = readGatewayToolsFromDir(dir);
283
+ const existing = sources.find((s) => s.name === sourceName);
284
+ if (!existing) {
285
+ throw new GatewayToolNotFoundError(`No gateway tool named '${sourceName}' for '${agentName}'.`);
286
+ }
287
+ writeGatewayToolsFile(dir, sources.filter((s) => s.name !== sourceName));
288
+ removeAutoAddedActauthRules(dir, toolNamesFor(existing));
289
+ }
290
+ /** Removes one slug from an existing source — the per-tool remove icon
291
+ * in the Tools tab's Gateway Tools list, as opposed to removeGatewayTool
292
+ * above, which drops the whole source at once. Deletes the source
293
+ * entirely if that was its last slug: a source with zero slugs produces
294
+ * zero tools anyway (mcpplug's connectComposioSource loops per slug, see
295
+ * connectEntry), so there's nothing left worth keeping registered. Same
296
+ * "also remove any rule this tool was auto-granted" behavior as
297
+ * removeGatewayTool — see its own doc comment and
298
+ * removeAutoAddedActauthRules. */
299
+ export function removeGatewayToolSlug(agentName, sourceName, slug) {
300
+ const dir = agentDir(agentName);
301
+ const sources = readGatewayToolsFromDir(dir);
302
+ const index = sources.findIndex((s) => s.name === sourceName);
303
+ if (index === -1 || !sources[index].slugs.includes(slug)) {
304
+ throw new GatewayToolNotFoundError(`No gateway tool '${slug}' registered under '${sourceName}' for '${agentName}'.`);
305
+ }
306
+ const remainingSlugs = sources[index].slugs.filter((s) => s !== slug);
307
+ const updated = remainingSlugs.length === 0
308
+ ? sources.filter((_, i) => i !== index)
309
+ : sources.map((s, i) => (i === index ? { ...s, slugs: remainingSlugs } : s));
310
+ writeGatewayToolsFile(dir, updated);
311
+ removeAutoAddedActauthRules(dir, [`${sources[index].name}_${slug}`]);
312
+ }
313
+ async function connectEntry(entry) {
314
+ if (entry.provider === 'composio') {
315
+ const source = await connectComposioSource(entry.name, { slugs: entry.slugs, cliCommand: entry.cliCommand });
316
+ try {
317
+ return await source.loadTools();
318
+ }
319
+ finally {
320
+ await source.close();
321
+ }
322
+ }
323
+ // Unreachable while GatewayToolEntry is a union of one — the check (and
324
+ // the cast it needs) starts earning its keep the moment a second
325
+ // provider is added and a stale gateway-tools.yml entry from before
326
+ // that provider existed needs a real error, not a silent skip.
327
+ throw new Error(`Unknown gateway tool provider '${entry.provider}'.`);
328
+ }
329
+ /** mcpplug's own connectComposioSource fills each ToolDefinition's
330
+ * description with a mechanically humanized slug (e.g. "abort repository
331
+ * migration") — the only thing it has to work with is `composio execute
332
+ * <slug> --get-schema`, which has no top-level description field, only
333
+ * per-input ones. Composio's own catalog (`composio tools list
334
+ * <toolkit>`) has a genuinely useful one instead (e.g. "Tool to abort a
335
+ * repository migration that is queued or in progress. Use when you need
336
+ * to cancel an ongoing migration operation.") — worth fetching for
337
+ * describeGatewayTools specifically (see its own "always current worth
338
+ * more than cheap" doc comment), an occasional, deliberate admin action.
339
+ * Deliberately *not* applied inside connectEntry itself, even though
340
+ * that would also improve what the model actually sees at runtime —
341
+ * connectEntry is loadGatewayToolsFromDir's hot path, and that function's
342
+ * own doc comment already explicitly rejects paying any extra
343
+ * reconnect/network cost there beyond what's unavoidable; confirmed live
344
+ * that adding one more CLI call per distinct toolkit on every cache-miss
345
+ * measurably slowed it down enough to blow real test timeouts.
346
+ *
347
+ * Toolkit is derived from each slug's own TOOLKIT_ACTION naming
348
+ * convention, not from `entry.name` — that's just a local namespace
349
+ * label an operator can set to anything (see ComposioGatewayToolEntry's
350
+ * own doc comment), so it can't be trusted to name a real toolkit.
351
+ * Best-effort per toolkit: a lookup failing (unusual slug shape, a
352
+ * toolkit no longer connected) just leaves those tools at their
353
+ * humanize() fallback rather than failing the whole call — same
354
+ * fail-open reasoning loadGatewayToolsFromDir's own doc comment already
355
+ * uses for a broken source. */
356
+ async function withRealComposioDescriptions(tools, slugs, cliCommand) {
357
+ const toolkits = Array.from(new Set(slugs.map((slug) => slug.split('_')[0].toLowerCase())));
358
+ const descriptions = new Map();
359
+ await Promise.all(toolkits.map(async (toolkit) => {
360
+ try {
361
+ const catalog = await listComposioTools(toolkit, cliCommand);
362
+ for (const t of catalog)
363
+ descriptions.set(t.slug, t.description);
364
+ }
365
+ catch {
366
+ // Best-effort — see this function's own doc comment.
367
+ }
368
+ }));
369
+ return tools.map((tool, i) => {
370
+ const real = descriptions.get(slugs[i]);
371
+ return real ? { ...tool, description: real } : tool;
372
+ });
373
+ }
374
+ /** The admin page's read model — every registered source for `agentName`,
375
+ * each actually connected to and reported on individually, same
376
+ * fail-open-per-source behavior loadGatewayToolsFromDir has (see its own
377
+ * doc comment). The difference is what happens with a failure: this
378
+ * function surfaces it explicitly, per source, since an operator looking
379
+ * at this page needs to know *which* source is broken and why — where
380
+ * loadGatewayToolsFromDir just logs and drops it, since runAgent() has no
381
+ * structured place to hand a per-source error to. Deliberately bypasses
382
+ * loadGatewayToolsFromDir's mtime cache — this is an occasional,
383
+ * deliberate operator action, not a per-turn hot path, so "always
384
+ * current" is worth more here than "cheap." */
385
+ export async function describeGatewayTools(agentName) {
386
+ const entries = readGatewayTools(agentName);
387
+ return Promise.all(entries.map(async (entry) => {
388
+ try {
389
+ const rawTools = await connectEntry(entry);
390
+ // See withRealComposioDescriptions' own doc comment for why this
391
+ // enrichment happens here, not inside connectEntry itself.
392
+ const tools = entry.provider === 'composio' ? await withRealComposioDescriptions(rawTools, entry.slugs, entry.cliCommand) : rawTools;
393
+ return { entry, status: 'ok', tools: tools.map((t) => ({ name: t.name, description: t.description })) };
394
+ }
395
+ catch (err) {
396
+ return { entry, status: 'error', tools: [], error: err instanceof Error ? err.message : String(err) };
397
+ }
398
+ }));
399
+ }
400
+ /** In-memory, keyed by the registry file's own mtime — cheap to check
401
+ * every call (one stat), and correct: it can only go stale if the file
402
+ * changes without its mtime changing, which no real filesystem does.
403
+ * Real cost here is connectEntry's own CLI subprocess-per-slug (see
404
+ * mcpplug's composio-source.ts) — reconnecting on every single
405
+ * runAgent() turn would be a real, unnecessary latency tax the rest of
406
+ * this repo's folder-form defaults (loadDefaultTools, loadRules) don't
407
+ * have to worry about, since those are plain reads/imports, not a
408
+ * network/subprocess round trip. */
409
+ const cache = new Map();
410
+ /** Resolves agents/<name>/gateway-tools.yml (or the equivalent nested path
411
+ * for a subagent — see readGatewayToolsFromDir) into real ToolDefinitions,
412
+ * for run-agent.ts to merge into the rest of an agent's tools. Missing
413
+ * file is `[]`, no cache entry created.
414
+ *
415
+ * Fails open per source: one broken/unreachable gateway (Composio down,
416
+ * a CLI not authenticated) is logged and contributes no tools, rather
417
+ * than rejecting the whole call — the same reasoning a single tool's own
418
+ * `execute()` failing doesn't crash runAgent's whole turn (ToolLane
419
+ * reports it as an is_error tool_result instead). Without this, an agent
420
+ * with an unrelated broken integration would fail *every* request, tools
421
+ * it actually needs included — confirmed live: a run-agent.test.ts test
422
+ * that reuses the real 'file-agent' name purely to exercise the
423
+ * skillsDirs default started silently depending on the real `composio`
424
+ * CLI/network the moment agents/file-agent/gateway-tools.yml existed,
425
+ * before this fix made a failed (or slow/unavailable) source harmless
426
+ * instead of fatal. */
427
+ export async function loadGatewayToolsFromDir(dir) {
428
+ const path = gatewayToolsPath(dir);
429
+ if (!existsSync(path))
430
+ return [];
431
+ const mtimeMs = statSync(path).mtimeMs;
432
+ const cached = cache.get(path);
433
+ if (cached && cached.mtimeMs === mtimeMs)
434
+ return cached.tools;
435
+ const entries = readGatewayToolsFromDir(dir);
436
+ const resolved = await Promise.all(entries.map(async (entry) => {
437
+ try {
438
+ return await connectEntry(entry);
439
+ }
440
+ catch (err) {
441
+ console.error(`[gateway-tools] source '${entry.name}' (${entry.provider}) failed to load, skipping: ${err instanceof Error ? err.message : String(err)}`);
442
+ return [];
443
+ }
444
+ }));
445
+ const tools = resolved.flat();
446
+ cache.set(path, { mtimeMs, tools });
447
+ return tools;
448
+ }
449
+ // ----- Discovery: what's already connected, what a connected app offers
450
+ // -----
451
+ //
452
+ // The add-a-source form used to ask an operator to already know a
453
+ // toolkit's exact slug strings by heart — real friction, and the only
454
+ // reason was that nothing in this file had actually shelled out to check
455
+ // what `composio connections list` / `composio tools list <toolkit>`
456
+ // return. Both work off the same `composio link <toolkit>` auth
457
+ // connectComposioSource already assumes — no extra "developer project"
458
+ // setup (`composio dev init`) needed, confirmed against a real CLI
459
+ // before building this.
460
+ async function runComposioCli(cliCommand, args) {
461
+ let stdout;
462
+ try {
463
+ ;
464
+ ({ stdout } = await execFileAsync(cliCommand, args));
465
+ }
466
+ catch (err) {
467
+ const message = err instanceof Error ? err.message : String(err);
468
+ throw new Error(`composio CLI call failed (${args.join(' ')}): ${message}`);
469
+ }
470
+ try {
471
+ return JSON.parse(stdout);
472
+ }
473
+ catch {
474
+ throw new Error(`composio CLI returned non-JSON output for ${args.join(' ')}: ${stdout}`);
475
+ }
476
+ }
477
+ /** Flattens `composio connections list`'s own shape (a map of toolkit
478
+ * slug -> array of accounts) into one array — easier for a caller (the
479
+ * add-source picker) to filter/sort without knowing that shape itself.
480
+ * Every toolkit with at least one connected account appears, `status`
481
+ * and all; deciding what counts as "usable" (typically just `'ACTIVE'`)
482
+ * is left to the caller; a CLI/auth failure propagates as a normal
483
+ * rejection — see runComposioCli. */
484
+ export async function listComposioConnections(cliCommand = 'composio') {
485
+ const raw = (await runComposioCli(cliCommand, ['connections', 'list']));
486
+ const connections = [];
487
+ for (const [toolkit, accounts] of Object.entries(raw)) {
488
+ for (const account of accounts) {
489
+ connections.push({ toolkit, status: account.status, alias: account.alias ?? null, wordId: account.word_id ?? null });
490
+ }
491
+ }
492
+ return connections;
493
+ }
494
+ /** What `composio tools list <toolkit>` offers for one already-connected
495
+ * app — the picker's second step, once a toolkit is chosen from
496
+ * listComposioConnections. Every action a toolkit exposes, not just ones
497
+ * already registered as a gateway tool source — the picker's whole point
498
+ * is showing what's *available* to add. `--limit 1000` (the CLI's own
499
+ * max) is explicit rather than left to the CLI's own default: without
500
+ * it, `composio tools list github` silently caps at 30 results — out of
501
+ * GitHub's real ~900, and alphabetically sorted, so a picker session
502
+ * would only ever see a narrow, unrepresentative slice (every result
503
+ * starting with "A" — ABORT/ACCEPT/ADD/API/APPROVE/ASSIGN — almost all
504
+ * mutating verbs), not "every tool this toolkit has" the doc comment
505
+ * above already promises. Confirmed live against the real CLI: no
506
+ * --limit gave 30, --limit 1000 gave 893. */
507
+ export async function listComposioTools(toolkit, cliCommand = 'composio') {
508
+ const raw = (await runComposioCli(cliCommand, ['tools', 'list', toolkit, '--limit', '1000']));
509
+ return raw.map((t) => ({ slug: t.slug, name: t.name, description: t.description }));
510
+ }
511
+ /** Whether the composio CLI itself (not any one connected app — see
512
+ * listComposioConnections for that) is authenticated on this machine.
513
+ * `composio whoami` succeeds once `composio login` has run and fails
514
+ * otherwise, so a failure here is read as "not connected", not
515
+ * propagated as an error the way listComposioConnections' own CLI
516
+ * failures are — this is a normal, expected state for an environment
517
+ * that just hasn't been logged in yet, not a bug. Deliberately
518
+ * read-only: login is an interactive, machine-wide CLI session (opens a
519
+ * browser, affects every agent's gateway tools, not just one web
520
+ * request), so the global config page's Gateways panel that calls this
521
+ * only ever shows status + instructions, never drives login/logout
522
+ * itself. */
523
+ export async function getComposioAuthStatus(cliCommand = 'composio') {
524
+ try {
525
+ const raw = (await runComposioCli(cliCommand, ['whoami']));
526
+ return { connected: true, email: raw.email, org: raw.current_org_name };
527
+ }
528
+ catch {
529
+ return { connected: false };
530
+ }
531
+ }
532
+ /** `composio logout` — already non-interactive on its own (see its own
533
+ * --help), unlike login. Machine-wide: this deauthenticates the CLI for
534
+ * every agent's gateway tools on this machine, not just the browser tab
535
+ * that clicked Disconnect — same as running it in a terminal yourself.
536
+ *
537
+ * There's no connectComposioAccount counterpart: `composio login`'s only
538
+ * non-interactive path is `--user-api-key`, which turned out not to be
539
+ * what an operator actually has on hand — the key Composio's own
540
+ * dashboard hands out for MCP client auth (sent via an x-consumer-api-key
541
+ * header) isn't the same credential, and 401s against this flag.
542
+ * Composio's own documented path is plain, interactive `composio login`
543
+ * (opens a browser) after installing the CLI — not something a web
544
+ * request can drive, so the Gateways panel shows that as instructions
545
+ * instead of a form; see its own comment in adapters/global-config-page.ts. */
546
+ export async function disconnectComposioAccount(cliCommand = 'composio') {
547
+ await execFileAsync(cliCommand, ['logout']);
548
+ }
@@ -89,4 +89,26 @@ export interface RunAgentResult {
89
89
  * actually enforce, not a re-derived guess at them. */
90
90
  export declare function loadRules(config: AgentConfig): RuleSet;
91
91
  export declare function loadDefaultTools(config: AgentConfig): Promise<ToolDefinition[]>;
92
+ /** Auto-loads agents/<name>/subagents/<child>/index.{ts,js} — each one a
93
+ * full AgentConfig, wrapped with agentAsTool and merged into `config`'s
94
+ * own tools, no import or AgentConfig.tools edit required. Unlike
95
+ * loadDefaultTools, this always runs regardless of whether `config.tools`
96
+ * was left to its own default or set explicitly — see AgentConfig.tools's
97
+ * own doc comment for why subagents are a distinct concern from
98
+ * hand-written tools. A missing subagents/ folder is just `[]`, same
99
+ * missing-is-fine treatment tools/ and skills/ get; a subdirectory with
100
+ * neither index.ts nor index.js is skipped, same as resolveModulePath's
101
+ * own handling in discover-agents.ts.
102
+ *
103
+ * Each subagent is loaded via loadAgentModule — the same per-module
104
+ * resolution discoverAgents itself uses — then patched by
105
+ * resolveSubagentConfig (see its own doc comment for why) before being
106
+ * wrapped. Its own `config` goes through this exact same function again
107
+ * the next time *it* runs (inside agentAsTool's execute, via runAgent).
108
+ * That's what makes nesting (a subagent with its own subagents/ folder)
109
+ * work with no extra recursion code: it's just this function being
110
+ * called again, one level down. A folder can't be its own ancestor, so
111
+ * this can't cycle the way a hand-wired agentAsTool(getEntry(...)) call
112
+ * elsewhere could. */
113
+ export declare function loadSubagentAsTools(config: AgentConfig): Promise<ToolDefinition[]>;
92
114
  export declare function runAgent(config: AgentConfig, modelCall: ModelCall, userMessage: string, history?: Message[], options?: RunAgentOptions): Promise<RunAgentResult>;
package/dist/run-agent.js CHANGED
@@ -14,6 +14,7 @@ import { ToolLane } from 'toollane';
14
14
  import { Reflow } from 'reflowkit';
15
15
  import { loadAgentModule } from './discover-agents.js';
16
16
  import { agentAsTool } from './agent-as-tool.js';
17
+ import { loadGatewayToolsFromDir } from './gateway-tools.js';
17
18
  // Resolved relative to *this file's own location* (via import.meta.url),
18
19
  // not process.cwd() — the same reasoning agent-registry.ts's own
19
20
  // agentsDir uses. In dev (tsx), this file and agents/ are both source
@@ -139,7 +140,7 @@ export async function loadDefaultTools(config) {
139
140
  * `agents/<name>/` — correct for a top-level agent, where that's exactly
140
141
  * where its module lives, but wrong for a subagent: its `config` only
141
142
  * carries its own bare name (e.g. 'billing-agent'), not the nested folder
142
- * loadSubagentTools actually found it in
143
+ * loadSubagentAsTools actually found it in
143
144
  * (`agents/support-orchestrator/subagents/billing-agent/`). Left alone,
144
145
  * an omitted `tools`/`rules`/`skillsDirs` on a subagent would silently
145
146
  * resolve against `agents/billing-agent/...` instead — empty, if no such
@@ -154,12 +155,19 @@ export async function loadDefaultTools(config) {
154
155
  * deny-everything ruleset" fallback loadRules's own default path gets —
155
156
  * computed here instead of left to loadRules, since leaving `rules`
156
157
  * unset would have it fall through to loadRules's *own* (wrong,
157
- * name-based) default path instead of this one. */
158
+ * name-based) default path instead of this one.
159
+ *
160
+ * `gateway-tools.yml` (see gateway-tools.ts) gets the same dir-correctness
161
+ * fix, but merged in unconditionally rather than gated on `tools` being
162
+ * unset — same reasoning loadSubagentAsTools' own unconditional merge into
163
+ * the top-level tools line has: gateway-registered tools are an
164
+ * operator/admin concern, distinct from hand-written `tools`, so setting
165
+ * `tools` explicitly shouldn't opt a subagent out of its own
166
+ * gateway-tools.yml the way it opts out of the tools/ folder default. */
158
167
  async function resolveSubagentConfig(config, dir) {
159
168
  const resolved = { ...config };
160
- if (resolved.tools === undefined) {
161
- resolved.tools = await loadToolsFromDir(join(dir, 'tools'));
162
- }
169
+ const baseTools = resolved.tools === undefined ? await loadToolsFromDir(join(dir, 'tools')) : resolved.tools;
170
+ resolved.tools = [...baseTools, ...(await loadGatewayToolsFromDir(dir))];
163
171
  if (resolved.rules === undefined) {
164
172
  const rulesPath = join(dir, 'actauth.yml');
165
173
  if (existsSync(rulesPath)) {
@@ -196,7 +204,7 @@ async function resolveSubagentConfig(config, dir) {
196
204
  * called again, one level down. A folder can't be its own ancestor, so
197
205
  * this can't cycle the way a hand-wired agentAsTool(getEntry(...)) call
198
206
  * elsewhere could. */
199
- async function loadSubagentTools(config) {
207
+ export async function loadSubagentAsTools(config) {
200
208
  const subagentsDir = join(agentsRootDir, config.name, 'subagents');
201
209
  if (!existsSync(subagentsDir))
202
210
  return [];
@@ -229,10 +237,15 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
229
237
  // (including `[]`) is used as-is; omitted entirely defaults to
230
238
  // importing agents/<name>/tools/index.{ts,js} — see loadDefaultTools's
231
239
  // own doc comment for the full reasoning and the cases that can't use it.
232
- // agents/<name>/subagents/* is merged in on top either way — see
233
- // loadSubagentTools's own doc comment for why that one isn't gated by
240
+ // agents/<name>/subagents/* and agents/<name>/gateway-tools.yml (see
241
+ // gateway-tools.ts) are both merged in on top either way — see
242
+ // loadSubagentAsTools's own doc comment for why neither is gated by
234
243
  // whether `tools` was explicit.
235
- const tools = [...(config.tools ?? (await loadDefaultTools(config))), ...(await loadSubagentTools(config))];
244
+ const tools = [
245
+ ...(config.tools ?? (await loadDefaultTools(config))),
246
+ ...(await loadSubagentAsTools(config)),
247
+ ...(await loadGatewayToolsFromDir(join(agentsRootDir, config.name))),
248
+ ];
236
249
  // Omitted entirely (undefined): default to this agent's own
237
250
  // agents/<name>/skills — the folder-form convention every agent in
238
251
  // this repo already follows (see agent-config.ts's skillsDirs doc
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.0.17",
3
+ "version": "0.0.18",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",
@@ -40,12 +40,14 @@
40
40
  "dist/discover-agents.d.ts",
41
41
  "dist/agent-as-tool.js",
42
42
  "dist/agent-as-tool.d.ts",
43
+ "dist/gateway-tools.js",
44
+ "dist/gateway-tools.d.ts",
43
45
  "LICENSE"
44
46
  ],
45
47
  "scripts": {
46
48
  "build": "tsc && chmod +x dist/cli.js",
47
49
  "prepublishOnly": "npm run build",
48
- "start": "node dist/adapters/http.js",
50
+ "start": "node --env-file-if-exists=.env dist/adapters/http.js",
49
51
  "test": "vitest run"
50
52
  },
51
53
  "dependencies": {