loopengine 0.1.22 → 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 +10 -10
- 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/web/agents-config-page.js +6 -6
- 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/package.json +3 -3
package/README.md
CHANGED
|
@@ -25,10 +25,10 @@ top to bottom. Run agents over a CLI, an HTTP API, or both.
|
|
|
25
25
|
- **Sessions persist automatically.** Send a message, get a reply, come
|
|
26
26
|
back later with the same session id and continue — no database to wire
|
|
27
27
|
up yourself, and it survives a crash mid-turn cleanly.
|
|
28
|
-
- **
|
|
28
|
+
- **An ability system for reusable capabilities.** Install a bundle of
|
|
29
29
|
tools, a skill, and the permission rules that gate them with one command
|
|
30
30
|
— real, reviewable files in your own repo, not an opaque dependency. See
|
|
31
|
-
[
|
|
31
|
+
[Ability system](#ability-system) below.
|
|
32
32
|
- **Does real work, safely.** Tools can hit a database, send emails, call
|
|
33
33
|
GitHub/Slack, anything with an `execute` function. Permission rules gate
|
|
34
34
|
what happens without a human: safe reads auto-run, risky actions get
|
|
@@ -118,7 +118,7 @@ redeploy needed for most of it:
|
|
|
118
118
|
| **Skills** | Create, edit, and delete `SKILL.md` files for this agent directly in the browser — write the body, preview the rendered markdown, save. |
|
|
119
119
|
| **Tools** | Local hand-written tools, gateway-sourced tools (e.g. [Composio](https://composio.dev)), and subagents-as-tools, in one place. Connect a new external gateway source or add/remove a tool without touching a file. |
|
|
120
120
|
| **ActAuth** | Add, edit, and delete permission rules — scope, tool, condition, decision — and change `default_decision`, live. |
|
|
121
|
-
| **Environment** | Every env var
|
|
121
|
+
| **Environment** | Every env var an ability (see [Ability system](#ability-system)) declared it needs, across everything installed for this agent — which ones are set, which are missing, and a form to set one. A value is never echoed back once set. |
|
|
122
122
|
|
|
123
123
|
Every tab is backed by a real API (`GET /agents/:name/config`, `.../actauth`,
|
|
124
124
|
`.../env`, ...) that reuses the exact same resolution `runAgent()` itself
|
|
@@ -170,15 +170,15 @@ click, a webhook HMAC signature, a magic-link token) ship under
|
|
|
170
170
|
live and durable, worked examples, and how resumption actually works
|
|
171
171
|
under the hood.
|
|
172
172
|
|
|
173
|
-
##
|
|
173
|
+
## Ability system
|
|
174
174
|
|
|
175
175
|
Giving an agent a new capability is usually three separate, hand-authored
|
|
176
176
|
things: a tool file, a `SKILL.md` teaching the model when to use it, and
|
|
177
|
-
an `actauth` rule allowing it to actually run. A **loopengine
|
|
177
|
+
an `actauth` rule allowing it to actually run. A **loopengine ability**
|
|
178
178
|
bundles all three into one installable unit:
|
|
179
179
|
|
|
180
180
|
```bash
|
|
181
|
-
npx loopengine add-
|
|
181
|
+
npx loopengine add-ability <spec> --agent customer-service
|
|
182
182
|
```
|
|
183
183
|
|
|
184
184
|
`<spec>` is anything `npm pack` understands — a public or private npm
|
|
@@ -189,17 +189,17 @@ dependency — reviewable, diffable, and editable the same as anything you
|
|
|
189
189
|
would have hand-written, not an opaque black box.
|
|
190
190
|
|
|
191
191
|
```bash
|
|
192
|
-
npx loopengine upgrade-
|
|
193
|
-
npx loopengine remove-
|
|
192
|
+
npx loopengine upgrade-ability <abilityName> --agent customer-service
|
|
193
|
+
npx loopengine remove-ability <abilityName> --agent customer-service
|
|
194
194
|
```
|
|
195
195
|
|
|
196
196
|
Upgrading does a real three-way merge per file — a hand-edit since install
|
|
197
197
|
survives, a genuine conflict leaves `<<<<<<<` markers to resolve by hand
|
|
198
198
|
instead of silently overwriting either side. Removing refuses a file
|
|
199
|
-
that's been modified since install unless you pass `--force`.
|
|
199
|
+
that's been modified since install unless you pass `--force`. An ability
|
|
200
200
|
can also declare the env vars its tools need, which then show up in the
|
|
201
201
|
Admin UI's [Environment tab](#admin-ui) automatically once installed. See
|
|
202
|
-
[`
|
|
202
|
+
[`ABILITIES.md`](ABILITIES.md) for the full design — format, publishing,
|
|
203
203
|
and how the merge/upgrade mechanics work in detail.
|
|
204
204
|
|
|
205
205
|
## Running an agent
|
|
@@ -1,48 +1,54 @@
|
|
|
1
|
-
export declare class
|
|
1
|
+
export declare class AbilityManifestError extends Error {
|
|
2
2
|
}
|
|
3
|
-
export declare class
|
|
3
|
+
export declare class AbilityVersionError extends Error {
|
|
4
4
|
}
|
|
5
|
-
export declare class
|
|
5
|
+
export declare class AbilityCollisionError extends Error {
|
|
6
6
|
}
|
|
7
|
-
export declare class
|
|
7
|
+
export declare class AbilityNotInstalledError extends Error {
|
|
8
8
|
}
|
|
9
|
-
export declare class
|
|
9
|
+
export declare class AbilityAlreadyInstalledError extends Error {
|
|
10
10
|
}
|
|
11
|
-
export interface
|
|
11
|
+
export interface AbilityEnvDecl {
|
|
12
12
|
name: string;
|
|
13
13
|
description?: string;
|
|
14
14
|
secret?: boolean;
|
|
15
15
|
}
|
|
16
|
-
|
|
16
|
+
/** `name`/`version` deliberately aren't declared here — they're read off
|
|
17
|
+
* the ability's own sibling `package.json` instead (see readManifest),
|
|
18
|
+
* which already has to exist and already has to carry real, valid values
|
|
19
|
+
* for `npm pack` to treat the directory as a fetchable package at all.
|
|
20
|
+
* loopengine.ability.json only ever needs to declare what npm has no
|
|
21
|
+
* vocabulary for. */
|
|
22
|
+
export interface AbilityManifest {
|
|
17
23
|
name: string;
|
|
18
24
|
version: string;
|
|
19
25
|
loopengineVersion: string;
|
|
20
26
|
tools?: string[];
|
|
21
27
|
skills?: string[];
|
|
22
28
|
actauth?: string;
|
|
23
|
-
env?:
|
|
29
|
+
env?: AbilityEnvDecl[];
|
|
24
30
|
}
|
|
25
|
-
/** One agent's record of one installed
|
|
31
|
+
/** One agent's record of one installed ability — the merge base a
|
|
26
32
|
* future upgrade needs, what remove needs to know is safe to delete,
|
|
27
33
|
* and (env) what the Admin UI's secrets section reads to know which
|
|
28
|
-
* vars to prompt for. Written to agents/<agent>/.loopengine-
|
|
34
|
+
* vars to prompt for. Written to agents/<agent>/.loopengine-abilities.json,
|
|
29
35
|
* same role .create-loopengine.json already plays for template files. */
|
|
30
|
-
export interface
|
|
36
|
+
export interface InstalledAbilityRecord {
|
|
31
37
|
version: string;
|
|
32
38
|
/** The exact spec (bare registry name, "name@version", a git+ssh/
|
|
33
39
|
* git+https URL with a #committish, a file: path, ...) last used to
|
|
34
|
-
* successfully install or upgrade this
|
|
35
|
-
* oldContentSpec's own doc comment for why
|
|
36
|
-
* stored verbatim rather than reconstructed from `
|
|
37
|
-
* Optional only because
|
|
40
|
+
* successfully install or upgrade this ability — see
|
|
41
|
+
* oldContentSpec's own doc comment for why upgradeAbility needs this
|
|
42
|
+
* stored verbatim rather than reconstructed from `abilityName`.
|
|
43
|
+
* Optional only because an ability installed before this field existed
|
|
38
44
|
* has no recorded value — see oldContentSpec's own fallback. */
|
|
39
45
|
spec?: string;
|
|
40
46
|
tools: string[];
|
|
41
47
|
skills: string[];
|
|
42
48
|
actauthRules: string[];
|
|
43
|
-
env:
|
|
49
|
+
env: AbilityEnvDecl[];
|
|
44
50
|
/** relative path (e.g. "tools/foo.ts") -> sha256 hex, as of the last
|
|
45
|
-
* install/upgrade — remove-
|
|
51
|
+
* install/upgrade — remove-ability's dirty-check compares against
|
|
46
52
|
* this rather than storing/refetching full content to diff. */
|
|
47
53
|
contentHashes: Record<string, string>;
|
|
48
54
|
}
|
|
@@ -52,11 +58,11 @@ export interface InstalledPackageRecord {
|
|
|
52
58
|
* accepts anything `npm pack` does: a public/private registry spec, a
|
|
53
59
|
* git URL (`github:org/repo`, `git+ssh://...`), or a local `file:../path`
|
|
54
60
|
* — the last of which is what this module's own tests point at, so no
|
|
55
|
-
* real network call is needed there; see
|
|
56
|
-
*
|
|
57
|
-
export declare function
|
|
61
|
+
* real network call is needed there; see ABILITIES.md's "Publishing an
|
|
62
|
+
* ability" section). */
|
|
63
|
+
export declare function fetchAbilityDir(spec: string): string;
|
|
58
64
|
export interface FetchOptions {
|
|
59
|
-
|
|
65
|
+
fetchAbilityDir?: (spec: string) => string;
|
|
60
66
|
/** Test-only override for the installing project's own "loopengine"
|
|
61
67
|
* dependency range — defaults to a real package.json read. Without
|
|
62
68
|
* this, checkLoopengineVersion's refusal path is unreachable from
|
|
@@ -71,7 +77,7 @@ export interface FetchOptions {
|
|
|
71
77
|
* (reusing addToolToIndex exactly as web/http-tool-admin.ts's own
|
|
72
78
|
* createHttpTool does), copies each skill directory verbatim (a raw
|
|
73
79
|
* cpSync, not skills-admin.ts's writeSkill — that regenerates
|
|
74
|
-
* frontmatter from scratch and can't carry
|
|
80
|
+
* frontmatter from scratch and can't carry an ability's own
|
|
75
81
|
* frontmatter/assets), and appends each actauth rule (addActauthRule).
|
|
76
82
|
*
|
|
77
83
|
* Deliberately does *not* attempt a live in-memory registry splice the
|
|
@@ -79,7 +85,7 @@ export interface FetchOptions {
|
|
|
79
85
|
* admin-created tool — that works there because the HTTP request
|
|
80
86
|
* creating the tool runs *inside the same process* as the already-running
|
|
81
87
|
* server, so updateAgent's mutation is visible to every later request in
|
|
82
|
-
* that same process immediately. `add-
|
|
88
|
+
* that same process immediately. `add-ability` is a separate, one-off
|
|
83
89
|
* CLI process with no connection to whatever server might be running
|
|
84
90
|
* elsewhere — even a successful getEntry/updateAgent call here would
|
|
85
91
|
* only mutate *this* CLI invocation's own throwaway registry, then
|
|
@@ -96,41 +102,41 @@ export interface FetchOptions {
|
|
|
96
102
|
*
|
|
97
103
|
* All-or-nothing: every collision check below runs, and the whole
|
|
98
104
|
* install is refused, before a single file is written. */
|
|
99
|
-
export declare function
|
|
105
|
+
export declare function installAbility(agentName: string, spec: string, options?: FetchOptions): Promise<{
|
|
100
106
|
installed: string[];
|
|
101
107
|
}>;
|
|
102
108
|
export interface UpgradeFileResult {
|
|
103
109
|
path: string;
|
|
104
110
|
status: 'updated' | 'unchanged' | 'conflict';
|
|
105
111
|
}
|
|
106
|
-
/** Upgrades an already-installed
|
|
107
|
-
* to `
|
|
112
|
+
/** Upgrades an already-installed ability to whatever `spec` (defaulting
|
|
113
|
+
* to `abilityName`, i.e. "latest") currently resolves to. Tool and skill
|
|
108
114
|
* files get a real three-way merge (mine = current file, possibly
|
|
109
115
|
* hand-edited since install; base = the version recorded at
|
|
110
116
|
* install/last-upgrade, refetched via oldContentSpec; theirs = newly
|
|
111
117
|
* fetched) — identical technique to `create-loopengine upgrade`, just
|
|
112
|
-
* applied to
|
|
118
|
+
* applied to ability-managed files instead of template files. actauth
|
|
113
119
|
* rules upgrade per-rule instead: the target actauth.yml holds rules
|
|
114
|
-
* from other
|
|
120
|
+
* from other abilities and hand-written ones too, so there's no coherent
|
|
115
121
|
* "whole file" base/theirs to merge — a rule unchanged since install
|
|
116
122
|
* updates cleanly; a hand-edited one is left alone and reported as a
|
|
117
123
|
* conflict rather than overwritten. */
|
|
118
|
-
export declare function
|
|
124
|
+
export declare function upgradeAbility(agentName: string, abilityName: string, options?: FetchOptions & {
|
|
119
125
|
spec?: string;
|
|
120
126
|
}): Promise<{
|
|
121
127
|
files: UpgradeFileResult[];
|
|
122
128
|
}>;
|
|
123
|
-
/** Removes an installed
|
|
129
|
+
/** Removes an installed ability's tool and skill files, and its actauth
|
|
124
130
|
* rules. A file whose content no longer matches the hash recorded at
|
|
125
131
|
* install/last-upgrade is refused (reported in `refused`, left on disk)
|
|
126
|
-
* unless `force` is passed — a concrete implementation of
|
|
132
|
+
* unless `force` is passed — a concrete implementation of ABILITIES.md's
|
|
127
133
|
* "refuses if any of them look hand-modified," cheaper than storing full
|
|
128
|
-
* content or refetching the
|
|
134
|
+
* content or refetching the ability to diff. Does not patch
|
|
129
135
|
* tools/index.ts to remove the now-dangling import — same "refuse
|
|
130
136
|
* rather than guess a second time" doctrine addToolToIndex's own doc
|
|
131
137
|
* comment already applies; a stale import surfaces as a clear build
|
|
132
138
|
* error, not a silent break. */
|
|
133
|
-
export declare function
|
|
139
|
+
export declare function removeAbility(agentName: string, abilityName: string, force?: boolean): {
|
|
134
140
|
removed: string[];
|
|
135
141
|
refused: string[];
|
|
136
142
|
};
|
|
@@ -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');
|
|
@@ -1110,9 +1110,9 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1110
1110
|
});
|
|
1111
1111
|
}
|
|
1112
1112
|
|
|
1113
|
-
// ---- Environment tab: every env var
|
|
1114
|
-
// bin/
|
|
1115
|
-
// every
|
|
1113
|
+
// ---- Environment tab: every env var an ability (see ABILITIES.md,
|
|
1114
|
+
// bin/ability-manager.ts) declared it needs for this agent, across
|
|
1115
|
+
// every ability installed — status only, a value is never shown once
|
|
1116
1116
|
// set, matching env-admin.ts's own never-echo-a-secret rule. Lazily
|
|
1117
1117
|
// loaded the same way Actauth is, for the same reason (avoid a
|
|
1118
1118
|
// redundant fetch on every agent switch for a tab that isn't always
|
|
@@ -1126,7 +1126,7 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1126
1126
|
return '<tr>' +
|
|
1127
1127
|
'<td><code>' + escapeHtml(v.name) + '</code></td>' +
|
|
1128
1128
|
'<td>' + escapeHtml(v.description || '') + '</td>' +
|
|
1129
|
-
'<td class="hint">' + escapeHtml(v.
|
|
1129
|
+
'<td class="hint">' + escapeHtml(v.abilityName) + '</td>' +
|
|
1130
1130
|
'<td>' + (v.set ? '<span class="hint">set</span>' : '<span class="error">not set</span>') + '</td>' +
|
|
1131
1131
|
'<td><form class="add-source env-var-form" data-name="' + escapeHtml(v.name) + '">' +
|
|
1132
1132
|
'<input type="' + (v.secret ? 'password' : 'text') + '" name="value" placeholder="' + (v.set ? 'unchanged unless you type a new value' : 'value') + '" required>' +
|
|
@@ -1136,9 +1136,9 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1136
1136
|
}
|
|
1137
1137
|
|
|
1138
1138
|
function renderEnvConfigHtml(vars) {
|
|
1139
|
-
if (!vars.length) return '<p class="hint">No installed
|
|
1139
|
+
if (!vars.length) return '<p class="hint">No installed ability has declared any environment variables for this agent yet.</p>';
|
|
1140
1140
|
var rows = vars.map(renderEnvRow).join('');
|
|
1141
|
-
return '<table><thead><tr><th>Name</th><th>Description</th><th>
|
|
1141
|
+
return '<table><thead><tr><th>Name</th><th>Description</th><th>Ability</th><th>Status</th><th>Set value</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
|
1142
1142
|
}
|
|
1143
1143
|
|
|
1144
1144
|
function envContentEl() {
|
package/dist/web/env-admin.d.ts
CHANGED
|
@@ -4,13 +4,13 @@ export interface DeclaredEnvVar {
|
|
|
4
4
|
name: string;
|
|
5
5
|
description?: string;
|
|
6
6
|
secret: boolean;
|
|
7
|
-
|
|
7
|
+
abilityName: string;
|
|
8
8
|
set: boolean;
|
|
9
9
|
}
|
|
10
|
-
/** Every env var any
|
|
11
|
-
* needs, deduplicated by name —
|
|
12
|
-
* cross-
|
|
13
|
-
* *project*, not per agent, so two unrelated
|
|
10
|
+
/** Every env var any ability installed for `agentName` declared it
|
|
11
|
+
* needs, deduplicated by name — ABILITIES.md's own open question on
|
|
12
|
+
* cross-ability name collisions applies here too (one .env per
|
|
13
|
+
* *project*, not per agent, so two unrelated abilities declaring the
|
|
14
14
|
* same name can't actually be told apart; the first one seen wins the
|
|
15
15
|
* description shown). `set` is read live off `process.env`, not cached,
|
|
16
16
|
* so it reflects whatever the last `setEnvVar` call — or a plain
|
|
@@ -19,7 +19,7 @@ export declare function listDeclaredEnvVars(agentName: string): DeclaredEnvVar[]
|
|
|
19
19
|
/** Upserts `NAME=VALUE` into the project's `.env` file — preserving
|
|
20
20
|
* every other line (comments, blank lines, unrelated keys) — and
|
|
21
21
|
* applies it to *this* running process immediately via `process.env`,
|
|
22
|
-
* so a newly-installed
|
|
22
|
+
* so a newly-installed ability's tools work without a restart. Callers
|
|
23
23
|
* (the PUT route in adapters/http.ts) are responsible for refusing to
|
|
24
24
|
* call this at all when `LOOPENGINE_ADMIN_AUTH` isn't set — this
|
|
25
25
|
* function itself has no notion of HTTP auth, it just writes. */
|
package/dist/web/env-admin.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// Backs the Admin UI's "Environment" section — every env var
|
|
2
|
-
// (see
|
|
3
|
-
// across every
|
|
1
|
+
// Backs the Admin UI's "Environment" section — every env var an ability
|
|
2
|
+
// (see ABILITIES.md, bin/ability-manager.ts) declared as required,
|
|
3
|
+
// across every ability installed for an agent, with set/not-set status
|
|
4
4
|
// only. A value marked `secret` is never echoed back once set — same
|
|
5
5
|
// never-echo-a-secret rule web/http-tool-admin.ts's own `{{ENV_VAR}}`
|
|
6
6
|
// header handling already establishes for a tool's own secrets.
|
|
@@ -13,12 +13,12 @@ export class EnvVarNameError extends Error {
|
|
|
13
13
|
// validates a {{ENV_VAR}} header reference against.
|
|
14
14
|
const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
|
15
15
|
function provenancePath(agentName) {
|
|
16
|
-
return join(agentDir(agentName), '.loopengine-
|
|
16
|
+
return join(agentDir(agentName), '.loopengine-abilities.json');
|
|
17
17
|
}
|
|
18
|
-
/** Every env var any
|
|
19
|
-
* needs, deduplicated by name —
|
|
20
|
-
* cross-
|
|
21
|
-
* *project*, not per agent, so two unrelated
|
|
18
|
+
/** Every env var any ability installed for `agentName` declared it
|
|
19
|
+
* needs, deduplicated by name — ABILITIES.md's own open question on
|
|
20
|
+
* cross-ability name collisions applies here too (one .env per
|
|
21
|
+
* *project*, not per agent, so two unrelated abilities declaring the
|
|
22
22
|
* same name can't actually be told apart; the first one seen wins the
|
|
23
23
|
* description shown). `set` is read live off `process.env`, not cached,
|
|
24
24
|
* so it reflects whatever the last `setEnvVar` call — or a plain
|
|
@@ -30,7 +30,7 @@ export function listDeclaredEnvVars(agentName) {
|
|
|
30
30
|
const provenance = JSON.parse(readFileSync(path, 'utf8'));
|
|
31
31
|
const seen = new Set();
|
|
32
32
|
const result = [];
|
|
33
|
-
for (const [
|
|
33
|
+
for (const [abilityName, record] of Object.entries(provenance)) {
|
|
34
34
|
for (const decl of record.env) {
|
|
35
35
|
if (seen.has(decl.name))
|
|
36
36
|
continue;
|
|
@@ -39,7 +39,7 @@ export function listDeclaredEnvVars(agentName) {
|
|
|
39
39
|
name: decl.name,
|
|
40
40
|
description: decl.description,
|
|
41
41
|
secret: decl.secret === true,
|
|
42
|
-
|
|
42
|
+
abilityName,
|
|
43
43
|
set: process.env[decl.name] !== undefined,
|
|
44
44
|
});
|
|
45
45
|
}
|
|
@@ -68,7 +68,7 @@ function serializeEnvValue(value) {
|
|
|
68
68
|
/** Upserts `NAME=VALUE` into the project's `.env` file — preserving
|
|
69
69
|
* every other line (comments, blank lines, unrelated keys) — and
|
|
70
70
|
* applies it to *this* running process immediately via `process.env`,
|
|
71
|
-
* so a newly-installed
|
|
71
|
+
* so a newly-installed ability's tools work without a restart. Callers
|
|
72
72
|
* (the PUT route in adapters/http.ts) are responsible for refusing to
|
|
73
73
|
* call this at all when `LOOPENGINE_ADMIN_AUTH` isn't set — this
|
|
74
74
|
* function itself has no notion of HTTP auth, it just writes. */
|
|
@@ -40,9 +40,9 @@ export declare function toCamelCase(snakeCase: string): string;
|
|
|
40
40
|
export declare function addToolToIndex(toolsIndexPath: string, toolFileName: string, exportName: string): void;
|
|
41
41
|
/** addToolToIndex's own undo — removes the named tool's import line and
|
|
42
42
|
* array entry. Best-effort, not "refuse rather than guess" like
|
|
43
|
-
* addToolToIndex: this only ever runs against a tool
|
|
43
|
+
* addToolToIndex: this only ever runs against a tool ability-manager.ts
|
|
44
44
|
* itself installed (so the exact shape addToolToIndex wrote is known),
|
|
45
|
-
* and the caller (
|
|
45
|
+
* and the caller (removeAbility) has already deleted the tool's .ts
|
|
46
46
|
* file — leaving a stale entry behind, the previous behavior, means a
|
|
47
47
|
* dangling import that fails the next build, which is strictly worse
|
|
48
48
|
* than a no-op here if the file turns out not to match (hand-edited
|
|
@@ -191,8 +191,8 @@ export function addToolToIndex(toolsIndexPath, toolFileName, exportName) {
|
|
|
191
191
|
if (!arrayMatch) {
|
|
192
192
|
throw new HttpToolIndexShapeError(`${toolsIndexPath} doesn't match the expected "export const tools: ToolDefinition[] = [...]" shape — add this tool to it by hand instead.`);
|
|
193
193
|
}
|
|
194
|
-
// Idempotent — a caller that adds the same tool twice (e.g.
|
|
195
|
-
// manager.ts's
|
|
194
|
+
// Idempotent — a caller that adds the same tool twice (e.g. ability-
|
|
195
|
+
// manager.ts's removeAbility not having cleaned up this same file
|
|
196
196
|
// before a re-add, or simply calling this twice by mistake) gets a
|
|
197
197
|
// no-op the second time instead of a duplicate `import { X }`
|
|
198
198
|
// declaration, which is a TypeScript syntax error ("Duplicate
|
|
@@ -213,9 +213,9 @@ export function addToolToIndex(toolsIndexPath, toolFileName, exportName) {
|
|
|
213
213
|
}
|
|
214
214
|
/** addToolToIndex's own undo — removes the named tool's import line and
|
|
215
215
|
* array entry. Best-effort, not "refuse rather than guess" like
|
|
216
|
-
* addToolToIndex: this only ever runs against a tool
|
|
216
|
+
* addToolToIndex: this only ever runs against a tool ability-manager.ts
|
|
217
217
|
* itself installed (so the exact shape addToolToIndex wrote is known),
|
|
218
|
-
* and the caller (
|
|
218
|
+
* and the caller (removeAbility) has already deleted the tool's .ts
|
|
219
219
|
* file — leaving a stale entry behind, the previous behavior, means a
|
|
220
220
|
* dangling import that fails the next build, which is strictly worse
|
|
221
221
|
* than a no-op here if the file turns out not to match (hand-edited
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loopengine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.24",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"dist/index.d.ts",
|
|
24
24
|
"dist/bin/cli.js",
|
|
25
25
|
"dist/bin/cli.d.ts",
|
|
26
|
-
"dist/bin/
|
|
27
|
-
"dist/bin/
|
|
26
|
+
"dist/bin/ability-manager.js",
|
|
27
|
+
"dist/bin/ability-manager.d.ts",
|
|
28
28
|
"dist/core/run-agent.js",
|
|
29
29
|
"dist/core/run-agent.d.ts",
|
|
30
30
|
"dist/core/loop-events.js",
|