loopengine 0.1.22 → 0.1.27
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} +223 -98
- package/dist/bin/cli.js +22 -22
- package/dist/web/agents-config-page.js +15 -6
- package/dist/web/env-admin.d.ts +29 -10
- package/dist/web/env-admin.js +39 -21
- package/dist/web/http-tool-admin.d.ts +23 -12
- package/dist/web/http-tool-admin.js +60 -30
- package/package.json +3 -3
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
|
|
@@ -1123,10 +1123,19 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1123
1123
|
}
|
|
1124
1124
|
|
|
1125
1125
|
function renderEnvRow(v) {
|
|
1126
|
+
// More than one ability declaring the same name doesn't get resolved
|
|
1127
|
+
// here — there's one .env per project, so both read whatever single
|
|
1128
|
+
// value ends up set, whether that's actually correct or a coincidence
|
|
1129
|
+
// (see env-admin.ts's own doc comment). Listing every ability instead
|
|
1130
|
+
// of just the first one seen at least makes that visible to whoever's
|
|
1131
|
+
// looking, instead of silently hiding all but one.
|
|
1132
|
+
var sharedNote = v.abilityNames.length > 1
|
|
1133
|
+
? ' <span class="hint" title="More than one installed ability declares this name — there is only one value for it project-wide, so check whether they actually need the same one.">(shared)</span>'
|
|
1134
|
+
: '';
|
|
1126
1135
|
return '<tr>' +
|
|
1127
1136
|
'<td><code>' + escapeHtml(v.name) + '</code></td>' +
|
|
1128
1137
|
'<td>' + escapeHtml(v.description || '') + '</td>' +
|
|
1129
|
-
'<td class="hint">' + escapeHtml(v.
|
|
1138
|
+
'<td class="hint">' + escapeHtml(v.abilityNames.join(', ')) + sharedNote + '</td>' +
|
|
1130
1139
|
'<td>' + (v.set ? '<span class="hint">set</span>' : '<span class="error">not set</span>') + '</td>' +
|
|
1131
1140
|
'<td><form class="add-source env-var-form" data-name="' + escapeHtml(v.name) + '">' +
|
|
1132
1141
|
'<input type="' + (v.secret ? 'password' : 'text') + '" name="value" placeholder="' + (v.set ? 'unchanged unless you type a new value' : 'value') + '" required>' +
|
|
@@ -1136,9 +1145,9 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1136
1145
|
}
|
|
1137
1146
|
|
|
1138
1147
|
function renderEnvConfigHtml(vars) {
|
|
1139
|
-
if (!vars.length) return '<p class="hint">No installed
|
|
1148
|
+
if (!vars.length) return '<p class="hint">No installed ability has declared any environment variables for this agent yet.</p>';
|
|
1140
1149
|
var rows = vars.map(renderEnvRow).join('');
|
|
1141
|
-
return '<table><thead><tr><th>Name</th><th>Description</th><th>
|
|
1150
|
+
return '<table><thead><tr><th>Name</th><th>Description</th><th>Required by</th><th>Status</th><th>Set value</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
|
1142
1151
|
}
|
|
1143
1152
|
|
|
1144
1153
|
function envContentEl() {
|
package/dist/web/env-admin.d.ts
CHANGED
|
@@ -4,22 +4,41 @@ export interface DeclaredEnvVar {
|
|
|
4
4
|
name: string;
|
|
5
5
|
description?: string;
|
|
6
6
|
secret: boolean;
|
|
7
|
-
|
|
7
|
+
/** Every ability that declares this name, in install order — usually
|
|
8
|
+
* one, but see listDeclaredEnvVars's own doc comment for why this is
|
|
9
|
+
* an array, not a single name. */
|
|
10
|
+
abilityNames: string[];
|
|
8
11
|
set: boolean;
|
|
9
12
|
}
|
|
10
|
-
/** Every env var any
|
|
11
|
-
* needs,
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
/** Every env var any ability installed for `agentName` declared it
|
|
14
|
+
* needs, merged by name. There's one .env per *project*, not per agent
|
|
15
|
+
* or per ability, so this can't actually resolve a genuine cross-ability
|
|
16
|
+
* name collision (two abilities declaring, say, "API_KEY" for two
|
|
17
|
+
* unrelated services still both read whichever single value ends up
|
|
18
|
+
* set — there is no per-ability slot to give them) — unlike a tool or
|
|
19
|
+
* skill name collision (see ability-manager.ts's namespacedToolName/
|
|
20
|
+
* namespacedSkillId), an env var name is a literal `process.env.X`
|
|
21
|
+
* reference baked into the ability's own code, not a label loopengine's
|
|
22
|
+
* own generated glue controls, so there's nothing here to safely rename.
|
|
23
|
+
* What this *can* do, and used to not: surface every ability that
|
|
24
|
+
* declares a given name (`abilityNames`, plural) instead of silently
|
|
25
|
+
* keeping only the first one seen and hiding the rest — turns a
|
|
26
|
+
* collision from invisible into something a human looking at the
|
|
27
|
+
* Environment tab can actually notice and judge (a real conflict
|
|
28
|
+
* needing one ability's own env var renamed, or a coincidence that's
|
|
29
|
+
* actually fine because both intentionally share one real credential).
|
|
30
|
+
* `secret` is true if *any* declaring ability marked it secret — the
|
|
31
|
+
* safer default, since treating a real secret as non-secret because a
|
|
32
|
+
* different ability's own declaration happened to be checked first
|
|
33
|
+
* would be the one direction genuinely worth avoiding. `set` is read
|
|
34
|
+
* live off `process.env`, not cached, so it reflects whatever the last
|
|
35
|
+
* `setEnvVar` call — or a plain restart picking up `.env` — actually
|
|
36
|
+
* did. */
|
|
18
37
|
export declare function listDeclaredEnvVars(agentName: string): DeclaredEnvVar[];
|
|
19
38
|
/** Upserts `NAME=VALUE` into the project's `.env` file — preserving
|
|
20
39
|
* every other line (comments, blank lines, unrelated keys) — and
|
|
21
40
|
* applies it to *this* running process immediately via `process.env`,
|
|
22
|
-
* so a newly-installed
|
|
41
|
+
* so a newly-installed ability's tools work without a restart. Callers
|
|
23
42
|
* (the PUT route in adapters/http.ts) are responsible for refusing to
|
|
24
43
|
* call this at all when `LOOPENGINE_ADMIN_AUTH` isn't set — this
|
|
25
44
|
* 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,38 +13,56 @@ 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,
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
18
|
+
/** Every env var any ability installed for `agentName` declared it
|
|
19
|
+
* needs, merged by name. There's one .env per *project*, not per agent
|
|
20
|
+
* or per ability, so this can't actually resolve a genuine cross-ability
|
|
21
|
+
* name collision (two abilities declaring, say, "API_KEY" for two
|
|
22
|
+
* unrelated services still both read whichever single value ends up
|
|
23
|
+
* set — there is no per-ability slot to give them) — unlike a tool or
|
|
24
|
+
* skill name collision (see ability-manager.ts's namespacedToolName/
|
|
25
|
+
* namespacedSkillId), an env var name is a literal `process.env.X`
|
|
26
|
+
* reference baked into the ability's own code, not a label loopengine's
|
|
27
|
+
* own generated glue controls, so there's nothing here to safely rename.
|
|
28
|
+
* What this *can* do, and used to not: surface every ability that
|
|
29
|
+
* declares a given name (`abilityNames`, plural) instead of silently
|
|
30
|
+
* keeping only the first one seen and hiding the rest — turns a
|
|
31
|
+
* collision from invisible into something a human looking at the
|
|
32
|
+
* Environment tab can actually notice and judge (a real conflict
|
|
33
|
+
* needing one ability's own env var renamed, or a coincidence that's
|
|
34
|
+
* actually fine because both intentionally share one real credential).
|
|
35
|
+
* `secret` is true if *any* declaring ability marked it secret — the
|
|
36
|
+
* safer default, since treating a real secret as non-secret because a
|
|
37
|
+
* different ability's own declaration happened to be checked first
|
|
38
|
+
* would be the one direction genuinely worth avoiding. `set` is read
|
|
39
|
+
* live off `process.env`, not cached, so it reflects whatever the last
|
|
40
|
+
* `setEnvVar` call — or a plain restart picking up `.env` — actually
|
|
41
|
+
* did. */
|
|
26
42
|
export function listDeclaredEnvVars(agentName) {
|
|
27
43
|
const path = provenancePath(agentName);
|
|
28
44
|
if (!existsSync(path))
|
|
29
45
|
return [];
|
|
30
46
|
const provenance = JSON.parse(readFileSync(path, 'utf8'));
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
for (const [packageName, record] of Object.entries(provenance)) {
|
|
47
|
+
const byName = new Map();
|
|
48
|
+
for (const [abilityName, record] of Object.entries(provenance)) {
|
|
34
49
|
for (const decl of record.env) {
|
|
35
|
-
|
|
50
|
+
const existing = byName.get(decl.name);
|
|
51
|
+
if (existing) {
|
|
52
|
+
existing.abilityNames.push(abilityName);
|
|
53
|
+
existing.secret = existing.secret || decl.secret === true;
|
|
36
54
|
continue;
|
|
37
|
-
|
|
38
|
-
|
|
55
|
+
}
|
|
56
|
+
byName.set(decl.name, {
|
|
39
57
|
name: decl.name,
|
|
40
58
|
description: decl.description,
|
|
41
59
|
secret: decl.secret === true,
|
|
42
|
-
|
|
60
|
+
abilityNames: [abilityName],
|
|
43
61
|
set: process.env[decl.name] !== undefined,
|
|
44
62
|
});
|
|
45
63
|
}
|
|
46
64
|
}
|
|
47
|
-
return
|
|
65
|
+
return [...byName.values()];
|
|
48
66
|
}
|
|
49
67
|
// process.cwd(), matching exactly where bin/cli.ts's own runTsx passes
|
|
50
68
|
// --env-file-if-exists=.env (Node's own --env-file resolves relative to
|
|
@@ -68,7 +86,7 @@ function serializeEnvValue(value) {
|
|
|
68
86
|
/** Upserts `NAME=VALUE` into the project's `.env` file — preserving
|
|
69
87
|
* every other line (comments, blank lines, unrelated keys) — and
|
|
70
88
|
* applies it to *this* running process immediately via `process.env`,
|
|
71
|
-
* so a newly-installed
|
|
89
|
+
* so a newly-installed ability's tools work without a restart. Callers
|
|
72
90
|
* (the PUT route in adapters/http.ts) are responsible for refusing to
|
|
73
91
|
* call this at all when `LOOPENGINE_ADMIN_AUTH` isn't set — this
|
|
74
92
|
* function itself has no notion of HTTP auth, it just writes. */
|
|
@@ -37,18 +37,29 @@ export interface HttpToolSpec {
|
|
|
37
37
|
safe?: boolean;
|
|
38
38
|
}
|
|
39
39
|
export declare function toCamelCase(snakeCase: string): string;
|
|
40
|
-
export declare function addToolToIndex(toolsIndexPath: string, toolFileName: string,
|
|
41
|
-
/** addToolToIndex's own undo — removes the named tool's import line
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* since). No-ops (rather
|
|
50
|
-
*
|
|
51
|
-
|
|
40
|
+
export declare function addToolToIndex(toolsIndexPath: string, toolFileName: string, importSpecifier: string, arrayExpression?: string, preamble?: string): void;
|
|
41
|
+
/** addToolToIndex's own undo — removes the named tool's import line,
|
|
42
|
+
* wrapper `const` (if a namespaced install added one), and array entry.
|
|
43
|
+
* Best-effort, not "refuse rather than guess" like addToolToIndex: this
|
|
44
|
+
* only ever runs against a tool ability-manager.ts itself installed (so
|
|
45
|
+
* the exact shape addToolToIndex wrote is known), and the caller
|
|
46
|
+
* (removeAbility) has already deleted the tool's .ts file — leaving a
|
|
47
|
+
* stale entry behind, the previous behavior, means a dangling import
|
|
48
|
+
* that fails the next build, which is strictly worse than a no-op here
|
|
49
|
+
* if the file turns out not to match (hand-edited since). No-ops (rather
|
|
50
|
+
* than throwing) when the file is missing entirely, or the expected
|
|
51
|
+
* import/array-entry shape isn't found.
|
|
52
|
+
*
|
|
53
|
+
* `arrayExpression` (not the raw import specifier) — same value
|
|
54
|
+
* addToolToIndex's own `arrayExpression` was called with, since that's
|
|
55
|
+
* what actually appears in the array literal and (for a namespaced
|
|
56
|
+
* install) names the wrapper `const` too. The import line is matched by
|
|
57
|
+
* `toolFileName` alone, not by the exact imported binding: a namespaced
|
|
58
|
+
* install aliases its import (`X as Y`), so matching on the file path —
|
|
59
|
+
* which uniquely identifies this one tool either way — is what makes
|
|
60
|
+
* this work for both shapes without the caller having to say which one
|
|
61
|
+
* it originally was. */
|
|
62
|
+
export declare function removeToolFromIndex(toolsIndexPath: string, toolFileName: string, arrayExpression: string): void;
|
|
52
63
|
/** Generates agents/<agentName>/tools/<spec.name>.ts, registers it in
|
|
53
64
|
* that agent's tools/index.ts, then imports the file it just wrote and
|
|
54
65
|
* returns the real, callable ToolDefinition object — so the route
|
|
@@ -179,20 +179,36 @@ ${responseHandling} },
|
|
|
179
179
|
// across every real example in this repo (agents/customer-service/
|
|
180
180
|
// tools/index.ts, create-loopengine's own template) — a handful of
|
|
181
181
|
// named imports, then one `export const tools: ToolDefinition[] = [...]`
|
|
182
|
-
// array literal. This patches that exact shape with
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
182
|
+
// array literal. This patches that exact shape with small string edits
|
|
183
|
+
// (one new import line, one new array entry, and — only for a namespaced
|
|
184
|
+
// ability install, see ability-manager.ts's namespacedToolName — one
|
|
185
|
+
// `const` line between them) rather than a full TS-AST rewrite; if the
|
|
186
|
+
// file has been hand-edited into something this pattern doesn't
|
|
187
|
+
// recognize, it refuses instead of risking corrupting it — same "refuse
|
|
188
|
+
// rather than guess" discipline agent-file-admin.ts's own doc comment
|
|
189
|
+
// already establishes for index.ts edits.
|
|
190
|
+
//
|
|
191
|
+
// `importSpecifier` and `arrayExpression` are deliberately separate
|
|
192
|
+
// parameters, not always the same string: the common case (this
|
|
193
|
+
// function's only caller until ability-manager.ts's own namespacing)
|
|
194
|
+
// has them identical (a bare `import { X }`, then bare `X` in the
|
|
195
|
+
// array), but a namespaced tool install needs an *aliased* import (two
|
|
196
|
+
// abilities' own files can both export a same-named top-level `const` —
|
|
197
|
+
// e.g. both literally `webSearch` — which would collide as two
|
|
198
|
+
// identically-named imports in this one file even though the source
|
|
199
|
+
// files differ) feeding into a small wrapper `const` that overrides the
|
|
200
|
+
// object's own `name` field (aliasing the import only renames the JS
|
|
201
|
+
// binding, never the `ToolDefinition.name` string the model actually
|
|
202
|
+
// calls by) — `preamble` is that wrapper line, and `arrayExpression` is
|
|
203
|
+
// its name, not the raw import's.
|
|
204
|
+
export function addToolToIndex(toolsIndexPath, toolFileName, importSpecifier, arrayExpression = importSpecifier, preamble) {
|
|
189
205
|
const source = readFileSync(toolsIndexPath, 'utf8');
|
|
190
206
|
const arrayMatch = source.match(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/);
|
|
191
207
|
if (!arrayMatch) {
|
|
192
208
|
throw new HttpToolIndexShapeError(`${toolsIndexPath} doesn't match the expected "export const tools: ToolDefinition[] = [...]" shape — add this tool to it by hand instead.`);
|
|
193
209
|
}
|
|
194
|
-
// Idempotent — a caller that adds the same tool twice (e.g.
|
|
195
|
-
// manager.ts's
|
|
210
|
+
// Idempotent — a caller that adds the same tool twice (e.g. ability-
|
|
211
|
+
// manager.ts's removeAbility not having cleaned up this same file
|
|
196
212
|
// before a re-add, or simply calling this twice by mistake) gets a
|
|
197
213
|
// no-op the second time instead of a duplicate `import { X }`
|
|
198
214
|
// declaration, which is a TypeScript syntax error ("Duplicate
|
|
@@ -201,40 +217,54 @@ export function addToolToIndex(toolsIndexPath, toolFileName, exportName) {
|
|
|
201
217
|
.split(',')
|
|
202
218
|
.map((e) => e.trim())
|
|
203
219
|
.filter(Boolean);
|
|
204
|
-
if (arrayEntries.includes(
|
|
220
|
+
if (arrayEntries.includes(arrayExpression))
|
|
205
221
|
return;
|
|
206
|
-
const importLine = `import { ${
|
|
222
|
+
const importLine = `import { ${importSpecifier} } from './${toolFileName}.js'\n`;
|
|
207
223
|
const lastImportMatch = [...source.matchAll(/^import .+\n/gm)].pop();
|
|
208
224
|
const insertAt = lastImportMatch ? lastImportMatch.index + lastImportMatch[0].length : 0;
|
|
209
|
-
const
|
|
210
|
-
const
|
|
225
|
+
const preambleBlock = preamble ? `${preamble}\n` : '';
|
|
226
|
+
const withImport = source.slice(0, insertAt) + importLine + preambleBlock + source.slice(insertAt);
|
|
227
|
+
const newEntries = [...arrayEntries, arrayExpression].join(', ');
|
|
211
228
|
const withEntry = withImport.replace(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/, `export const tools: ToolDefinition[] = [${newEntries}]`);
|
|
212
229
|
writeFileSync(toolsIndexPath, withEntry);
|
|
213
230
|
}
|
|
214
|
-
/** addToolToIndex's own undo — removes the named tool's import line
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
* since). No-ops (rather
|
|
223
|
-
*
|
|
224
|
-
|
|
231
|
+
/** addToolToIndex's own undo — removes the named tool's import line,
|
|
232
|
+
* wrapper `const` (if a namespaced install added one), and array entry.
|
|
233
|
+
* Best-effort, not "refuse rather than guess" like addToolToIndex: this
|
|
234
|
+
* only ever runs against a tool ability-manager.ts itself installed (so
|
|
235
|
+
* the exact shape addToolToIndex wrote is known), and the caller
|
|
236
|
+
* (removeAbility) has already deleted the tool's .ts file — leaving a
|
|
237
|
+
* stale entry behind, the previous behavior, means a dangling import
|
|
238
|
+
* that fails the next build, which is strictly worse than a no-op here
|
|
239
|
+
* if the file turns out not to match (hand-edited since). No-ops (rather
|
|
240
|
+
* than throwing) when the file is missing entirely, or the expected
|
|
241
|
+
* import/array-entry shape isn't found.
|
|
242
|
+
*
|
|
243
|
+
* `arrayExpression` (not the raw import specifier) — same value
|
|
244
|
+
* addToolToIndex's own `arrayExpression` was called with, since that's
|
|
245
|
+
* what actually appears in the array literal and (for a namespaced
|
|
246
|
+
* install) names the wrapper `const` too. The import line is matched by
|
|
247
|
+
* `toolFileName` alone, not by the exact imported binding: a namespaced
|
|
248
|
+
* install aliases its import (`X as Y`), so matching on the file path —
|
|
249
|
+
* which uniquely identifies this one tool either way — is what makes
|
|
250
|
+
* this work for both shapes without the caller having to say which one
|
|
251
|
+
* it originally was. */
|
|
252
|
+
export function removeToolFromIndex(toolsIndexPath, toolFileName, arrayExpression) {
|
|
225
253
|
if (!existsSync(toolsIndexPath))
|
|
226
254
|
return;
|
|
227
|
-
|
|
228
|
-
const importLineRe = new RegExp(`^import \\{
|
|
229
|
-
|
|
230
|
-
const
|
|
255
|
+
let source = readFileSync(toolsIndexPath, 'utf8');
|
|
256
|
+
const importLineRe = new RegExp(`^import \\{ [^}]+ \\} from '\\./${toolFileName}\\.js'\\n`, 'm');
|
|
257
|
+
source = source.replace(importLineRe, '');
|
|
258
|
+
const preambleRe = new RegExp(`^const ${arrayExpression}: ToolDefinition = \\{[^\\n]*\\}\\n`, 'm');
|
|
259
|
+
source = source.replace(preambleRe, '');
|
|
260
|
+
const arrayMatch = source.match(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/);
|
|
231
261
|
if (!arrayMatch)
|
|
232
262
|
return;
|
|
233
263
|
const remainingEntries = arrayMatch[1]
|
|
234
264
|
.split(',')
|
|
235
265
|
.map((e) => e.trim())
|
|
236
|
-
.filter((e) => e && e !==
|
|
237
|
-
const withEntry =
|
|
266
|
+
.filter((e) => e && e !== arrayExpression);
|
|
267
|
+
const withEntry = source.replace(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/, `export const tools: ToolDefinition[] = [${remainingEntries.join(', ')}]`);
|
|
238
268
|
writeFileSync(toolsIndexPath, withEntry);
|
|
239
269
|
}
|
|
240
270
|
// The spec is persisted alongside the generated .ts file, under the same
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loopengine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.27",
|
|
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",
|