loopengine 0.0.16 → 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 +33 -3
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +107 -12
- package/dist/discover-agents.d.ts +1 -1
- package/dist/discover-agents.js +1 -1
- package/dist/gateway-tools.d.ts +194 -0
- package/dist/gateway-tools.js +548 -0
- package/dist/run-agent.d.ts +22 -0
- package/dist/run-agent.js +22 -9
- package/package.json +4 -2
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.
|
|
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
|
-
###
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// The `loopengine` bin
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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.
|
|
11
|
+
import { realpathSync } from 'node:fs';
|
|
8
12
|
import { access, mkdir, writeFile } from 'node:fs/promises';
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
9
14
|
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
10
16
|
const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
11
17
|
export class AgentNameError extends Error {
|
|
12
18
|
}
|
|
@@ -26,7 +32,7 @@ export const config: AgentConfig = {
|
|
|
26
32
|
}
|
|
27
33
|
/** Same shape as agentIndexTemplate, plus `toolDescription` — the one
|
|
28
34
|
* field a subagent needs that a top-level agent doesn't. run-agent.ts's
|
|
29
|
-
*
|
|
35
|
+
* loadSubagentAsTools wraps this config with agentAsTool, which throws
|
|
30
36
|
* without a real toolDescription: it's what the parent agent's model
|
|
31
37
|
* reads to decide when to delegate here, and systemPrompt (instructions
|
|
32
38
|
* for this agent itself) isn't a substitute. See agent-as-tool.ts. */
|
|
@@ -77,7 +83,7 @@ function resolveParentDir(baseDir, parent) {
|
|
|
77
83
|
/** Writes <parentDir>/subagents/<name>/index.ts under baseDir and
|
|
78
84
|
* returns its path, where <parentDir> is `parent` resolved by
|
|
79
85
|
* resolveParentDir — the folder convention run-agent.ts's
|
|
80
|
-
*
|
|
86
|
+
* loadSubagentAsTools auto-discovers, so this subagent becomes one of
|
|
81
87
|
* `parent`'s tools with no further edits: no import, no
|
|
82
88
|
* AgentConfig.tools entry, nothing to register by hand. Requires
|
|
83
89
|
* `parent` to already exist (its own index.ts) — a subagent without a
|
|
@@ -111,6 +117,36 @@ async function pathExists(p) {
|
|
|
111
117
|
return false;
|
|
112
118
|
}
|
|
113
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
|
+
}
|
|
114
150
|
async function main() {
|
|
115
151
|
const [, , command, ...rest] = process.argv;
|
|
116
152
|
if (command === 'add-agent') {
|
|
@@ -163,12 +199,71 @@ async function main() {
|
|
|
163
199
|
console.log(`Run the top-level agent: npx tsx adapters/cli.ts --agent ${parent.split('/')[0]} "hello"`);
|
|
164
200
|
return;
|
|
165
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
|
+
}
|
|
166
243
|
console.error('Usage: loopengine add-agent <name>');
|
|
167
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');
|
|
168
248
|
process.exitCode = 1;
|
|
169
249
|
}
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
250
|
+
// A package-manager shim (npx, node_modules/.bin) invokes this file
|
|
251
|
+
// through a symlink, not its real path — process.argv[1] is the
|
|
252
|
+
// symlink's path, while Node resolves import.meta.url to the real path
|
|
253
|
+
// when loading an ES module. Comparing the raw argv[1] against the
|
|
254
|
+
// resolved module URL fails in exactly that case and main() silently
|
|
255
|
+
// never runs — realpath argv[1] first so both sides are resolved. (Same
|
|
256
|
+
// bug, same fix, as create-loopengine's own cli.ts — see its git history
|
|
257
|
+
// for the live symlink repro, and skillgarden's CLI before that.)
|
|
258
|
+
function isMain() {
|
|
259
|
+
if (!process.argv[1])
|
|
260
|
+
return false;
|
|
261
|
+
try {
|
|
262
|
+
return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
174
267
|
}
|
|
268
|
+
if (isMain())
|
|
269
|
+
main();
|
|
@@ -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 /
|
|
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
|
package/dist/discover-agents.js
CHANGED
|
@@ -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 /
|
|
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
|
+
}
|
package/dist/run-agent.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
161
|
-
|
|
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
|
|
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/*
|
|
233
|
-
//
|
|
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 = [
|
|
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.
|
|
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": {
|