loopengine 0.1.15 → 0.1.24
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 +124 -299
- package/dist/bin/{package-manager.d.ts → ability-manager.d.ts} +39 -33
- package/dist/bin/{package-manager.js → ability-manager.js} +85 -74
- package/dist/bin/cli.js +22 -22
- package/dist/core/agent-config.d.ts +1 -1
- package/dist/core/gateway-tools.js +4 -4
- package/dist/core/mcpplug.d.ts +23 -0
- package/dist/core/mcpplug.js +64 -0
- package/dist/core/run-agent.js +2 -2
- package/dist/core/session-store.d.ts +1 -1
- package/dist/core/session-store.js +1 -1
- package/dist/core/sessionknit.d.ts +87 -0
- package/dist/core/sessionknit.js +189 -0
- package/dist/core/skillgarden/activation.d.ts +4 -0
- package/dist/core/skillgarden/activation.js +16 -0
- package/dist/core/skillgarden/budget.d.ts +20 -0
- package/dist/core/skillgarden/budget.js +28 -0
- package/dist/core/skillgarden/discovery.d.ts +11 -0
- package/dist/core/skillgarden/discovery.js +40 -0
- package/dist/core/skillgarden/frontmatter.d.ts +10 -0
- package/dist/core/skillgarden/frontmatter.js +35 -0
- package/dist/core/skillgarden/index.d.ts +8 -0
- package/dist/core/skillgarden/index.js +12 -0
- package/dist/core/skillgarden/loader.d.ts +31 -0
- package/dist/core/skillgarden/loader.js +70 -0
- package/dist/core/skillgarden/substitute.d.ts +4 -0
- package/dist/core/skillgarden/substitute.js +13 -0
- package/dist/core/skillgarden/types.d.ts +23 -0
- package/dist/core/skillgarden/types.js +1 -0
- package/dist/core/toollane.d.ts +46 -0
- package/dist/core/toollane.js +69 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +4 -1
- package/dist/web/agents-config-page.js +7 -203
- package/dist/web/env-admin.d.ts +6 -6
- package/dist/web/env-admin.js +11 -11
- package/dist/web/http-tool-admin.d.ts +2 -2
- package/dist/web/http-tool-admin.js +4 -4
- package/dist/web/skills-admin.js +1 -1
- package/package.json +10 -7
- package/dist/web/skillgarden-admin.d.ts +0 -24
- package/dist/web/skillgarden-admin.js +0 -76
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// Backs `loopengine add-
|
|
2
|
-
// bin/cli.ts) — the install/upgrade/remove mechanics
|
|
3
|
-
// specifies for a "loopengine
|
|
1
|
+
// Backs `loopengine add-ability|upgrade-ability|remove-ability` (see
|
|
2
|
+
// bin/cli.ts) — the install/upgrade/remove mechanics ABILITIES.md
|
|
3
|
+
// specifies for a "loopengine ability": a bundle of tool files, skill
|
|
4
4
|
// directories, and actauth rules, installed by *copying* files into an
|
|
5
|
-
// agent's own tree (never an npm import) so
|
|
5
|
+
// agent's own tree (never an npm import) so an ability's code is exactly
|
|
6
6
|
// as reviewable/hand-editable as anything the Admin UI's HTTP tool
|
|
7
7
|
// builder already generates (see web/http-tool-admin.ts's own header
|
|
8
8
|
// comment on `generateToolCode` for that same "real code, not an opaque
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// `threeWayMerge`'s `git merge-file --diff3` — since `loopengine` and
|
|
15
15
|
// `create-loopengine` are separate published packages and a runtime
|
|
16
16
|
// depending on a scaffolding tool (or vice versa) isn't a dependency
|
|
17
|
-
// direction worth introducing to share ~30 lines (see
|
|
17
|
+
// direction worth introducing to share ~30 lines (see ABILITIES.md's
|
|
18
18
|
// "Upgrading" section).
|
|
19
19
|
import { createHash } from 'node:crypto';
|
|
20
20
|
import { execFileSync } from 'node:child_process';
|
|
@@ -26,29 +26,29 @@ import semver from 'semver';
|
|
|
26
26
|
import { agentDir } from '../core/gateway-tools.js';
|
|
27
27
|
import { addToolToIndex, removeToolFromIndex, toCamelCase } from '../web/http-tool-admin.js';
|
|
28
28
|
import { addActauthRule, updateActauthRule, removeActauthRule, readActauthConfig } from '../web/actauth-admin.js';
|
|
29
|
-
export class
|
|
29
|
+
export class AbilityManifestError extends Error {
|
|
30
30
|
}
|
|
31
|
-
export class
|
|
31
|
+
export class AbilityVersionError extends Error {
|
|
32
32
|
}
|
|
33
|
-
export class
|
|
33
|
+
export class AbilityCollisionError extends Error {
|
|
34
34
|
}
|
|
35
|
-
export class
|
|
35
|
+
export class AbilityNotInstalledError extends Error {
|
|
36
36
|
}
|
|
37
|
-
export class
|
|
37
|
+
export class AbilityAlreadyInstalledError extends Error {
|
|
38
38
|
}
|
|
39
39
|
// Same character sets tool names / skill ids are already validated
|
|
40
40
|
// against elsewhere (web/http-tool-admin.ts's TOOL_NAME_PATTERN,
|
|
41
41
|
// web/skills-admin.ts's SKILL_ID_PATTERN) — re-checked here because a
|
|
42
|
-
// name derived from an untrusted
|
|
42
|
+
// name derived from an untrusted ability's own manifest becomes a path
|
|
43
43
|
// segment (agents/<agent>/tools/<name>.ts, .../skills/<id>/): without
|
|
44
|
-
// this,
|
|
44
|
+
// this, an ability declaring a skill dir literally named ".." would have
|
|
45
45
|
// `basename('..')` hand back `'..'` unchanged (path.basename does not
|
|
46
46
|
// strip it), and the later `cpSync` would land one directory up, inside
|
|
47
47
|
// the agent's own root instead of its skills/ folder.
|
|
48
48
|
const TOOL_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
|
|
49
49
|
const SKILL_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
50
50
|
function provenancePath(agentName) {
|
|
51
|
-
return join(agentDir(agentName), '.loopengine-
|
|
51
|
+
return join(agentDir(agentName), '.loopengine-abilities.json');
|
|
52
52
|
}
|
|
53
53
|
function readProvenance(agentName) {
|
|
54
54
|
const path = provenancePath(agentName);
|
|
@@ -69,10 +69,10 @@ function sha256(content) {
|
|
|
69
69
|
* accepts anything `npm pack` does: a public/private registry spec, a
|
|
70
70
|
* git URL (`github:org/repo`, `git+ssh://...`), or a local `file:../path`
|
|
71
71
|
* — the last of which is what this module's own tests point at, so no
|
|
72
|
-
* real network call is needed there; see
|
|
73
|
-
*
|
|
74
|
-
export function
|
|
75
|
-
const workDir = mkdtempSync(join(tmpdir(), 'loopengine-
|
|
72
|
+
* real network call is needed there; see ABILITIES.md's "Publishing an
|
|
73
|
+
* ability" section). */
|
|
74
|
+
export function fetchAbilityDir(spec) {
|
|
75
|
+
const workDir = mkdtempSync(join(tmpdir(), 'loopengine-ability-'));
|
|
76
76
|
execFileSync('npm', ['pack', spec, '--pack-destination', workDir], { stdio: 'pipe' });
|
|
77
77
|
const tarball = readdirSync(workDir).find((f) => f.endsWith('.tgz'));
|
|
78
78
|
if (!tarball) {
|
|
@@ -85,22 +85,33 @@ export function fetchPackageDir(spec) {
|
|
|
85
85
|
execFileSync('tar', ['-xzf', join(workDir, tarball), '-C', workDir], { stdio: 'pipe' });
|
|
86
86
|
return join(workDir, 'package');
|
|
87
87
|
}
|
|
88
|
-
function readManifest(
|
|
89
|
-
const manifestPath = join(
|
|
88
|
+
function readManifest(abilityDir) {
|
|
89
|
+
const manifestPath = join(abilityDir, 'loopengine.ability.json');
|
|
90
90
|
if (!existsSync(manifestPath)) {
|
|
91
|
-
throw new
|
|
91
|
+
throw new AbilityManifestError(`${abilityDir} has no loopengine.ability.json — not a valid loopengine ability.`);
|
|
92
92
|
}
|
|
93
|
-
const
|
|
94
|
-
if (!
|
|
95
|
-
throw new
|
|
93
|
+
const declared = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
94
|
+
if (!declared.loopengineVersion) {
|
|
95
|
+
throw new AbilityManifestError('loopengine.ability.json must have "loopengineVersion".');
|
|
96
96
|
}
|
|
97
|
-
|
|
97
|
+
// name/version come from package.json, not loopengine.ability.json —
|
|
98
|
+
// see AbilityManifest's own doc comment for why duplicating them here
|
|
99
|
+
// would just be two numbers to keep in sync instead of one.
|
|
100
|
+
const pkgJsonPath = join(abilityDir, 'package.json');
|
|
101
|
+
if (!existsSync(pkgJsonPath)) {
|
|
102
|
+
throw new AbilityManifestError(`${abilityDir} has no package.json — every loopengine ability needs one (name/version), even though its own metadata lives in loopengine.ability.json.`);
|
|
103
|
+
}
|
|
104
|
+
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
|
|
105
|
+
if (!pkgJson.name || !pkgJson.version) {
|
|
106
|
+
throw new AbilityManifestError(`${pkgJsonPath} must have "name" and "version".`);
|
|
107
|
+
}
|
|
108
|
+
return { ...declared, name: pkgJson.name, version: pkgJson.version };
|
|
98
109
|
}
|
|
99
110
|
// The installing project's own dependencies.loopengine is itself a
|
|
100
111
|
// range (e.g. "^0.1.10"), not a concrete installed version — checking
|
|
101
112
|
// the *floor* of that range against the manifest's required range is
|
|
102
113
|
// the conservative choice: if even the lowest version the range could
|
|
103
|
-
// resolve to wouldn't satisfy the
|
|
114
|
+
// resolve to wouldn't satisfy the ability, refuse now rather than risk
|
|
104
115
|
// the exact silent-failure gap the Parallel-safe checkbox already hit
|
|
105
116
|
// (a feature that imports an export an *actually installed* older
|
|
106
117
|
// version doesn't have yet, with no error, just quietly not working).
|
|
@@ -131,7 +142,7 @@ function checkLoopengineVersion(manifest, installedRangeOverride) {
|
|
|
131
142
|
return;
|
|
132
143
|
installedRange = ownPkg.dependencies?.loopengine;
|
|
133
144
|
if (!installedRange) {
|
|
134
|
-
throw new
|
|
145
|
+
throw new AbilityVersionError(`This project's package.json has no "loopengine" dependency — can't check compatibility.`);
|
|
135
146
|
}
|
|
136
147
|
}
|
|
137
148
|
// A non-registry dependency specifier (file:, git:, workspace:, ...) —
|
|
@@ -149,19 +160,19 @@ function checkLoopengineVersion(manifest, installedRangeOverride) {
|
|
|
149
160
|
return;
|
|
150
161
|
}
|
|
151
162
|
if (!floor || !semver.satisfies(floor, manifest.loopengineVersion)) {
|
|
152
|
-
throw new
|
|
163
|
+
throw new AbilityVersionError(`Ability '${manifest.name}' needs loopengine ${manifest.loopengineVersion}, but this project depends on loopengine ${installedRange} — bump the dependency first.`);
|
|
153
164
|
}
|
|
154
165
|
}
|
|
155
|
-
function parseActauthRules(
|
|
166
|
+
function parseActauthRules(abilityDir, manifest) {
|
|
156
167
|
if (!manifest.actauth)
|
|
157
168
|
return [];
|
|
158
|
-
const rulesPath = join(
|
|
169
|
+
const rulesPath = join(abilityDir, manifest.actauth);
|
|
159
170
|
if (!existsSync(rulesPath)) {
|
|
160
|
-
throw new
|
|
171
|
+
throw new AbilityManifestError(`Manifest references actauth file '${manifest.actauth}', which doesn't exist in the ability.`);
|
|
161
172
|
}
|
|
162
173
|
const parsed = parseYaml(readFileSync(rulesPath, 'utf8'));
|
|
163
174
|
if (!Array.isArray(parsed)) {
|
|
164
|
-
throw new
|
|
175
|
+
throw new AbilityManifestError(`'${manifest.actauth}' must be a YAML array of {name, scope, tool, decision} rules.`);
|
|
165
176
|
}
|
|
166
177
|
return parsed;
|
|
167
178
|
}
|
|
@@ -171,7 +182,7 @@ function parseActauthRules(packageDir, manifest) {
|
|
|
171
182
|
* (reusing addToolToIndex exactly as web/http-tool-admin.ts's own
|
|
172
183
|
* createHttpTool does), copies each skill directory verbatim (a raw
|
|
173
184
|
* cpSync, not skills-admin.ts's writeSkill — that regenerates
|
|
174
|
-
* frontmatter from scratch and can't carry
|
|
185
|
+
* frontmatter from scratch and can't carry an ability's own
|
|
175
186
|
* frontmatter/assets), and appends each actauth rule (addActauthRule).
|
|
176
187
|
*
|
|
177
188
|
* Deliberately does *not* attempt a live in-memory registry splice the
|
|
@@ -179,7 +190,7 @@ function parseActauthRules(packageDir, manifest) {
|
|
|
179
190
|
* admin-created tool — that works there because the HTTP request
|
|
180
191
|
* creating the tool runs *inside the same process* as the already-running
|
|
181
192
|
* server, so updateAgent's mutation is visible to every later request in
|
|
182
|
-
* that same process immediately. `add-
|
|
193
|
+
* that same process immediately. `add-ability` is a separate, one-off
|
|
183
194
|
* CLI process with no connection to whatever server might be running
|
|
184
195
|
* elsewhere — even a successful getEntry/updateAgent call here would
|
|
185
196
|
* only mutate *this* CLI invocation's own throwaway registry, then
|
|
@@ -196,50 +207,50 @@ function parseActauthRules(packageDir, manifest) {
|
|
|
196
207
|
*
|
|
197
208
|
* All-or-nothing: every collision check below runs, and the whole
|
|
198
209
|
* install is refused, before a single file is written. */
|
|
199
|
-
export async function
|
|
200
|
-
const fetch = options.
|
|
201
|
-
const
|
|
202
|
-
const manifest = readManifest(
|
|
210
|
+
export async function installAbility(agentName, spec, options = {}) {
|
|
211
|
+
const fetch = options.fetchAbilityDir ?? fetchAbilityDir;
|
|
212
|
+
const abilityDir = fetch(spec);
|
|
213
|
+
const manifest = readManifest(abilityDir);
|
|
203
214
|
checkLoopengineVersion(manifest, options.installedLoopengineRange);
|
|
204
215
|
const provenance = readProvenance(agentName);
|
|
205
216
|
if (provenance[manifest.name]) {
|
|
206
|
-
throw new
|
|
217
|
+
throw new AbilityAlreadyInstalledError(`Ability '${manifest.name}' is already installed for '${agentName}' — use upgrade-ability instead.`);
|
|
207
218
|
}
|
|
208
219
|
const toolFiles = manifest.tools ?? [];
|
|
209
220
|
const skillDirs = manifest.skills ?? [];
|
|
210
|
-
const rules = parseActauthRules(
|
|
221
|
+
const rules = parseActauthRules(abilityDir, manifest);
|
|
211
222
|
const toolsDir = join(agentDir(agentName), 'tools');
|
|
212
223
|
const skillsDirPath = join(agentDir(agentName), 'skills');
|
|
213
224
|
// Collision checks — refuse the whole install before writing anything,
|
|
214
225
|
// same "refuse rather than guess" rule HttpToolExistsError/
|
|
215
226
|
// HttpToolIndexShapeError already enforce for a single admin-created
|
|
216
|
-
// tool, just applied
|
|
227
|
+
// tool, just applied ability-wide.
|
|
217
228
|
const toolNames = [];
|
|
218
229
|
for (const toolFile of toolFiles) {
|
|
219
230
|
const toolName = basename(toolFile, '.ts');
|
|
220
231
|
if (!TOOL_NAME_PATTERN.test(toolName)) {
|
|
221
|
-
throw new
|
|
232
|
+
throw new AbilityManifestError(`Tool file '${toolFile}' doesn't name a valid tool (must be lowercase snake_case).`);
|
|
222
233
|
}
|
|
223
234
|
toolNames.push(toolName);
|
|
224
235
|
if (existsSync(join(toolsDir, `${toolName}.ts`))) {
|
|
225
|
-
throw new
|
|
236
|
+
throw new AbilityCollisionError(`agents/${agentName}/tools/${toolName}.ts already exists.`);
|
|
226
237
|
}
|
|
227
238
|
}
|
|
228
239
|
const skillIds = [];
|
|
229
240
|
for (const skillDir of skillDirs) {
|
|
230
241
|
const skillId = basename(skillDir);
|
|
231
242
|
if (!SKILL_ID_PATTERN.test(skillId)) {
|
|
232
|
-
throw new
|
|
243
|
+
throw new AbilityManifestError(`Skill directory '${skillDir}' doesn't name a valid skill id (must be lowercase, hyphen-separated).`);
|
|
233
244
|
}
|
|
234
245
|
skillIds.push(skillId);
|
|
235
246
|
if (existsSync(join(skillsDirPath, skillId))) {
|
|
236
|
-
throw new
|
|
247
|
+
throw new AbilityCollisionError(`agents/${agentName}/skills/${skillId}/ already exists.`);
|
|
237
248
|
}
|
|
238
249
|
}
|
|
239
250
|
const existingRuleNames = new Set(readActauthConfig(agentName).rules.map((r) => r.name));
|
|
240
251
|
for (const rule of rules) {
|
|
241
252
|
if (existingRuleNames.has(rule.name)) {
|
|
242
|
-
throw new
|
|
253
|
+
throw new AbilityCollisionError(`An actauth rule named '${rule.name}' already exists for '${agentName}'.`);
|
|
243
254
|
}
|
|
244
255
|
}
|
|
245
256
|
const contentHashes = {};
|
|
@@ -252,7 +263,7 @@ export async function installPackage(agentName, spec, options = {}) {
|
|
|
252
263
|
const indexPath = join(toolsDir, 'index.ts');
|
|
253
264
|
for (const toolFile of toolFiles) {
|
|
254
265
|
const toolName = basename(toolFile, '.ts');
|
|
255
|
-
const code = readFileSync(join(
|
|
266
|
+
const code = readFileSync(join(abilityDir, toolFile), 'utf8');
|
|
256
267
|
const destPath = join(toolsDir, `${toolName}.ts`);
|
|
257
268
|
writeFileSync(destPath, code);
|
|
258
269
|
contentHashes[`tools/${toolName}.ts`] = sha256(code);
|
|
@@ -268,7 +279,7 @@ export async function installPackage(agentName, spec, options = {}) {
|
|
|
268
279
|
// Copy skill directories verbatim.
|
|
269
280
|
for (const skillDir of skillDirs) {
|
|
270
281
|
const skillId = basename(skillDir);
|
|
271
|
-
const srcPath = join(
|
|
282
|
+
const srcPath = join(abilityDir, skillDir);
|
|
272
283
|
const destPath = join(skillsDirPath, skillId);
|
|
273
284
|
mkdirSync(skillsDirPath, { recursive: true });
|
|
274
285
|
cpSync(srcPath, destPath, { recursive: true });
|
|
@@ -299,7 +310,7 @@ export async function installPackage(agentName, spec, options = {}) {
|
|
|
299
310
|
// threeWayMerge already documents. Operates on a disposable scratch
|
|
300
311
|
// copy, never the real project file directly.
|
|
301
312
|
function mergeFile(mine, base, theirs) {
|
|
302
|
-
const scratchDir = mkdtempSync(join(tmpdir(), 'loopengine-
|
|
313
|
+
const scratchDir = mkdtempSync(join(tmpdir(), 'loopengine-ability-merge-'));
|
|
303
314
|
const minePath = join(scratchDir, 'mine');
|
|
304
315
|
const basePath = join(scratchDir, 'base');
|
|
305
316
|
const theirsPath = join(scratchDir, 'theirs');
|
|
@@ -341,13 +352,13 @@ const PINNED_SPEC = /^(git\+|git:|https?:|file:|\.\.?\/|\/)/;
|
|
|
341
352
|
* would still resolve there today. A git/file/URL spec is returned
|
|
342
353
|
* as-is — see PINNED_SPEC's own doc comment for why appending "@version"
|
|
343
354
|
* to one of those breaks instead of pinning. `recordedSpec` is only
|
|
344
|
-
* absent for
|
|
345
|
-
* existed; falling back to `
|
|
355
|
+
* absent for an ability installed before InstalledAbilityRecord.spec
|
|
356
|
+
* existed; falling back to `abilityName` there reproduces this
|
|
346
357
|
* function's own old (buggy for a git/file install) behavior exactly —
|
|
347
|
-
* no worse than before, and only for
|
|
358
|
+
* no worse than before, and only for an ability that hasn't upgraded
|
|
348
359
|
* since. */
|
|
349
|
-
function oldContentSpec(recordedSpec,
|
|
350
|
-
const base = recordedSpec ??
|
|
360
|
+
function oldContentSpec(recordedSpec, abilityName, version) {
|
|
361
|
+
const base = recordedSpec ?? abilityName;
|
|
351
362
|
if (PINNED_SPEC.test(base))
|
|
352
363
|
return base;
|
|
353
364
|
// Strip any version/tag the spec already carries (a scoped name's own
|
|
@@ -357,27 +368,27 @@ function oldContentSpec(recordedSpec, packageName, version) {
|
|
|
357
368
|
const bareName = base.startsWith('@') ? `@${base.slice(1).split('@')[0]}` : base.split('@')[0];
|
|
358
369
|
return `${bareName}@${version}`;
|
|
359
370
|
}
|
|
360
|
-
/** Upgrades an already-installed
|
|
361
|
-
* to `
|
|
371
|
+
/** Upgrades an already-installed ability to whatever `spec` (defaulting
|
|
372
|
+
* to `abilityName`, i.e. "latest") currently resolves to. Tool and skill
|
|
362
373
|
* files get a real three-way merge (mine = current file, possibly
|
|
363
374
|
* hand-edited since install; base = the version recorded at
|
|
364
375
|
* install/last-upgrade, refetched via oldContentSpec; theirs = newly
|
|
365
376
|
* fetched) — identical technique to `create-loopengine upgrade`, just
|
|
366
|
-
* applied to
|
|
377
|
+
* applied to ability-managed files instead of template files. actauth
|
|
367
378
|
* rules upgrade per-rule instead: the target actauth.yml holds rules
|
|
368
|
-
* from other
|
|
379
|
+
* from other abilities and hand-written ones too, so there's no coherent
|
|
369
380
|
* "whole file" base/theirs to merge — a rule unchanged since install
|
|
370
381
|
* updates cleanly; a hand-edited one is left alone and reported as a
|
|
371
382
|
* conflict rather than overwritten. */
|
|
372
|
-
export async function
|
|
373
|
-
const fetch = options.
|
|
383
|
+
export async function upgradeAbility(agentName, abilityName, options = {}) {
|
|
384
|
+
const fetch = options.fetchAbilityDir ?? fetchAbilityDir;
|
|
374
385
|
const provenance = readProvenance(agentName);
|
|
375
|
-
const record = provenance[
|
|
386
|
+
const record = provenance[abilityName];
|
|
376
387
|
if (!record) {
|
|
377
|
-
throw new
|
|
388
|
+
throw new AbilityNotInstalledError(`Ability '${abilityName}' isn't installed for '${agentName}'.`);
|
|
378
389
|
}
|
|
379
|
-
const spec = options.spec ??
|
|
380
|
-
const oldDir = fetch(oldContentSpec(record.spec,
|
|
390
|
+
const spec = options.spec ?? abilityName;
|
|
391
|
+
const oldDir = fetch(oldContentSpec(record.spec, abilityName, record.version));
|
|
381
392
|
const newDir = fetch(spec);
|
|
382
393
|
const oldManifest = readManifest(oldDir);
|
|
383
394
|
const newManifest = readManifest(newDir);
|
|
@@ -452,7 +463,7 @@ export async function upgradePackage(agentName, packageName, options = {}) {
|
|
|
452
463
|
updateActauthRule(agentName, ruleName, { scope: newRule.scope, tool: newRule.tool, decision: newRule.decision });
|
|
453
464
|
results.push({ path, status: 'updated' });
|
|
454
465
|
}
|
|
455
|
-
provenance[
|
|
466
|
+
provenance[abilityName] = { ...record, version: newManifest.version, spec, contentHashes: newContentHashes, env: newManifest.env ?? record.env };
|
|
456
467
|
writeProvenance(agentName, provenance);
|
|
457
468
|
return { files: results };
|
|
458
469
|
}
|
|
@@ -464,21 +475,21 @@ function isDirty(path, recordedHash) {
|
|
|
464
475
|
return true;
|
|
465
476
|
return sha256(readFileSync(path, 'utf8')) !== recordedHash;
|
|
466
477
|
}
|
|
467
|
-
/** Removes an installed
|
|
478
|
+
/** Removes an installed ability's tool and skill files, and its actauth
|
|
468
479
|
* rules. A file whose content no longer matches the hash recorded at
|
|
469
480
|
* install/last-upgrade is refused (reported in `refused`, left on disk)
|
|
470
|
-
* unless `force` is passed — a concrete implementation of
|
|
481
|
+
* unless `force` is passed — a concrete implementation of ABILITIES.md's
|
|
471
482
|
* "refuses if any of them look hand-modified," cheaper than storing full
|
|
472
|
-
* content or refetching the
|
|
483
|
+
* content or refetching the ability to diff. Does not patch
|
|
473
484
|
* tools/index.ts to remove the now-dangling import — same "refuse
|
|
474
485
|
* rather than guess a second time" doctrine addToolToIndex's own doc
|
|
475
486
|
* comment already applies; a stale import surfaces as a clear build
|
|
476
487
|
* error, not a silent break. */
|
|
477
|
-
export function
|
|
488
|
+
export function removeAbility(agentName, abilityName, force = false) {
|
|
478
489
|
const provenance = readProvenance(agentName);
|
|
479
|
-
const record = provenance[
|
|
490
|
+
const record = provenance[abilityName];
|
|
480
491
|
if (!record) {
|
|
481
|
-
throw new
|
|
492
|
+
throw new AbilityNotInstalledError(`Ability '${abilityName}' isn't installed for '${agentName}'.`);
|
|
482
493
|
}
|
|
483
494
|
const removed = [];
|
|
484
495
|
const refused = [];
|
|
@@ -494,10 +505,10 @@ export function removePackage(agentName, packageName, force = false) {
|
|
|
494
505
|
continue;
|
|
495
506
|
}
|
|
496
507
|
rmSync(fullPath, { force: true });
|
|
497
|
-
// Undoes
|
|
508
|
+
// Undoes installAbility's own addToolToIndex call — without this, a
|
|
498
509
|
// deleted tool's import survives in tools/index.ts, and either fails
|
|
499
510
|
// the next build (a dangling import to a file that no longer exists)
|
|
500
|
-
// or, worse, silently duplicates on a later add-
|
|
511
|
+
// or, worse, silently duplicates on a later add-ability of the same
|
|
501
512
|
// tool (addToolToIndex used to have no idempotence check at all —
|
|
502
513
|
// now it does, but this is the actual fix: the stale entry shouldn't
|
|
503
514
|
// be there to begin with).
|
|
@@ -525,10 +536,10 @@ export function removePackage(agentName, packageName, force = false) {
|
|
|
525
536
|
}
|
|
526
537
|
}
|
|
527
538
|
if (refused.length === 0) {
|
|
528
|
-
delete provenance[
|
|
539
|
+
delete provenance[abilityName];
|
|
529
540
|
}
|
|
530
541
|
else {
|
|
531
|
-
provenance[
|
|
542
|
+
provenance[abilityName] = { ...record, tools: remainingTools, skills: remainingSkills, actauthRules: [] };
|
|
532
543
|
}
|
|
533
544
|
writeProvenance(agentName, provenance);
|
|
534
545
|
return { removed, refused };
|
package/dist/bin/cli.js
CHANGED
|
@@ -13,7 +13,7 @@ import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
|
13
13
|
import { spawn } from 'node:child_process';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
|
-
import {
|
|
16
|
+
import { installAbility, upgradeAbility, removeAbility } from './ability-manager.js';
|
|
17
17
|
const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
18
18
|
export class AgentNameError extends Error {
|
|
19
19
|
}
|
|
@@ -231,7 +231,7 @@ async function requireAdapterFile(relPath) {
|
|
|
231
231
|
process.exitCode = 1;
|
|
232
232
|
return false;
|
|
233
233
|
}
|
|
234
|
-
// Shared by add-
|
|
234
|
+
// Shared by add-ability/upgrade-ability/remove-ability below — pulls a
|
|
235
235
|
// `--flag value` pair out of argv, same inline style `run`'s own
|
|
236
236
|
// `--input` handling already uses, factored out only because three
|
|
237
237
|
// commands need the identical `--agent <name>` extraction (a single use
|
|
@@ -301,22 +301,22 @@ async function main() {
|
|
|
301
301
|
console.log(`Run the top-level agent: npx tsx adapters/cli.ts --agent ${parent.split('/')[0]} "hello"`);
|
|
302
302
|
return;
|
|
303
303
|
}
|
|
304
|
-
// Installs a loopengine
|
|
304
|
+
// Installs a loopengine ability (see ABILITIES.md) — a bundle of tool
|
|
305
305
|
// files, skill directories, and actauth rules published as an ordinary
|
|
306
306
|
// npm package (public, private-registry, git, or a local file: path),
|
|
307
307
|
// copied into the target agent's own tree rather than added as a
|
|
308
|
-
// node_modules import (see
|
|
308
|
+
// node_modules import (see ability-manager.ts's own header comment for
|
|
309
309
|
// why). <spec> is whatever `npm pack` itself accepts.
|
|
310
|
-
if (command === 'add-
|
|
310
|
+
if (command === 'add-ability') {
|
|
311
311
|
const { value: agent, rest: withoutAgent } = extractFlagValue(rest, '--agent');
|
|
312
312
|
const [spec] = withoutAgent;
|
|
313
313
|
if (!spec || !agent) {
|
|
314
|
-
console.error('Usage: loopengine add-
|
|
314
|
+
console.error('Usage: loopengine add-ability <spec> --agent <name>');
|
|
315
315
|
process.exitCode = 1;
|
|
316
316
|
return;
|
|
317
317
|
}
|
|
318
318
|
try {
|
|
319
|
-
const { installed } = await
|
|
319
|
+
const { installed } = await installAbility(agent, spec);
|
|
320
320
|
console.log(`Installed into agents/${agent}/:`);
|
|
321
321
|
for (const p of installed)
|
|
322
322
|
console.log(` ${p}`);
|
|
@@ -330,23 +330,23 @@ async function main() {
|
|
|
330
330
|
}
|
|
331
331
|
return;
|
|
332
332
|
}
|
|
333
|
-
// Pulls in whatever <
|
|
333
|
+
// Pulls in whatever <abilityName> (or --spec, if the ability moved)
|
|
334
334
|
// currently resolves to, three-way-merging each tool/skill file it
|
|
335
335
|
// manages against what changed upstream since install — a hand-edit
|
|
336
336
|
// survives a clean merge; a real conflict leaves <<<<<<< markers to
|
|
337
337
|
// resolve by hand, same as create-loopengine upgrade already does for
|
|
338
338
|
// template files.
|
|
339
|
-
if (command === 'upgrade-
|
|
339
|
+
if (command === 'upgrade-ability') {
|
|
340
340
|
const { value: agent, rest: withoutAgent } = extractFlagValue(rest, '--agent');
|
|
341
341
|
const { value: spec, rest: withoutSpec } = extractFlagValue(withoutAgent, '--spec');
|
|
342
|
-
const [
|
|
343
|
-
if (!
|
|
344
|
-
console.error('Usage: loopengine upgrade-
|
|
342
|
+
const [abilityName] = withoutSpec;
|
|
343
|
+
if (!abilityName || !agent) {
|
|
344
|
+
console.error('Usage: loopengine upgrade-ability <abilityName> --agent <name> [--spec <spec>]');
|
|
345
345
|
process.exitCode = 1;
|
|
346
346
|
return;
|
|
347
347
|
}
|
|
348
348
|
try {
|
|
349
|
-
const { files } = await
|
|
349
|
+
const { files } = await upgradeAbility(agent, abilityName, spec ? { spec } : {});
|
|
350
350
|
for (const f of files)
|
|
351
351
|
console.log(` ${f.status.padEnd(9)}${f.path}`);
|
|
352
352
|
const conflicts = files.filter((f) => f.status === 'conflict');
|
|
@@ -358,20 +358,20 @@ async function main() {
|
|
|
358
358
|
}
|
|
359
359
|
return;
|
|
360
360
|
}
|
|
361
|
-
// Removes
|
|
361
|
+
// Removes an ability's tool/skill files and actauth rules — refuses a
|
|
362
362
|
// file whose content no longer matches what was recorded at
|
|
363
363
|
// install/last-upgrade (a hand-edit since) unless --force is given.
|
|
364
|
-
if (command === 'remove-
|
|
364
|
+
if (command === 'remove-ability') {
|
|
365
365
|
const { value: agent, rest: withoutAgent } = extractFlagValue(rest, '--agent');
|
|
366
366
|
const { present: force, rest: withoutForce } = extractFlagPresence(withoutAgent, '--force');
|
|
367
|
-
const [
|
|
368
|
-
if (!
|
|
369
|
-
console.error('Usage: loopengine remove-
|
|
367
|
+
const [abilityName] = withoutForce;
|
|
368
|
+
if (!abilityName || !agent) {
|
|
369
|
+
console.error('Usage: loopengine remove-ability <abilityName> --agent <name> [--force]');
|
|
370
370
|
process.exitCode = 1;
|
|
371
371
|
return;
|
|
372
372
|
}
|
|
373
373
|
try {
|
|
374
|
-
const { removed, refused } =
|
|
374
|
+
const { removed, refused } = removeAbility(agent, abilityName, force);
|
|
375
375
|
for (const p of removed)
|
|
376
376
|
console.log(` removed ${p}`);
|
|
377
377
|
for (const p of refused)
|
|
@@ -463,9 +463,9 @@ async function main() {
|
|
|
463
463
|
}
|
|
464
464
|
console.error('Usage: loopengine add-agent <name>');
|
|
465
465
|
console.error(' loopengine add-subagent <parent> <name>');
|
|
466
|
-
console.error(' loopengine add-
|
|
467
|
-
console.error(' loopengine upgrade-
|
|
468
|
-
console.error(' loopengine remove-
|
|
466
|
+
console.error(' loopengine add-ability <spec> --agent <name>');
|
|
467
|
+
console.error(' loopengine upgrade-ability <abilityName> --agent <name> [--spec <spec>]');
|
|
468
|
+
console.error(' loopengine remove-ability <abilityName> --agent <name> [--force]');
|
|
469
469
|
console.error(' loopengine run <agent> [--session <id>] "<message>"');
|
|
470
470
|
console.error(' loopengine serve');
|
|
471
471
|
console.error(' loopengine dev');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Rule, Decision, Scope } from 'actauth';
|
|
2
2
|
import type { Redis } from 'ioredis';
|
|
3
|
-
import type { SafetyClassifier } from 'toollane';
|
|
3
|
+
import type { SafetyClassifier } from './toollane.js';
|
|
4
4
|
import type { Summarizer } from './compaction.js';
|
|
5
5
|
/** Which channel a runAgent() call is on — the value RunAgentOptions.channel
|
|
6
6
|
* carries, checked against directly by AgentConfig.httpNotifier's own
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Lets an operator register external tool gateways (Composio today —
|
|
2
|
-
// Nango, Arcade, Scalekit are meant to slot in later as thin
|
|
3
|
-
// ToolSource adapters, same shape, once this mechanism is
|
|
4
|
-
// an agent, without hand-writing a tools/index.ts. The registry is
|
|
2
|
+
// Nango, Arcade, Scalekit are meant to slot in later as thin
|
|
3
|
+
// mcpplug.ts ToolSource adapters, same shape, once this mechanism is
|
|
4
|
+
// proven) against an agent, without hand-writing a tools/index.ts. The registry is
|
|
5
5
|
// agents/<name>/gateway-tools.yml — plain data, read fresh off disk every
|
|
6
6
|
// call, same "no restart to see an edit" behavior actauth.yml already
|
|
7
7
|
// has (see run-agent.ts's loadRules). adapters/http.ts's admin routes are
|
|
@@ -12,7 +12,7 @@ import { join } from 'node:path';
|
|
|
12
12
|
import { execFile } from 'node:child_process';
|
|
13
13
|
import { promisify } from 'node:util';
|
|
14
14
|
import { parse as parseYaml, parseDocument, stringify as stringifyYaml } from 'yaml';
|
|
15
|
-
import { connectComposioSource } from 'mcpplug';
|
|
15
|
+
import { connectComposioSource } from './mcpplug.js';
|
|
16
16
|
const execFileAsync = promisify(execFile);
|
|
17
17
|
// gateway-tools.yml is plain data (see this file's own header comment),
|
|
18
18
|
// not compiled code — same "resolved against process.cwd(), like
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ToolDefinition } from './agent-config.js';
|
|
2
|
+
/** One connection to one external tool gateway (Composio today; a raw MCP
|
|
3
|
+
* client is a later, separate source). `name` is used to namespace every
|
|
4
|
+
* tool it produces, so two sources never collide. */
|
|
5
|
+
export interface ToolSource {
|
|
6
|
+
name: string;
|
|
7
|
+
loadTools(): Promise<ToolDefinition[]>;
|
|
8
|
+
close(): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
export interface ComposioSourceOptions {
|
|
11
|
+
/** Composio tool slugs to expose, e.g.
|
|
12
|
+
* "GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER". Explicit, not
|
|
13
|
+
* auto-discovered — same reasoning actauth rules are opt-in per tool
|
|
14
|
+
* rather than a blanket allow; an agent should only see the Composio
|
|
15
|
+
* actions its config actually lists. */
|
|
16
|
+
slugs: string[];
|
|
17
|
+
/** Defaults to 'composio' — override to point at a stand-in binary in tests. */
|
|
18
|
+
cliCommand?: string;
|
|
19
|
+
}
|
|
20
|
+
/** Sources ToolDefinitions from the Composio CLI — already-authenticated
|
|
21
|
+
* (`composio link <toolkit>`) tool execution across 1000+ apps, so this
|
|
22
|
+
* source needs no OAuth code of its own; Composio owns that entirely. */
|
|
23
|
+
export declare function connectComposioSource(name: string, options: ComposioSourceOptions): Promise<ToolSource>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Formerly the standalone `mcpplug` package — folded in here since it
|
|
2
|
+
// had exactly one real consumer (gateway-tools.ts, below) and the
|
|
3
|
+
// "usable by any agent runtime" framing it shipped with was aspirational,
|
|
4
|
+
// never actually exercised by a second host. Reuses this repo's own
|
|
5
|
+
// ToolDefinition (agent-config.ts) directly instead of mcpplug's own
|
|
6
|
+
// parallel-but-identical copy — that duplication only existed to keep
|
|
7
|
+
// mcpplug import-free of loopengine, which stopped mattering once it
|
|
8
|
+
// moved in.
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
const run = promisify(execFile);
|
|
12
|
+
function humanize(slug) {
|
|
13
|
+
return slug.toLowerCase().split('_').join(' ');
|
|
14
|
+
}
|
|
15
|
+
async function runCli(cliCommand, args) {
|
|
16
|
+
let stdout;
|
|
17
|
+
try {
|
|
18
|
+
;
|
|
19
|
+
({ stdout } = await run(cliCommand, args));
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
23
|
+
throw new Error(`composio CLI call failed (${args.join(' ')}): ${message}`);
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(stdout);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new Error(`composio CLI returned non-JSON output for ${args.join(' ')}: ${stdout}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Sources ToolDefinitions from the Composio CLI — already-authenticated
|
|
33
|
+
* (`composio link <toolkit>`) tool execution across 1000+ apps, so this
|
|
34
|
+
* source needs no OAuth code of its own; Composio owns that entirely. */
|
|
35
|
+
export async function connectComposioSource(name, options) {
|
|
36
|
+
const cliCommand = options.cliCommand ?? 'composio';
|
|
37
|
+
return {
|
|
38
|
+
name,
|
|
39
|
+
async loadTools() {
|
|
40
|
+
const tools = [];
|
|
41
|
+
for (const slug of options.slugs) {
|
|
42
|
+
const schema = await runCli(cliCommand, ['execute', slug, '--get-schema']);
|
|
43
|
+
const inputSchema = schema.inputSchema ?? {};
|
|
44
|
+
tools.push({
|
|
45
|
+
name: `${name}_${slug}`,
|
|
46
|
+
description: humanize(slug),
|
|
47
|
+
input_schema: inputSchema,
|
|
48
|
+
execute: async (input) => {
|
|
49
|
+
const result = await runCli(cliCommand, ['execute', slug, '-d', JSON.stringify(input)]);
|
|
50
|
+
if (result.successful === false || result.error) {
|
|
51
|
+
throw new Error(`composio tool ${slug} failed: ${JSON.stringify(result.error ?? result)}`);
|
|
52
|
+
}
|
|
53
|
+
return result.data ?? result;
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return tools;
|
|
58
|
+
},
|
|
59
|
+
// Each call is its own subprocess — nothing held open between calls,
|
|
60
|
+
// so there's nothing to tear down. Kept for symmetry with sources
|
|
61
|
+
// that do hold a live connection (e.g. a future raw MCP client).
|
|
62
|
+
close: async () => { },
|
|
63
|
+
};
|
|
64
|
+
}
|
package/dist/core/run-agent.js
CHANGED
|
@@ -8,10 +8,10 @@ import { dirname, join } from 'node:path';
|
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
9
|
import { parse as parseYaml } from 'yaml';
|
|
10
10
|
import { Gate, RuleSet, ConsoleApprover } from 'actauth';
|
|
11
|
-
import { SkillGarden } from 'skillgarden';
|
|
11
|
+
import { SkillGarden } from './skillgarden/index.js';
|
|
12
12
|
import { BudgetTracker } from './budget.js';
|
|
13
13
|
import { Compactor } from './compaction.js';
|
|
14
|
-
import { ToolLane } from 'toollane';
|
|
14
|
+
import { ToolLane } from './toollane.js';
|
|
15
15
|
import { Recovery } from './recovery.js';
|
|
16
16
|
import { loadAgentModule } from './discover-agents.js';
|
|
17
17
|
import { agentAsTool } from './agent-as-tool.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Redis } from 'ioredis';
|
|
2
|
-
import { type Storage } from 'sessionknit';
|
|
2
|
+
import { type Storage } from './sessionknit.js';
|
|
3
3
|
import type { Message } from '#core/run-agent.js';
|
|
4
4
|
export interface SessionResult<T> {
|
|
5
5
|
/** Exactly what this turn added — durably appended as-is, in order.
|